From ad91e7006e41f8ee5b8dcefee30f99e8ca44133a Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 10 Jul 2019 16:59:53 +0200 Subject: WIP plugins: plugin settings on server side --- server/controllers/api/index.ts | 2 + server/controllers/api/plugins.ts | 121 +++++++++++++++++++++ server/helpers/custom-validators/video-channels.ts | 4 +- server/initializers/constants.ts | 4 +- server/lib/plugins/plugin-manager.ts | 111 ++++++++++++------- server/lib/plugins/yarn.ts | 8 +- server/middlewares/validators/plugins.ts | 89 ++++++++++++++- server/middlewares/validators/sort.ts | 5 +- server/models/server/plugin.ts | 78 ++++++++++++- server/typings/express.ts | 4 +- 10 files changed, 372 insertions(+), 54 deletions(-) create mode 100644 server/controllers/api/plugins.ts (limited to 'server') diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts index ea2615e28..0876283a2 100644 --- a/server/controllers/api/index.ts +++ b/server/controllers/api/index.ts @@ -14,6 +14,7 @@ import { searchRouter } from './search' import { overviewsRouter } from './overviews' import { videoPlaylistRouter } from './video-playlist' import { CONFIG } from '../../initializers/config' +import { pluginsRouter } from '../plugins' const apiRouter = express.Router() @@ -42,6 +43,7 @@ apiRouter.use('/videos', videosRouter) apiRouter.use('/jobs', jobsRouter) apiRouter.use('/search', searchRouter) apiRouter.use('/overviews', overviewsRouter) +apiRouter.use('/plugins', pluginsRouter) apiRouter.use('/ping', pong) apiRouter.use('/*', badRequest) diff --git a/server/controllers/api/plugins.ts b/server/controllers/api/plugins.ts new file mode 100644 index 000000000..89cc67f54 --- /dev/null +++ b/server/controllers/api/plugins.ts @@ -0,0 +1,121 @@ +import * as express from 'express' +import { getFormattedObjects } from '../../helpers/utils' +import { + asyncMiddleware, + authenticate, + ensureUserHasRight, + paginationValidator, + setDefaultPagination, + setDefaultSort +} from '../../middlewares' +import { pluginsSortValidator } from '../../middlewares/validators' +import { PluginModel } from '../../models/server/plugin' +import { UserRight } from '../../../shared/models/users' +import { + enabledPluginValidator, + installPluginValidator, + listPluginsValidator, + uninstallPluginValidator, + updatePluginSettingsValidator +} from '../../middlewares/validators/plugins' +import { PluginManager } from '../../lib/plugins/plugin-manager' +import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model' +import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model' + +const pluginRouter = express.Router() + +pluginRouter.get('/', + authenticate, + ensureUserHasRight(UserRight.MANAGE_PLUGINS), + listPluginsValidator, + paginationValidator, + pluginsSortValidator, + setDefaultSort, + setDefaultPagination, + asyncMiddleware(listPlugins) +) + +pluginRouter.get('/:pluginName/settings', + authenticate, + ensureUserHasRight(UserRight.MANAGE_PLUGINS), + asyncMiddleware(enabledPluginValidator), + asyncMiddleware(listPluginSettings) +) + +pluginRouter.put('/:pluginName/settings', + authenticate, + ensureUserHasRight(UserRight.MANAGE_PLUGINS), + updatePluginSettingsValidator, + asyncMiddleware(enabledPluginValidator), + asyncMiddleware(updatePluginSettings) +) + +pluginRouter.post('/install', + authenticate, + ensureUserHasRight(UserRight.MANAGE_PLUGINS), + installPluginValidator, + asyncMiddleware(installPlugin) +) + +pluginRouter.post('/uninstall', + authenticate, + ensureUserHasRight(UserRight.MANAGE_PLUGINS), + uninstallPluginValidator, + asyncMiddleware(uninstallPlugin) +) + +// --------------------------------------------------------------------------- + +export { + pluginRouter +} + +// --------------------------------------------------------------------------- + +async function listPlugins (req: express.Request, res: express.Response) { + const type = req.query.type + + const resultList = await PluginModel.listForApi({ + type, + start: req.query.start, + count: req.query.count, + sort: req.query.sort + }) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + +async function installPlugin (req: express.Request, res: express.Response) { + const body: InstallPlugin = req.body + + await PluginManager.Instance.install(body.npmName) + + return res.sendStatus(204) +} + +async function uninstallPlugin (req: express.Request, res: express.Response) { + const body: ManagePlugin = req.body + + await PluginManager.Instance.uninstall(body.npmName) + + return res.sendStatus(204) +} + +async function listPluginSettings (req: express.Request, res: express.Response) { + const plugin = res.locals.plugin + + const settings = await PluginManager.Instance.getSettings(plugin.name) + + return res.json({ + settings + }) +} + +async function updatePluginSettings (req: express.Request, res: express.Response) { + const plugin = res.locals.plugin + + plugin.settings = req.body.settings + await plugin.save() + + return res.sendStatus(204) +} diff --git a/server/helpers/custom-validators/video-channels.ts b/server/helpers/custom-validators/video-channels.ts index f818ce8f1..e1a2f9503 100644 --- a/server/helpers/custom-validators/video-channels.ts +++ b/server/helpers/custom-validators/video-channels.ts @@ -51,9 +51,7 @@ export { function processVideoChannelExist (videoChannel: VideoChannelModel, res: express.Response) { if (!videoChannel) { - res.status(404) - .json({ error: 'Video channel not found' }) - .end() + `` return false } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index e5f88b71d..4163fe49d 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -62,7 +62,9 @@ const SORTABLE_COLUMNS = { USER_NOTIFICATIONS: [ 'createdAt' ], - VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ] + VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ], + + PLUGINS: [ 'name', 'createdAt', 'updatedAt' ] } const OAUTH_LIFETIME = { diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts index 8496979f8..3d8375acd 100644 --- a/server/lib/plugins/plugin-manager.ts +++ b/server/lib/plugins/plugin-manager.ts @@ -1,6 +1,5 @@ import { PluginModel } from '../../models/server/plugin' import { logger } from '../../helpers/logger' -import { RegisterHookOptions } from '../../../shared/models/plugins/register.model' import { basename, join } from 'path' import { CONFIG } from '../../initializers/config' import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins' @@ -11,7 +10,9 @@ import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants' import { PluginType } from '../../../shared/models/plugins/plugin.type' import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn' import { outputFile } from 'fs-extra' -import { ServerConfigPlugin } from '../../../shared/models/server' +import { RegisterSettingOptions } from '../../../shared/models/plugins/register-setting.model' +import { RegisterHookOptions } from '../../../shared/models/plugins/register-hook.model' +import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model' export interface RegisteredPlugin { name: string @@ -43,26 +44,13 @@ export class PluginManager { private static instance: PluginManager private registeredPlugins: { [ name: string ]: RegisteredPlugin } = {} + private settings: { [ name: string ]: RegisterSettingOptions[] } = {} private hooks: { [ name: string ]: HookInformationValue[] } = {} private constructor () { } - async registerPluginsAndThemes () { - await this.resetCSSGlobalFile() - - const plugins = await PluginModel.listEnabledPluginsAndThemes() - - for (const plugin of plugins) { - try { - await this.registerPluginOrTheme(plugin) - } catch (err) { - logger.error('Cannot register plugin %s, skipping.', plugin.name, { err }) - } - } - - this.sortHooksByPriority() - } + // ###################### Getters ###################### getRegisteredPluginOrTheme (name: string) { return this.registeredPlugins[name] @@ -92,6 +80,12 @@ export class PluginManager { return this.getRegisteredPluginsOrThemes(PluginType.THEME) } + getSettings (name: string) { + return this.settings[name] || [] + } + + // ###################### Hooks ###################### + async runHook (hookName: string, param?: any) { let result = param @@ -99,8 +93,11 @@ export class PluginManager { for (const hook of this.hooks[hookName]) { try { - if (wait) result = await hook.handler(param) - else result = hook.handler() + if (wait) { + result = await hook.handler(param) + } else { + result = hook.handler() + } } catch (err) { logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err }) } @@ -109,6 +106,24 @@ export class PluginManager { return result } + // ###################### Registration ###################### + + async registerPluginsAndThemes () { + await this.resetCSSGlobalFile() + + const plugins = await PluginModel.listEnabledPluginsAndThemes() + + for (const plugin of plugins) { + try { + await this.registerPluginOrTheme(plugin) + } catch (err) { + logger.error('Cannot register plugin %s, skipping.', plugin.name, { err }) + } + } + + this.sortHooksByPriority() + } + async unregister (name: string) { const plugin = this.getRegisteredPlugin(name) @@ -133,7 +148,9 @@ export class PluginManager { await this.regeneratePluginGlobalCSS() } - async install (toInstall: string, version: string, fromDisk = false) { + // ###################### Installation ###################### + + async install (toInstall: string, version?: string, fromDisk = false) { let plugin: PluginModel let name: string @@ -206,6 +223,8 @@ export class PluginManager { logger.info('Plugin %s uninstalled.', packageName) } + // ###################### Private register ###################### + private async registerPluginOrTheme (plugin: PluginModel) { logger.info('Registering plugin or theme %s.', plugin.name) @@ -251,13 +270,25 @@ export class PluginManager { }) } + const registerSetting = (options: RegisterSettingOptions) => { + if (!this.settings[plugin.name]) this.settings[plugin.name] = [] + + this.settings[plugin.name].push(options) + } + + const settingsManager: PluginSettingsManager = { + getSetting: (name: string) => PluginModel.getSetting(plugin.name, name), + + setSetting: (name: string, value: string) => PluginModel.setSetting(plugin.name, name, value) + } + const library: PluginLibrary = require(join(pluginPath, packageJSON.library)) if (!isLibraryCodeValid(library)) { throw new Error('Library code is not valid (miss register or unregister function)') } - library.register({ registerHook }) + library.register({ registerHook, registerSetting, settingsManager }) logger.info('Add plugin %s CSS to global file.', plugin.name) @@ -266,13 +297,7 @@ export class PluginManager { return library } - private sortHooksByPriority () { - for (const hookName of Object.keys(this.hooks)) { - this.hooks[hookName].sort((a, b) => { - return b.priority - a.priority - }) - } - } + // ###################### CSS ###################### private resetCSSGlobalFile () { return outputFile(PLUGIN_GLOBAL_CSS_PATH, '') @@ -296,6 +321,26 @@ export class PluginManager { }) } + private async regeneratePluginGlobalCSS () { + await this.resetCSSGlobalFile() + + for (const key of Object.keys(this.registeredPlugins)) { + const plugin = this.registeredPlugins[key] + + await this.addCSSToGlobalFile(plugin.path, plugin.css) + } + } + + // ###################### Utils ###################### + + private sortHooksByPriority () { + for (const hookName of Object.keys(this.hooks)) { + this.hooks[hookName].sort((a, b) => { + return b.priority - a.priority + }) + } + } + private getPackageJSON (pluginName: string, pluginType: PluginType) { const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json') @@ -312,15 +357,7 @@ export class PluginManager { return name.replace(/^peertube-((theme)|(plugin))-/, '') } - private async regeneratePluginGlobalCSS () { - await this.resetCSSGlobalFile() - - for (const key of Object.keys(this.registeredPlugins)) { - const plugin = this.registeredPlugins[key] - - await this.addCSSToGlobalFile(plugin.path, plugin.css) - } - } + // ###################### Private getters ###################### private getRegisteredPluginsOrThemes (type: PluginType) { const plugins: RegisteredPlugin[] = [] diff --git a/server/lib/plugins/yarn.ts b/server/lib/plugins/yarn.ts index 35fe1625f..5fe1c5046 100644 --- a/server/lib/plugins/yarn.ts +++ b/server/lib/plugins/yarn.ts @@ -5,12 +5,14 @@ import { CONFIG } from '../../initializers/config' import { outputJSON, pathExists } from 'fs-extra' import { join } from 'path' -async function installNpmPlugin (name: string, version: string) { +async function installNpmPlugin (name: string, version?: string) { // Security check checkNpmPluginNameOrThrow(name) - checkPluginVersionOrThrow(version) + if (version) checkPluginVersionOrThrow(version) + + let toInstall = name + if (version) toInstall += `@${version}` - const toInstall = `${name}@${version}` await execYarn('add ' + toInstall) } diff --git a/server/middlewares/validators/plugins.ts b/server/middlewares/validators/plugins.ts index fcb461624..265ac7c17 100644 --- a/server/middlewares/validators/plugins.ts +++ b/server/middlewares/validators/plugins.ts @@ -1,10 +1,11 @@ import * as express from 'express' -import { param } from 'express-validator/check' +import { param, query, body } from 'express-validator/check' import { logger } from '../../helpers/logger' import { areValidationErrors } from './utils' -import { isPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins' +import { isPluginNameValid, isPluginTypeValid, isPluginVersionValid, isNpmPluginNameValid } from '../../helpers/custom-validators/plugins' import { PluginManager } from '../../lib/plugins/plugin-manager' -import { isSafePath } from '../../helpers/custom-validators/misc' +import { isBooleanValid, isSafePath } from '../../helpers/custom-validators/misc' +import { PluginModel } from '../../models/server/plugin' const servePluginStaticDirectoryValidator = [ param('pluginName').custom(isPluginNameValid).withMessage('Should have a valid plugin name'), @@ -28,8 +29,88 @@ const servePluginStaticDirectoryValidator = [ } ] +const listPluginsValidator = [ + query('type') + .optional() + .custom(isPluginTypeValid).withMessage('Should have a valid plugin type'), + query('uninstalled') + .optional() + .toBoolean() + .custom(isBooleanValid).withMessage('Should have a valid uninstalled attribute'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking listPluginsValidator parameters', { parameters: req.query }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + +const installPluginValidator = [ + body('npmName').custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking installPluginValidator parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + +const uninstallPluginValidator = [ + body('npmName').custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking managePluginValidator parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + +const enabledPluginValidator = [ + body('name').custom(isPluginNameValid).withMessage('Should have a valid plugin name'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking enabledPluginValidator parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + + const plugin = await PluginModel.load(req.body.name) + if (!plugin) { + return res.status(404) + .json({ error: 'Plugin not found' }) + .end() + } + + res.locals.plugin = plugin + + return next() + } +] + +const updatePluginSettingsValidator = [ + body('settings').exists().withMessage('Should have settings'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking enabledPluginValidator parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + // --------------------------------------------------------------------------- export { - servePluginStaticDirectoryValidator + servePluginStaticDirectoryValidator, + updatePluginSettingsValidator, + uninstallPluginValidator, + enabledPluginValidator, + installPluginValidator, + listPluginsValidator } diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index b497798d1..102db85cb 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -21,6 +21,7 @@ const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUM const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST) const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS) const SORTABLE_VIDEO_PLAYLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS) +const SORTABLE_PLUGINS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.PLUGINS) const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) @@ -41,6 +42,7 @@ const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COL const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS) const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS) const videoPlaylistsSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_COLUMNS) +const pluginsSortValidator = checkSort(SORTABLE_PLUGINS_COLUMNS) // --------------------------------------------------------------------------- @@ -63,5 +65,6 @@ export { accountsBlocklistSortValidator, serversBlocklistSortValidator, userNotificationsSortValidator, - videoPlaylistsSortValidator + videoPlaylistsSortValidator, + pluginsSortValidator } diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts index b3b8276df..059a442de 100644 --- a/server/models/server/plugin.ts +++ b/server/models/server/plugin.ts @@ -1,11 +1,20 @@ -import { AllowNull, Column, CreatedAt, DataType, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' -import { throwIfNotValid } from '../utils' +import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { getSort, throwIfNotValid } from '../utils' import { isPluginDescriptionValid, isPluginNameValid, isPluginTypeValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins' +import { PluginType } from '../../../shared/models/plugins/plugin.type' +import { PeerTubePlugin } from '../../../shared/models/plugins/peertube-plugin.model' +import { FindAndCountOptions } from 'sequelize' + +@DefaultScope(() => ({ + attributes: { + exclude: [ 'storage' ] + } +})) @Table({ tableName: 'plugin', @@ -85,14 +94,75 @@ export class PluginModel extends Model { return PluginModel.findOne(query) } - static uninstall (pluginName: string) { + static getSetting (pluginName: string, settingName: string) { + const query = { + attributes: [ 'settings' ], + where: { + name: pluginName + } + } + + return PluginModel.findOne(query) + .then(p => p.settings) + .then(settings => { + if (!settings) return undefined + + return settings[settingName] + }) + } + + static setSetting (pluginName: string, settingName: string, settingValue: string) { const query = { where: { name: pluginName } } - return PluginModel.update({ enabled: false, uninstalled: true }, query) + const toSave = { + [`settings.${settingName}`]: settingValue + } + + return PluginModel.update(toSave, query) + .then(() => undefined) + } + + static listForApi (options: { + type?: PluginType, + uninstalled?: boolean, + start: number, + count: number, + sort: string + }) { + const query: FindAndCountOptions = { + offset: options.start, + limit: options.count, + order: getSort(options.sort), + where: {} + } + + if (options.type) query.where['type'] = options.type + if (options.uninstalled) query.where['uninstalled'] = options.uninstalled + + return PluginModel + .findAndCountAll(query) + .then(({ rows, count }) => { + return { total: count, data: rows } + }) + } + + toFormattedJSON (): PeerTubePlugin { + return { + name: this.name, + type: this.type, + version: this.version, + enabled: this.enabled, + uninstalled: this.uninstalled, + peertubeEngine: this.peertubeEngine, + description: this.description, + settings: this.settings, + createdAt: this.createdAt, + updatedAt: this.updatedAt + } } } diff --git a/server/typings/express.ts b/server/typings/express.ts index aec10b606..3bffc1e9a 100644 --- a/server/typings/express.ts +++ b/server/typings/express.ts @@ -21,10 +21,10 @@ import { VideoBlacklistModel } from '../models/video/video-blacklist' import { VideoCaptionModel } from '../models/video/video-caption' import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' import { RegisteredPlugin } from '../lib/plugins/plugin-manager' +import { PluginModel } from '../models/server/plugin' declare module 'express' { - interface Response { locals: { video?: VideoModel @@ -81,6 +81,8 @@ declare module 'express' { authenticated?: boolean registeredPlugin?: RegisteredPlugin + + plugin?: PluginModel } } } -- cgit v1.2.3