aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-07-05 15:28:49 +0200
committerChocobozzz <chocobozzz@cpy.re>2019-07-24 10:58:16 +0200
commitf023a19c3eeeea2b014b47fae522a62eab320048 (patch)
tree988ff97432663db928f1e3e3f498da856e739de1
parent345da516fae80f24c90c2196e96393b489af2243 (diff)
downloadPeerTube-f023a19c3eeeea2b014b47fae522a62eab320048.tar.gz
PeerTube-f023a19c3eeeea2b014b47fae522a62eab320048.tar.zst
PeerTube-f023a19c3eeeea2b014b47fae522a62eab320048.zip
WIP plugins: install/uninstall
-rw-r--r--package.json2
-rwxr-xr-xscripts/plugin/install.ts39
-rw-r--r--server.ts4
-rw-r--r--server/helpers/core-utils.ts13
-rw-r--r--server/helpers/custom-validators/misc.ts2
-rw-r--r--server/helpers/custom-validators/plugins.ts12
-rw-r--r--server/initializers/database.ts4
-rw-r--r--server/lib/plugins/plugin-manager.ts76
-rw-r--r--server/lib/plugins/yarn.ts61
-rw-r--r--server/models/server/plugin.ts11
-rw-r--r--shared/models/plugins/plugin-package-json.model.ts1
11 files changed, 216 insertions, 9 deletions
diff --git a/package.json b/package.json
index ee12718c7..fde913574 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,8 @@
32 "clean:server:test": "scripty", 32 "clean:server:test": "scripty",
33 "watch:client": "scripty", 33 "watch:client": "scripty",
34 "watch:server": "scripty", 34 "watch:server": "scripty",
35 "plugin:install": "node ./dist/scripts/plugin/install.js",
36 "plugin:uninstall": "node ./dist/scripts/plugin/uninstall.js",
35 "danger:clean:dev": "scripty", 37 "danger:clean:dev": "scripty",
36 "danger:clean:prod": "scripty", 38 "danger:clean:prod": "scripty",
37 "danger:clean:modules": "scripty", 39 "danger:clean:modules": "scripty",
diff --git a/scripts/plugin/install.ts b/scripts/plugin/install.ts
new file mode 100755
index 000000000..8e9c9897f
--- /dev/null
+++ b/scripts/plugin/install.ts
@@ -0,0 +1,39 @@
1import { initDatabaseModels } from '../../server/initializers/database'
2import * as program from 'commander'
3import { PluginManager } from '../../server/lib/plugins/plugin-manager'
4import { isAbsolute } from 'path'
5
6program
7 .option('-n, --pluginName [pluginName]', 'Plugin name to install')
8 .option('-v, --pluginVersion [pluginVersion]', 'Plugin version to install')
9 .option('-p, --pluginPath [pluginPath]', 'Path of the plugin you want to install')
10 .parse(process.argv)
11
12if (!program['pluginName'] && !program['pluginPath']) {
13 console.error('You need to specify a plugin name with the desired version, or a plugin path.')
14 process.exit(-1)
15}
16
17if (program['pluginName'] && !program['pluginVersion']) {
18 console.error('You need to specify a the version of the plugin you want to install.')
19 process.exit(-1)
20}
21
22if (program['pluginPath'] && !isAbsolute(program['pluginPath'])) {
23 console.error('Plugin path should be absolute.')
24 process.exit(-1)
25}
26
27run()
28 .then(() => process.exit(0))
29 .catch(err => {
30 console.error(err)
31 process.exit(-1)
32 })
33
34async function run () {
35 await initDatabaseModels(true)
36
37 const toInstall = program['pluginName'] || program['pluginPath']
38 await PluginManager.Instance.install(toInstall, program['pluginVersion'], !!program['pluginPath'])
39}
diff --git a/server.ts b/server.ts
index 2f5f39db2..4d20faa9b 100644
--- a/server.ts
+++ b/server.ts
@@ -1,4 +1,6 @@
1// FIXME: https://github.com/nodejs/node/pull/16853 1// FIXME: https://github.com/nodejs/node/pull/16853
2import { PluginManager } from './server/lib/plugins/plugin-manager'
3
2require('tls').DEFAULT_ECDH_CURVE = 'auto' 4require('tls').DEFAULT_ECDH_CURVE = 'auto'
3 5
4import { isTestInstance } from './server/helpers/core-utils' 6import { isTestInstance } from './server/helpers/core-utils'
@@ -259,6 +261,8 @@ async function startApplication () {
259 updateStreamingPlaylistsInfohashesIfNeeded() 261 updateStreamingPlaylistsInfohashesIfNeeded()
260 .catch(err => logger.error('Cannot update streaming playlist infohashes.', { err })) 262 .catch(err => logger.error('Cannot update streaming playlist infohashes.', { err }))
261 263
264 await PluginManager.Instance.registerPlugins()
265
262 // Make server listening 266 // Make server listening
263 server.listen(port, hostname, () => { 267 server.listen(port, hostname, () => {
264 logger.info('Server listening on %s:%d', hostname, port) 268 logger.info('Server listening on %s:%d', hostname, port)
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index b1e9af0a1..c5b139378 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -10,7 +10,7 @@ import { isAbsolute, join } from 'path'
10import * as pem from 'pem' 10import * as pem from 'pem'
11import { URL } from 'url' 11import { URL } from 'url'
12import { truncate } from 'lodash' 12import { truncate } from 'lodash'
13import { exec } from 'child_process' 13import { exec, ExecOptions } from 'child_process'
14 14
15const objectConverter = (oldObject: any, keyConverter: (e: string) => string, valueConverter: (e: any) => any) => { 15const objectConverter = (oldObject: any, keyConverter: (e: string) => string, valueConverter: (e: any) => any) => {
16 if (!oldObject || typeof oldObject !== 'object') { 16 if (!oldObject || typeof oldObject !== 'object') {
@@ -204,6 +204,16 @@ function sha1 (str: string | Buffer, encoding: HexBase64Latin1Encoding = 'hex')
204 return createHash('sha1').update(str).digest(encoding) 204 return createHash('sha1').update(str).digest(encoding)
205} 205}
206 206
207function execShell (command: string, options?: ExecOptions) {
208 return new Promise<{ err?: Error, stdout: string, stderr: string }>((res, rej) => {
209 exec(command, options, (err, stdout, stderr) => {
210 if (err) return rej({ err, stdout, stderr })
211
212 return res({ stdout, stderr })
213 })
214 })
215}
216
207function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> { 217function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> {
208 return function promisified (): Promise<A> { 218 return function promisified (): Promise<A> {
209 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => { 219 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
@@ -269,6 +279,7 @@ export {
269 sanitizeUrl, 279 sanitizeUrl,
270 sanitizeHost, 280 sanitizeHost,
271 buildPath, 281 buildPath,
282 execShell,
272 peertubeTruncate, 283 peertubeTruncate,
273 284
274 sha256, 285 sha256,
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index f72513c1c..3ef38fce1 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -9,7 +9,7 @@ function exists (value: any) {
9function isSafePath (p: string) { 9function isSafePath (p: string) {
10 return exists(p) && 10 return exists(p) &&
11 (p + '').split(sep).every(part => { 11 (p + '').split(sep).every(part => {
12 return [ '', '.', '..' ].includes(part) === false 12 return [ '..' ].includes(part) === false
13 }) 13 })
14} 14}
15 15
diff --git a/server/helpers/custom-validators/plugins.ts b/server/helpers/custom-validators/plugins.ts
index ff687dc3f..2fcdc581f 100644
--- a/server/helpers/custom-validators/plugins.ts
+++ b/server/helpers/custom-validators/plugins.ts
@@ -17,6 +17,13 @@ function isPluginNameValid (value: string) {
17 validator.matches(value, /^[a-z\-]+$/) 17 validator.matches(value, /^[a-z\-]+$/)
18} 18}
19 19
20function isNpmPluginNameValid (value: string) {
21 return exists(value) &&
22 validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.NAME) &&
23 validator.matches(value, /^[a-z\-]+$/) &&
24 (value.startsWith('peertube-plugin-') || value.startsWith('peertube-theme-'))
25}
26
20function isPluginDescriptionValid (value: string) { 27function isPluginDescriptionValid (value: string) {
21 return exists(value) && validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.DESCRIPTION) 28 return exists(value) && validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.DESCRIPTION)
22} 29}
@@ -55,7 +62,7 @@ function isCSSPathsValid (css: any[]) {
55} 62}
56 63
57function isPackageJSONValid (packageJSON: PluginPackageJson, pluginType: PluginType) { 64function isPackageJSONValid (packageJSON: PluginPackageJson, pluginType: PluginType) {
58 return isPluginNameValid(packageJSON.name) && 65 return isNpmPluginNameValid(packageJSON.name) &&
59 isPluginDescriptionValid(packageJSON.description) && 66 isPluginDescriptionValid(packageJSON.description) &&
60 isPluginEngineValid(packageJSON.engine) && 67 isPluginEngineValid(packageJSON.engine) &&
61 isUrlValid(packageJSON.homepage) && 68 isUrlValid(packageJSON.homepage) &&
@@ -78,5 +85,6 @@ export {
78 isPluginVersionValid, 85 isPluginVersionValid,
79 isPluginNameValid, 86 isPluginNameValid,
80 isPluginDescriptionValid, 87 isPluginDescriptionValid,
81 isLibraryCodeValid 88 isLibraryCodeValid,
89 isNpmPluginNameValid
82} 90}
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 142063a99..a7988d75b 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -37,6 +37,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
37import { VideoPlaylistModel } from '../models/video/video-playlist' 37import { VideoPlaylistModel } from '../models/video/video-playlist'
38import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' 38import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
39import { ThumbnailModel } from '../models/video/thumbnail' 39import { ThumbnailModel } from '../models/video/thumbnail'
40import { PluginModel } from '../models/server/plugin'
40import { QueryTypes, Transaction } from 'sequelize' 41import { QueryTypes, Transaction } from 'sequelize'
41 42
42require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 43require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@@ -107,7 +108,8 @@ async function initDatabaseModels (silent: boolean) {
107 VideoStreamingPlaylistModel, 108 VideoStreamingPlaylistModel,
108 VideoPlaylistModel, 109 VideoPlaylistModel,
109 VideoPlaylistElementModel, 110 VideoPlaylistElementModel,
110 ThumbnailModel 111 ThumbnailModel,
112 PluginModel
111 ]) 113 ])
112 114
113 // Check extensions exist in the database 115 // Check extensions exist in the database
diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts
index b48ecc991..533ed4391 100644
--- a/server/lib/plugins/plugin-manager.ts
+++ b/server/lib/plugins/plugin-manager.ts
@@ -1,7 +1,7 @@
1import { PluginModel } from '../../models/server/plugin' 1import { PluginModel } from '../../models/server/plugin'
2import { logger } from '../../helpers/logger' 2import { logger } from '../../helpers/logger'
3import { RegisterHookOptions } from '../../../shared/models/plugins/register.model' 3import { RegisterHookOptions } from '../../../shared/models/plugins/register.model'
4import { join } from 'path' 4import { basename, join } from 'path'
5import { CONFIG } from '../../initializers/config' 5import { CONFIG } from '../../initializers/config'
6import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins' 6import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
7import { PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model' 7import { PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model'
@@ -9,6 +9,7 @@ import { PluginLibrary } from '../../../shared/models/plugins/plugin-library.mod
9import { createReadStream, createWriteStream } from 'fs' 9import { createReadStream, createWriteStream } from 'fs'
10import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants' 10import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
11import { PluginType } from '../../../shared/models/plugins/plugin.type' 11import { PluginType } from '../../../shared/models/plugins/plugin.type'
12import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
12 13
13export interface RegisteredPlugin { 14export interface RegisteredPlugin {
14 name: string 15 name: string
@@ -84,11 +85,63 @@ export class PluginManager {
84 await plugin.unregister() 85 await plugin.unregister()
85 } 86 }
86 87
88 async install (toInstall: string, version: string, fromDisk = false) {
89 let plugin: PluginModel
90 let name: string
91
92 logger.info('Installing plugin %s.', toInstall)
93
94 try {
95 fromDisk
96 ? await installNpmPluginFromDisk(toInstall)
97 : await installNpmPlugin(toInstall, version)
98
99 name = fromDisk ? basename(toInstall) : toInstall
100 const pluginType = name.startsWith('peertube-theme-') ? PluginType.THEME : PluginType.PLUGIN
101 const pluginName = this.normalizePluginName(name)
102
103 const packageJSON = this.getPackageJSON(pluginName, pluginType)
104 if (!isPackageJSONValid(packageJSON, pluginType)) {
105 throw new Error('PackageJSON is invalid.')
106 }
107
108 [ plugin ] = await PluginModel.upsert({
109 name: pluginName,
110 description: packageJSON.description,
111 type: pluginType,
112 version: packageJSON.version,
113 enabled: true,
114 uninstalled: false,
115 peertubeEngine: packageJSON.engine.peertube
116 }, { returning: true })
117 } catch (err) {
118 logger.error('Cannot install plugin %s, removing it...', toInstall, { err })
119
120 try {
121 await removeNpmPlugin(name)
122 } catch (err) {
123 logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err })
124 }
125
126 throw err
127 }
128
129 logger.info('Successful installation of plugin %s.', toInstall)
130
131 await this.registerPluginOrTheme(plugin)
132 }
133
134 async uninstall (packageName: string) {
135 await PluginModel.uninstall(this.normalizePluginName(packageName))
136
137 await removeNpmPlugin(packageName)
138 }
139
87 private async registerPluginOrTheme (plugin: PluginModel) { 140 private async registerPluginOrTheme (plugin: PluginModel) {
88 logger.info('Registering plugin or theme %s.', plugin.name) 141 logger.info('Registering plugin or theme %s.', plugin.name)
89 142
90 const pluginPath = join(CONFIG.STORAGE.PLUGINS_DIR, plugin.name, plugin.version) 143 const packageJSON = this.getPackageJSON(plugin.name, plugin.type)
91 const packageJSON: PluginPackageJson = require(join(pluginPath, 'package.json')) 144 const pluginPath = this.getPluginPath(plugin.name, plugin.type)
92 145
93 if (!isPackageJSONValid(packageJSON, plugin.type)) { 146 if (!isPackageJSONValid(packageJSON, plugin.type)) {
94 throw new Error('Package.JSON is invalid.') 147 throw new Error('Package.JSON is invalid.')
@@ -124,6 +177,7 @@ export class PluginManager {
124 } 177 }
125 178
126 const library: PluginLibrary = require(join(pluginPath, packageJSON.library)) 179 const library: PluginLibrary = require(join(pluginPath, packageJSON.library))
180
127 if (!isLibraryCodeValid(library)) { 181 if (!isLibraryCodeValid(library)) {
128 throw new Error('Library code is not valid (miss register or unregister function)') 182 throw new Error('Library code is not valid (miss register or unregister function)')
129 } 183 }
@@ -163,6 +217,22 @@ export class PluginManager {
163 }) 217 })
164 } 218 }
165 219
220 private getPackageJSON (pluginName: string, pluginType: PluginType) {
221 const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json')
222
223 return require(pluginPath) as PluginPackageJson
224 }
225
226 private getPluginPath (pluginName: string, pluginType: PluginType) {
227 const prefix = pluginType === PluginType.PLUGIN ? 'peertube-plugin-' : 'peertube-theme-'
228
229 return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', prefix + pluginName)
230 }
231
232 private normalizePluginName (name: string) {
233 return name.replace(/^peertube-((theme)|(plugin))-/, '')
234 }
235
166 static get Instance () { 236 static get Instance () {
167 return this.instance || (this.instance = new this()) 237 return this.instance || (this.instance = new this())
168 } 238 }
diff --git a/server/lib/plugins/yarn.ts b/server/lib/plugins/yarn.ts
new file mode 100644
index 000000000..35fe1625f
--- /dev/null
+++ b/server/lib/plugins/yarn.ts
@@ -0,0 +1,61 @@
1import { execShell } from '../../helpers/core-utils'
2import { logger } from '../../helpers/logger'
3import { isNpmPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins'
4import { CONFIG } from '../../initializers/config'
5import { outputJSON, pathExists } from 'fs-extra'
6import { join } from 'path'
7
8async function installNpmPlugin (name: string, version: string) {
9 // Security check
10 checkNpmPluginNameOrThrow(name)
11 checkPluginVersionOrThrow(version)
12
13 const toInstall = `${name}@${version}`
14 await execYarn('add ' + toInstall)
15}
16
17async function installNpmPluginFromDisk (path: string) {
18 await execYarn('add file:' + path)
19}
20
21async function removeNpmPlugin (name: string) {
22 checkNpmPluginNameOrThrow(name)
23
24 await execYarn('remove ' + name)
25}
26
27// ############################################################################
28
29export {
30 installNpmPlugin,
31 installNpmPluginFromDisk,
32 removeNpmPlugin
33}
34
35// ############################################################################
36
37async function execYarn (command: string) {
38 try {
39 const pluginDirectory = CONFIG.STORAGE.PLUGINS_DIR
40 const pluginPackageJSON = join(pluginDirectory, 'package.json')
41
42 // Create empty package.json file if needed
43 if (!await pathExists(pluginPackageJSON)) {
44 await outputJSON(pluginPackageJSON, {})
45 }
46
47 await execShell(`yarn ${command}`, { cwd: pluginDirectory })
48 } catch (result) {
49 logger.error('Cannot exec yarn.', { command, err: result.err, stderr: result.stderr })
50
51 throw result.err
52 }
53}
54
55function checkNpmPluginNameOrThrow (name: string) {
56 if (!isNpmPluginNameValid(name)) throw new Error('Invalid NPM plugin name to install')
57}
58
59function checkPluginVersionOrThrow (name: string) {
60 if (!isPluginVersionValid(name)) throw new Error('Invalid NPM plugin version to install')
61}
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts
index 7ce376d13..1fbfd208f 100644
--- a/server/models/server/plugin.ts
+++ b/server/models/server/plugin.ts
@@ -42,7 +42,6 @@ export class PluginModel extends Model<PluginModel> {
42 uninstalled: boolean 42 uninstalled: boolean
43 43
44 @AllowNull(false) 44 @AllowNull(false)
45 @Is('PluginPeertubeEngine', value => throwIfNotValid(value, isPluginVersionValid, 'peertubeEngine'))
46 @Column 45 @Column
47 peertubeEngine: string 46 peertubeEngine: string
48 47
@@ -76,4 +75,14 @@ export class PluginModel extends Model<PluginModel> {
76 return PluginModel.findAll(query) 75 return PluginModel.findAll(query)
77 } 76 }
78 77
78 static uninstall (pluginName: string) {
79 const query = {
80 where: {
81 name: pluginName
82 }
83 }
84
85 return PluginModel.update({ enabled: false, uninstalled: true }, query)
86 }
87
79} 88}
diff --git a/shared/models/plugins/plugin-package-json.model.ts b/shared/models/plugins/plugin-package-json.model.ts
index 4520ee181..d5aa90179 100644
--- a/shared/models/plugins/plugin-package-json.model.ts
+++ b/shared/models/plugins/plugin-package-json.model.ts
@@ -1,5 +1,6 @@
1export type PluginPackageJson = { 1export type PluginPackageJson = {
2 name: string 2 name: string
3 version: string
3 description: string 4 description: string
4 engine: { peertube: string }, 5 engine: { peertube: string },
5 6