"clean:server:test": "scripty",
"watch:client": "scripty",
"watch:server": "scripty",
+ "plugin:install": "node ./dist/scripts/plugin/install.js",
+ "plugin:uninstall": "node ./dist/scripts/plugin/uninstall.js",
"danger:clean:dev": "scripty",
"danger:clean:prod": "scripty",
"danger:clean:modules": "scripty",
--- /dev/null
+import { initDatabaseModels } from '../../server/initializers/database'
+import * as program from 'commander'
+import { PluginManager } from '../../server/lib/plugins/plugin-manager'
+import { isAbsolute } from 'path'
+
+program
+ .option('-n, --pluginName [pluginName]', 'Plugin name to install')
+ .option('-v, --pluginVersion [pluginVersion]', 'Plugin version to install')
+ .option('-p, --pluginPath [pluginPath]', 'Path of the plugin you want to install')
+ .parse(process.argv)
+
+if (!program['pluginName'] && !program['pluginPath']) {
+ console.error('You need to specify a plugin name with the desired version, or a plugin path.')
+ process.exit(-1)
+}
+
+if (program['pluginName'] && !program['pluginVersion']) {
+ console.error('You need to specify a the version of the plugin you want to install.')
+ process.exit(-1)
+}
+
+if (program['pluginPath'] && !isAbsolute(program['pluginPath'])) {
+ console.error('Plugin path should be absolute.')
+ process.exit(-1)
+}
+
+run()
+ .then(() => process.exit(0))
+ .catch(err => {
+ console.error(err)
+ process.exit(-1)
+ })
+
+async function run () {
+ await initDatabaseModels(true)
+
+ const toInstall = program['pluginName'] || program['pluginPath']
+ await PluginManager.Instance.install(toInstall, program['pluginVersion'], !!program['pluginPath'])
+}
// FIXME: https://github.com/nodejs/node/pull/16853
+import { PluginManager } from './server/lib/plugins/plugin-manager'
+
require('tls').DEFAULT_ECDH_CURVE = 'auto'
import { isTestInstance } from './server/helpers/core-utils'
updateStreamingPlaylistsInfohashesIfNeeded()
.catch(err => logger.error('Cannot update streaming playlist infohashes.', { err }))
+ await PluginManager.Instance.registerPlugins()
+
// Make server listening
server.listen(port, hostname, () => {
logger.info('Server listening on %s:%d', hostname, port)
import * as pem from 'pem'
import { URL } from 'url'
import { truncate } from 'lodash'
-import { exec } from 'child_process'
+import { exec, ExecOptions } from 'child_process'
const objectConverter = (oldObject: any, keyConverter: (e: string) => string, valueConverter: (e: any) => any) => {
if (!oldObject || typeof oldObject !== 'object') {
return createHash('sha1').update(str).digest(encoding)
}
+function execShell (command: string, options?: ExecOptions) {
+ return new Promise<{ err?: Error, stdout: string, stderr: string }>((res, rej) => {
+ exec(command, options, (err, stdout, stderr) => {
+ if (err) return rej({ err, stdout, stderr })
+
+ return res({ stdout, stderr })
+ })
+ })
+}
+
function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> {
return function promisified (): Promise<A> {
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
sanitizeUrl,
sanitizeHost,
buildPath,
+ execShell,
peertubeTruncate,
sha256,
function isSafePath (p: string) {
return exists(p) &&
(p + '').split(sep).every(part => {
- return [ '', '.', '..' ].includes(part) === false
+ return [ '..' ].includes(part) === false
})
}
validator.matches(value, /^[a-z\-]+$/)
}
+function isNpmPluginNameValid (value: string) {
+ return exists(value) &&
+ validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.NAME) &&
+ validator.matches(value, /^[a-z\-]+$/) &&
+ (value.startsWith('peertube-plugin-') || value.startsWith('peertube-theme-'))
+}
+
function isPluginDescriptionValid (value: string) {
return exists(value) && validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.DESCRIPTION)
}
}
function isPackageJSONValid (packageJSON: PluginPackageJson, pluginType: PluginType) {
- return isPluginNameValid(packageJSON.name) &&
+ return isNpmPluginNameValid(packageJSON.name) &&
isPluginDescriptionValid(packageJSON.description) &&
isPluginEngineValid(packageJSON.engine) &&
isUrlValid(packageJSON.homepage) &&
isPluginVersionValid,
isPluginNameValid,
isPluginDescriptionValid,
- isLibraryCodeValid
+ isLibraryCodeValid,
+ isNpmPluginNameValid
}
import { VideoPlaylistModel } from '../models/video/video-playlist'
import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
import { ThumbnailModel } from '../models/video/thumbnail'
+import { PluginModel } from '../models/server/plugin'
import { QueryTypes, Transaction } from 'sequelize'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
VideoStreamingPlaylistModel,
VideoPlaylistModel,
VideoPlaylistElementModel,
- ThumbnailModel
+ ThumbnailModel,
+ PluginModel
])
// Check extensions exist in the database
import { PluginModel } from '../../models/server/plugin'
import { logger } from '../../helpers/logger'
import { RegisterHookOptions } from '../../../shared/models/plugins/register.model'
-import { join } from 'path'
+import { basename, join } from 'path'
import { CONFIG } from '../../initializers/config'
import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
import { PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model'
import { createReadStream, createWriteStream } from 'fs'
import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
import { PluginType } from '../../../shared/models/plugins/plugin.type'
+import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
export interface RegisteredPlugin {
name: string
await plugin.unregister()
}
+ async install (toInstall: string, version: string, fromDisk = false) {
+ let plugin: PluginModel
+ let name: string
+
+ logger.info('Installing plugin %s.', toInstall)
+
+ try {
+ fromDisk
+ ? await installNpmPluginFromDisk(toInstall)
+ : await installNpmPlugin(toInstall, version)
+
+ name = fromDisk ? basename(toInstall) : toInstall
+ const pluginType = name.startsWith('peertube-theme-') ? PluginType.THEME : PluginType.PLUGIN
+ const pluginName = this.normalizePluginName(name)
+
+ const packageJSON = this.getPackageJSON(pluginName, pluginType)
+ if (!isPackageJSONValid(packageJSON, pluginType)) {
+ throw new Error('PackageJSON is invalid.')
+ }
+
+ [ plugin ] = await PluginModel.upsert({
+ name: pluginName,
+ description: packageJSON.description,
+ type: pluginType,
+ version: packageJSON.version,
+ enabled: true,
+ uninstalled: false,
+ peertubeEngine: packageJSON.engine.peertube
+ }, { returning: true })
+ } catch (err) {
+ logger.error('Cannot install plugin %s, removing it...', toInstall, { err })
+
+ try {
+ await removeNpmPlugin(name)
+ } catch (err) {
+ logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err })
+ }
+
+ throw err
+ }
+
+ logger.info('Successful installation of plugin %s.', toInstall)
+
+ await this.registerPluginOrTheme(plugin)
+ }
+
+ async uninstall (packageName: string) {
+ await PluginModel.uninstall(this.normalizePluginName(packageName))
+
+ await removeNpmPlugin(packageName)
+ }
+
private async registerPluginOrTheme (plugin: PluginModel) {
logger.info('Registering plugin or theme %s.', plugin.name)
- const pluginPath = join(CONFIG.STORAGE.PLUGINS_DIR, plugin.name, plugin.version)
- const packageJSON: PluginPackageJson = require(join(pluginPath, 'package.json'))
+ const packageJSON = this.getPackageJSON(plugin.name, plugin.type)
+ const pluginPath = this.getPluginPath(plugin.name, plugin.type)
if (!isPackageJSONValid(packageJSON, plugin.type)) {
throw new Error('Package.JSON is invalid.')
}
const library: PluginLibrary = require(join(pluginPath, packageJSON.library))
+
if (!isLibraryCodeValid(library)) {
throw new Error('Library code is not valid (miss register or unregister function)')
}
})
}
+ private getPackageJSON (pluginName: string, pluginType: PluginType) {
+ const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json')
+
+ return require(pluginPath) as PluginPackageJson
+ }
+
+ private getPluginPath (pluginName: string, pluginType: PluginType) {
+ const prefix = pluginType === PluginType.PLUGIN ? 'peertube-plugin-' : 'peertube-theme-'
+
+ return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', prefix + pluginName)
+ }
+
+ private normalizePluginName (name: string) {
+ return name.replace(/^peertube-((theme)|(plugin))-/, '')
+ }
+
static get Instance () {
return this.instance || (this.instance = new this())
}
--- /dev/null
+import { execShell } from '../../helpers/core-utils'
+import { logger } from '../../helpers/logger'
+import { isNpmPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins'
+import { CONFIG } from '../../initializers/config'
+import { outputJSON, pathExists } from 'fs-extra'
+import { join } from 'path'
+
+async function installNpmPlugin (name: string, version: string) {
+ // Security check
+ checkNpmPluginNameOrThrow(name)
+ checkPluginVersionOrThrow(version)
+
+ const toInstall = `${name}@${version}`
+ await execYarn('add ' + toInstall)
+}
+
+async function installNpmPluginFromDisk (path: string) {
+ await execYarn('add file:' + path)
+}
+
+async function removeNpmPlugin (name: string) {
+ checkNpmPluginNameOrThrow(name)
+
+ await execYarn('remove ' + name)
+}
+
+// ############################################################################
+
+export {
+ installNpmPlugin,
+ installNpmPluginFromDisk,
+ removeNpmPlugin
+}
+
+// ############################################################################
+
+async function execYarn (command: string) {
+ try {
+ const pluginDirectory = CONFIG.STORAGE.PLUGINS_DIR
+ const pluginPackageJSON = join(pluginDirectory, 'package.json')
+
+ // Create empty package.json file if needed
+ if (!await pathExists(pluginPackageJSON)) {
+ await outputJSON(pluginPackageJSON, {})
+ }
+
+ await execShell(`yarn ${command}`, { cwd: pluginDirectory })
+ } catch (result) {
+ logger.error('Cannot exec yarn.', { command, err: result.err, stderr: result.stderr })
+
+ throw result.err
+ }
+}
+
+function checkNpmPluginNameOrThrow (name: string) {
+ if (!isNpmPluginNameValid(name)) throw new Error('Invalid NPM plugin name to install')
+}
+
+function checkPluginVersionOrThrow (name: string) {
+ if (!isPluginVersionValid(name)) throw new Error('Invalid NPM plugin version to install')
+}
uninstalled: boolean
@AllowNull(false)
- @Is('PluginPeertubeEngine', value => throwIfNotValid(value, isPluginVersionValid, 'peertubeEngine'))
@Column
peertubeEngine: string
return PluginModel.findAll(query)
}
+ static uninstall (pluginName: string) {
+ const query = {
+ where: {
+ name: pluginName
+ }
+ }
+
+ return PluginModel.update({ enabled: false, uninstalled: true }, query)
+ }
+
}
export type PluginPackageJson = {
name: string
+ version: string
description: string
engine: { peertube: string },