From 5e2b2e2775421cd98286d6e2f75cf38aae7a212c Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 10 Apr 2020 15:07:54 +0200 Subject: [PATCH] Add ability for plugins to add custom routes --- server/controllers/plugins.ts | 41 ++- server/lib/plugins/plugin-manager.ts | 51 ++-- server/lib/plugins/register-helpers-store.ts | 235 ++++++++++++++++++ server/lib/plugins/register-helpers.ts | 180 -------------- server/middlewares/validators/plugins.ts | 48 ++-- .../peertube-plugin-test-five/main.js | 21 ++ .../peertube-plugin-test-five/package.json | 20 ++ server/tests/plugins/index.ts | 1 + server/tests/plugins/plugin-router.ts | 91 +++++++ .../plugins/register-server-option.model.ts | 7 + support/doc/plugins/guide.md | 19 +- 11 files changed, 482 insertions(+), 232 deletions(-) create mode 100644 server/lib/plugins/register-helpers-store.ts delete mode 100644 server/lib/plugins/register-helpers.ts create mode 100644 server/tests/fixtures/peertube-plugin-test-five/main.js create mode 100644 server/tests/fixtures/peertube-plugin-test-five/package.json create mode 100644 server/tests/plugins/plugin-router.ts diff --git a/server/controllers/plugins.ts b/server/controllers/plugins.ts index 1caee9a29..1fc49b646 100644 --- a/server/controllers/plugins.ts +++ b/server/controllers/plugins.ts @@ -2,7 +2,7 @@ import * as express from 'express' import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants' import { join } from 'path' import { PluginManager, RegisteredPlugin } from '../lib/plugins/plugin-manager' -import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins' +import { getPluginValidator, pluginStaticDirectoryValidator } from '../middlewares/validators/plugins' import { serveThemeCSSValidator } from '../middlewares/validators/themes' import { PluginType } from '../../shared/models/plugins/plugin.type' import { isTestInstance } from '../helpers/core-utils' @@ -24,22 +24,36 @@ pluginsRouter.get('/plugins/translations/:locale.json', ) pluginsRouter.get('/plugins/:pluginName/:pluginVersion/static/:staticEndpoint(*)', - servePluginStaticDirectoryValidator(PluginType.PLUGIN), + getPluginValidator(PluginType.PLUGIN), + pluginStaticDirectoryValidator, servePluginStaticDirectory ) pluginsRouter.get('/plugins/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', - servePluginStaticDirectoryValidator(PluginType.PLUGIN), + getPluginValidator(PluginType.PLUGIN), + pluginStaticDirectoryValidator, servePluginClientScripts ) +pluginsRouter.use('/plugins/:pluginName/router', + getPluginValidator(PluginType.PLUGIN, false), + servePluginCustomRoutes +) + +pluginsRouter.use('/plugins/:pluginName/:pluginVersion/router', + getPluginValidator(PluginType.PLUGIN), + servePluginCustomRoutes +) + pluginsRouter.get('/themes/:pluginName/:pluginVersion/static/:staticEndpoint(*)', - servePluginStaticDirectoryValidator(PluginType.THEME), + getPluginValidator(PluginType.THEME), + pluginStaticDirectoryValidator, servePluginStaticDirectory ) pluginsRouter.get('/themes/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', - servePluginStaticDirectoryValidator(PluginType.THEME), + getPluginValidator(PluginType.THEME), + pluginStaticDirectoryValidator, servePluginClientScripts ) @@ -85,22 +99,27 @@ function servePluginStaticDirectory (req: express.Request, res: express.Response const [ directory, ...file ] = staticEndpoint.split('/') const staticPath = plugin.staticDirs[directory] - if (!staticPath) { - return res.sendStatus(404) - } + if (!staticPath) return res.sendStatus(404) const filepath = file.join('/') return res.sendFile(join(plugin.path, staticPath, filepath), sendFileOptions) } +function servePluginCustomRoutes (req: express.Request, res: express.Response, next: express.NextFunction) { + const plugin: RegisteredPlugin = res.locals.registeredPlugin + const router = PluginManager.Instance.getRouter(plugin.npmName) + + if (!router) return res.sendStatus(404) + + return router(req, res, next) +} + function servePluginClientScripts (req: express.Request, res: express.Response) { const plugin: RegisteredPlugin = res.locals.registeredPlugin const staticEndpoint = req.params.staticEndpoint const file = plugin.clientScripts[staticEndpoint] - if (!file) { - return res.sendStatus(404) - } + if (!file) return res.sendStatus(404) return res.sendFile(join(plugin.path, staticEndpoint), sendFileOptions) } diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts index 44530d203..37fb07716 100644 --- a/server/lib/plugins/plugin-manager.ts +++ b/server/lib/plugins/plugin-manager.ts @@ -13,15 +13,14 @@ import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants' import { PluginType } from '../../../shared/models/plugins/plugin.type' import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn' import { outputFile, readJSON } from 'fs-extra' -import { ServerHook, ServerHookName, serverHookObject } from '../../../shared/models/plugins/server-hook.model' +import { ServerHook, ServerHookName } from '../../../shared/models/plugins/server-hook.model' import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks' import { RegisterServerOptions } from '../../typings/plugins/register-server-option.model' import { PluginLibrary } from '../../typings/plugins' import { ClientHtml } from '../client-html' -import { RegisterServerHookOptions } from '../../../shared/models/plugins/register-server-hook.model' -import { RegisterServerSettingOptions } from '../../../shared/models/plugins/register-server-setting.model' import { PluginTranslation } from '../../../shared/models/plugins/plugin-translation.model' -import { buildRegisterHelpers, reinitVideoConstants } from './register-helpers' +import { RegisterHelpersStore } from './register-helpers-store' +import { RegisterServerHookOptions } from '@shared/models/plugins/register-server-hook.model' export interface RegisteredPlugin { npmName: string @@ -59,10 +58,11 @@ export class PluginManager implements ServerHook { private static instance: PluginManager private registeredPlugins: { [name: string]: RegisteredPlugin } = {} - private settings: { [name: string]: RegisterServerSettingOptions[] } = {} private hooks: { [name: string]: HookInformationValue[] } = {} private translations: PluginLocalesTranslations = {} + private registerHelpersStore: { [npmName: string]: RegisterHelpersStore } = {} + private constructor () { } @@ -103,7 +103,17 @@ export class PluginManager implements ServerHook { } getRegisteredSettings (npmName: string) { - return this.settings[npmName] || [] + const store = this.registerHelpersStore[npmName] + if (store) return store.getSettings() + + return [] + } + + getRouter (npmName: string) { + const store = this.registerHelpersStore[npmName] + if (!store) return null + + return store.getRouter() } getTranslations (locale: string) { @@ -164,7 +174,6 @@ export class PluginManager implements ServerHook { } delete this.registeredPlugins[plugin.npmName] - delete this.settings[plugin.npmName] this.deleteTranslations(plugin.npmName) @@ -176,7 +185,10 @@ export class PluginManager implements ServerHook { this.hooks[key] = this.hooks[key].filter(h => h.npmName !== npmName) } - reinitVideoConstants(plugin.npmName) + const store = this.registerHelpersStore[plugin.npmName] + store.reinitVideoConstants(plugin.npmName) + + delete this.registerHelpersStore[plugin.npmName] logger.info('Regenerating registered plugin CSS to global file.') await this.regeneratePluginGlobalCSS() @@ -429,34 +441,21 @@ export class PluginManager implements ServerHook { // ###################### Generate register helpers ###################### private getRegisterHelpers (npmName: string, plugin: PluginModel): RegisterServerOptions { - const registerHook = (options: RegisterServerHookOptions) => { - if (serverHookObject[options.target] !== true) { - logger.warn('Unknown hook %s of plugin %s. Skipping.', options.target, npmName) - return - } - + const onHookAdded = (options: RegisterServerHookOptions) => { if (!this.hooks[options.target]) this.hooks[options.target] = [] this.hooks[options.target].push({ - npmName, + npmName: npmName, pluginName: plugin.name, handler: options.handler, priority: options.priority || 0 }) } - const registerSetting = (options: RegisterServerSettingOptions) => { - if (!this.settings[npmName]) this.settings[npmName] = [] - - this.settings[npmName].push(options) - } - - const registerHelpers = buildRegisterHelpers(npmName, plugin) + const registerHelpersStore = new RegisterHelpersStore(npmName, plugin, onHookAdded.bind(this)) + this.registerHelpersStore[npmName] = registerHelpersStore - return Object.assign(registerHelpers, { - registerHook, - registerSetting - }) + return registerHelpersStore.buildRegisterHelpers() } private sanitizeAndCheckPackageJSONOrThrow (packageJSON: PluginPackageJson, pluginType: PluginType) { diff --git a/server/lib/plugins/register-helpers-store.ts b/server/lib/plugins/register-helpers-store.ts new file mode 100644 index 000000000..c76c0161a --- /dev/null +++ b/server/lib/plugins/register-helpers-store.ts @@ -0,0 +1,235 @@ +import { PluginSettingsManager } from '@shared/models/plugins/plugin-settings-manager.model' +import { PluginModel } from '@server/models/server/plugin' +import { PluginStorageManager } from '@shared/models/plugins/plugin-storage-manager.model' +import { PluginVideoLanguageManager } from '@shared/models/plugins/plugin-video-language-manager.model' +import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '@server/initializers/constants' +import { PluginVideoLicenceManager } from '@shared/models/plugins/plugin-video-licence-manager.model' +import { PluginVideoCategoryManager } from '@shared/models/plugins/plugin-video-category-manager.model' +import { RegisterServerOptions } from '@server/typings/plugins' +import { buildPluginHelpers } from './plugin-helpers' +import { logger } from '@server/helpers/logger' +import { RegisterServerHookOptions } from '@shared/models/plugins/register-server-hook.model' +import { serverHookObject } from '@shared/models/plugins/server-hook.model' +import { RegisterServerSettingOptions } from '@shared/models/plugins/register-server-setting.model' +import * as express from 'express' + +type AlterableVideoConstant = 'language' | 'licence' | 'category' +type VideoConstant = { [key in number | string]: string } + +type UpdatedVideoConstant = { + [name in AlterableVideoConstant]: { + added: { key: number | string, label: string }[] + deleted: { key: number | string, label: string }[] + } +} + +export class RegisterHelpersStore { + private readonly updatedVideoConstants: UpdatedVideoConstant = { + language: { added: [], deleted: [] }, + licence: { added: [], deleted: [] }, + category: { added: [], deleted: [] } + } + + private readonly settings: RegisterServerSettingOptions[] = [] + + private readonly router: express.Router + + constructor ( + private readonly npmName: string, + private readonly plugin: PluginModel, + private readonly onHookAdded: (options: RegisterServerHookOptions) => void + ) { + this.router = express.Router() + } + + buildRegisterHelpers (): RegisterServerOptions { + const registerHook = this.buildRegisterHook() + const registerSetting = this.buildRegisterSetting() + + const getRouter = this.buildGetRouter() + + const settingsManager = this.buildSettingsManager() + const storageManager = this.buildStorageManager() + + const videoLanguageManager = this.buildVideoLanguageManager() + + const videoLicenceManager = this.buildVideoLicenceManager() + const videoCategoryManager = this.buildVideoCategoryManager() + + const peertubeHelpers = buildPluginHelpers(this.npmName) + + return { + registerHook, + registerSetting, + + getRouter, + + settingsManager, + storageManager, + + videoLanguageManager, + videoCategoryManager, + videoLicenceManager, + + peertubeHelpers + } + } + + reinitVideoConstants (npmName: string) { + const hash = { + language: VIDEO_LANGUAGES, + licence: VIDEO_LICENCES, + category: VIDEO_CATEGORIES + } + const types: AlterableVideoConstant[] = [ 'language', 'licence', 'category' ] + + for (const type of types) { + const updatedConstants = this.updatedVideoConstants[type][npmName] + if (!updatedConstants) continue + + for (const added of updatedConstants.added) { + delete hash[type][added.key] + } + + for (const deleted of updatedConstants.deleted) { + hash[type][deleted.key] = deleted.label + } + + delete this.updatedVideoConstants[type][npmName] + } + } + + getSettings () { + return this.settings + } + + getRouter () { + return this.router + } + + private buildGetRouter () { + return () => this.router + } + + private buildRegisterSetting () { + return (options: RegisterServerSettingOptions) => { + this.settings.push(options) + } + } + + private buildRegisterHook () { + return (options: RegisterServerHookOptions) => { + if (serverHookObject[options.target] !== true) { + logger.warn('Unknown hook %s of plugin %s. Skipping.', options.target, this.npmName) + return + } + + return this.onHookAdded(options) + } + } + + private buildSettingsManager (): PluginSettingsManager { + return { + getSetting: (name: string) => PluginModel.getSetting(this.plugin.name, this.plugin.type, name), + + setSetting: (name: string, value: string) => PluginModel.setSetting(this.plugin.name, this.plugin.type, name, value) + } + } + + private buildStorageManager (): PluginStorageManager { + return { + getData: (key: string) => PluginModel.getData(this.plugin.name, this.plugin.type, key), + + storeData: (key: string, data: any) => PluginModel.storeData(this.plugin.name, this.plugin.type, key, data) + } + } + + private buildVideoLanguageManager (): PluginVideoLanguageManager { + return { + addLanguage: (key: string, label: string) => { + return this.addConstant({ npmName: this.npmName, type: 'language', obj: VIDEO_LANGUAGES, key, label }) + }, + + deleteLanguage: (key: string) => { + return this.deleteConstant({ npmName: this.npmName, type: 'language', obj: VIDEO_LANGUAGES, key }) + } + } + } + + private buildVideoCategoryManager (): PluginVideoCategoryManager { + return { + addCategory: (key: number, label: string) => { + return this.addConstant({ npmName: this.npmName, type: 'category', obj: VIDEO_CATEGORIES, key, label }) + }, + + deleteCategory: (key: number) => { + return this.deleteConstant({ npmName: this.npmName, type: 'category', obj: VIDEO_CATEGORIES, key }) + } + } + } + + private buildVideoLicenceManager (): PluginVideoLicenceManager { + return { + addLicence: (key: number, label: string) => { + return this.addConstant({ npmName: this.npmName, type: 'licence', obj: VIDEO_LICENCES, key, label }) + }, + + deleteLicence: (key: number) => { + return this.deleteConstant({ npmName: this.npmName, type: 'licence', obj: VIDEO_LICENCES, key }) + } + } + } + + private addConstant (parameters: { + npmName: string + type: AlterableVideoConstant + obj: VideoConstant + key: T + label: string + }) { + const { npmName, type, obj, key, label } = parameters + + if (obj[key]) { + logger.warn('Cannot add %s %s by plugin %s: key already exists.', type, npmName, key) + return false + } + + if (!this.updatedVideoConstants[type][npmName]) { + this.updatedVideoConstants[type][npmName] = { + added: [], + deleted: [] + } + } + + this.updatedVideoConstants[type][npmName].added.push({ key, label }) + obj[key] = label + + return true + } + + private deleteConstant (parameters: { + npmName: string + type: AlterableVideoConstant + obj: VideoConstant + key: T + }) { + const { npmName, type, obj, key } = parameters + + if (!obj[key]) { + logger.warn('Cannot delete %s %s by plugin %s: key does not exist.', type, npmName, key) + return false + } + + if (!this.updatedVideoConstants[type][npmName]) { + this.updatedVideoConstants[type][npmName] = { + added: [], + deleted: [] + } + } + + this.updatedVideoConstants[type][npmName].deleted.push({ key, label: obj[key] }) + delete obj[key] + + return true + } +} diff --git a/server/lib/plugins/register-helpers.ts b/server/lib/plugins/register-helpers.ts deleted file mode 100644 index 4c0935a05..000000000 --- a/server/lib/plugins/register-helpers.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { PluginSettingsManager } from '@shared/models/plugins/plugin-settings-manager.model' -import { PluginModel } from '@server/models/server/plugin' -import { PluginStorageManager } from '@shared/models/plugins/plugin-storage-manager.model' -import { PluginVideoLanguageManager } from '@shared/models/plugins/plugin-video-language-manager.model' -import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '@server/initializers/constants' -import { PluginVideoLicenceManager } from '@shared/models/plugins/plugin-video-licence-manager.model' -import { PluginVideoCategoryManager } from '@shared/models/plugins/plugin-video-category-manager.model' -import { RegisterServerOptions } from '@server/typings/plugins' -import { buildPluginHelpers } from './plugin-helpers' -import { logger } from '@server/helpers/logger' - -type AlterableVideoConstant = 'language' | 'licence' | 'category' -type VideoConstant = { [key in number | string]: string } -type UpdatedVideoConstant = { - [name in AlterableVideoConstant]: { - [npmName: string]: { - added: { key: number | string, label: string }[] - deleted: { key: number | string, label: string }[] - } - } -} - -const updatedVideoConstants: UpdatedVideoConstant = { - language: {}, - licence: {}, - category: {} -} - -function buildRegisterHelpers (npmName: string, plugin: PluginModel): Omit { - const settingsManager = buildSettingsManager(plugin) - const storageManager = buildStorageManager(plugin) - - const videoLanguageManager = buildVideoLanguageManager(npmName) - - const videoCategoryManager = buildVideoCategoryManager(npmName) - const videoLicenceManager = buildVideoLicenceManager(npmName) - - const peertubeHelpers = buildPluginHelpers(npmName) - - return { - settingsManager, - storageManager, - videoLanguageManager, - videoCategoryManager, - videoLicenceManager, - peertubeHelpers - } -} - -function reinitVideoConstants (npmName: string) { - const hash = { - language: VIDEO_LANGUAGES, - licence: VIDEO_LICENCES, - category: VIDEO_CATEGORIES - } - const types: AlterableVideoConstant[] = [ 'language', 'licence', 'category' ] - - for (const type of types) { - const updatedConstants = updatedVideoConstants[type][npmName] - if (!updatedConstants) continue - - for (const added of updatedConstants.added) { - delete hash[type][added.key] - } - - for (const deleted of updatedConstants.deleted) { - hash[type][deleted.key] = deleted.label - } - - delete updatedVideoConstants[type][npmName] - } -} - -export { - buildRegisterHelpers, - reinitVideoConstants -} - -// --------------------------------------------------------------------------- - -function buildSettingsManager (plugin: PluginModel): PluginSettingsManager { - return { - getSetting: (name: string) => PluginModel.getSetting(plugin.name, plugin.type, name), - - setSetting: (name: string, value: string) => PluginModel.setSetting(plugin.name, plugin.type, name, value) - } -} - -function buildStorageManager (plugin: PluginModel): PluginStorageManager { - return { - getData: (key: string) => PluginModel.getData(plugin.name, plugin.type, key), - - storeData: (key: string, data: any) => PluginModel.storeData(plugin.name, plugin.type, key, data) - } -} - -function buildVideoLanguageManager (npmName: string): PluginVideoLanguageManager { - return { - addLanguage: (key: string, label: string) => addConstant({ npmName, type: 'language', obj: VIDEO_LANGUAGES, key, label }), - - deleteLanguage: (key: string) => deleteConstant({ npmName, type: 'language', obj: VIDEO_LANGUAGES, key }) - } -} - -function buildVideoCategoryManager (npmName: string): PluginVideoCategoryManager { - return { - addCategory: (key: number, label: string) => { - return addConstant({ npmName, type: 'category', obj: VIDEO_CATEGORIES, key, label }) - }, - - deleteCategory: (key: number) => { - return deleteConstant({ npmName, type: 'category', obj: VIDEO_CATEGORIES, key }) - } - } -} - -function buildVideoLicenceManager (npmName: string): PluginVideoLicenceManager { - return { - addLicence: (key: number, label: string) => { - return addConstant({ npmName, type: 'licence', obj: VIDEO_LICENCES, key, label }) - }, - - deleteLicence: (key: number) => { - return deleteConstant({ npmName, type: 'licence', obj: VIDEO_LICENCES, key }) - } - } -} - -function addConstant (parameters: { - npmName: string - type: AlterableVideoConstant - obj: VideoConstant - key: T - label: string -}) { - const { npmName, type, obj, key, label } = parameters - - if (obj[key]) { - logger.warn('Cannot add %s %s by plugin %s: key already exists.', type, npmName, key) - return false - } - - if (!updatedVideoConstants[type][npmName]) { - updatedVideoConstants[type][npmName] = { - added: [], - deleted: [] - } - } - - updatedVideoConstants[type][npmName].added.push({ key, label }) - obj[key] = label - - return true -} - -function deleteConstant (parameters: { - npmName: string - type: AlterableVideoConstant - obj: VideoConstant - key: T -}) { - const { npmName, type, obj, key } = parameters - - if (!obj[key]) { - logger.warn('Cannot delete %s %s by plugin %s: key does not exist.', type, npmName, key) - return false - } - - if (!updatedVideoConstants[type][npmName]) { - updatedVideoConstants[type][npmName] = { - added: [], - deleted: [] - } - } - - updatedVideoConstants[type][npmName].deleted.push({ key, label: obj[key] }) - delete obj[key] - - return true -} diff --git a/server/middlewares/validators/plugins.ts b/server/middlewares/validators/plugins.ts index 910d03c29..65765f473 100644 --- a/server/middlewares/validators/plugins.ts +++ b/server/middlewares/validators/plugins.ts @@ -1,5 +1,5 @@ import * as express from 'express' -import { body, param, query } from 'express-validator' +import { body, param, query, ValidationChain } from 'express-validator' import { logger } from '../../helpers/logger' import { areValidationErrors } from './utils' import { isNpmPluginNameValid, isPluginNameValid, isPluginTypeValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins' @@ -10,24 +10,43 @@ import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-pl import { PluginType } from '../../../shared/models/plugins/plugin.type' import { CONFIG } from '../../initializers/config' -const servePluginStaticDirectoryValidator = (pluginType: PluginType) => [ - param('pluginName').custom(isPluginNameValid).withMessage('Should have a valid plugin name'), - param('pluginVersion').custom(isPluginVersionValid).withMessage('Should have a valid plugin version'), - param('staticEndpoint').custom(isSafePath).withMessage('Should have a valid static endpoint'), +const getPluginValidator = (pluginType: PluginType, withVersion = true) => { + const validators: (ValidationChain | express.Handler)[] = [ + param('pluginName').custom(isPluginNameValid).withMessage('Should have a valid plugin name') + ] - (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking servePluginStaticDirectory parameters', { parameters: req.params }) + if (withVersion) { + validators.push( + param('pluginVersion').custom(isPluginVersionValid).withMessage('Should have a valid plugin version') + ) + } - if (areValidationErrors(req, res)) return + return validators.concat([ + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking getPluginValidator parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + + const npmName = PluginModel.buildNpmName(req.params.pluginName, pluginType) + const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(npmName) + + if (!plugin) return res.sendStatus(404) + if (withVersion && plugin.version !== req.params.pluginVersion) return res.sendStatus(404) - const npmName = PluginModel.buildNpmName(req.params.pluginName, pluginType) - const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(npmName) + res.locals.registeredPlugin = plugin - if (!plugin || plugin.version !== req.params.pluginVersion) { - return res.sendStatus(404) + return next() } + ]) +} + +const pluginStaticDirectoryValidator = [ + param('staticEndpoint').custom(isSafePath).withMessage('Should have a valid static endpoint'), - res.locals.registeredPlugin = plugin + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking pluginStaticDirectoryValidator parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return return next() } @@ -149,7 +168,8 @@ const listAvailablePluginsValidator = [ // --------------------------------------------------------------------------- export { - servePluginStaticDirectoryValidator, + pluginStaticDirectoryValidator, + getPluginValidator, updatePluginSettingsValidator, uninstallPluginValidator, listAvailablePluginsValidator, diff --git a/server/tests/fixtures/peertube-plugin-test-five/main.js b/server/tests/fixtures/peertube-plugin-test-five/main.js new file mode 100644 index 000000000..c1435b928 --- /dev/null +++ b/server/tests/fixtures/peertube-plugin-test-five/main.js @@ -0,0 +1,21 @@ +async function register ({ + getRouter +}) { + const router = getRouter() + router.get('/ping', (req, res) => res.json({ message: 'pong' })) + + router.post('/form/post/mirror', (req, res) => { + res.json(req.body) + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/server/tests/fixtures/peertube-plugin-test-five/package.json b/server/tests/fixtures/peertube-plugin-test-five/package.json new file mode 100644 index 000000000..1f5d65d9d --- /dev/null +++ b/server/tests/fixtures/peertube-plugin-test-five/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-five", + "version": "0.0.1", + "description": "Plugin test 5", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/server/tests/plugins/index.ts b/server/tests/plugins/index.ts index 9c9499a79..1414e7e58 100644 --- a/server/tests/plugins/index.ts +++ b/server/tests/plugins/index.ts @@ -3,3 +3,4 @@ import './filter-hooks' import './translations' import './video-constants' import './plugin-helpers' +import './plugin-router' diff --git a/server/tests/plugins/plugin-router.ts b/server/tests/plugins/plugin-router.ts new file mode 100644 index 000000000..cf4130f4b --- /dev/null +++ b/server/tests/plugins/plugin-router.ts @@ -0,0 +1,91 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import { cleanupTests, flushAndRunServer, ServerInfo } from '../../../shared/extra-utils/server/servers' +import { + getPluginTestPath, + installPlugin, + makeGetRequest, + makePostBodyRequest, + setAccessTokensToServers, uninstallPlugin +} from '../../../shared/extra-utils' +import { expect } from 'chai' + +describe('Test plugin helpers', function () { + let server: ServerInfo + const basePaths = [ + '/plugins/test-five/router/', + '/plugins/test-five/0.0.1/router/' + ] + + before(async function () { + this.timeout(30000) + + server = await flushAndRunServer(1) + await setAccessTokensToServers([ server ]) + + await installPlugin({ + url: server.url, + accessToken: server.accessToken, + path: getPluginTestPath('-five') + }) + }) + + it('Should answer "pong"', async function () { + for (const path of basePaths) { + const res = await makeGetRequest({ + url: server.url, + path: path + 'ping', + statusCodeExpected: 200 + }) + + expect(res.body.message).to.equal('pong') + } + }) + + it('Should mirror post body', async function () { + const body = { + hello: 'world', + riri: 'fifi', + loulou: 'picsou' + } + + for (const path of basePaths) { + const res = await makePostBodyRequest({ + url: server.url, + path: path + 'form/post/mirror', + fields: body, + statusCodeExpected: 200 + }) + + expect(res.body).to.deep.equal(body) + } + }) + + it('Should remove the plugin and remove the routes', async function () { + await uninstallPlugin({ + url: server.url, + accessToken: server.accessToken, + npmName: 'peertube-plugin-test-five' + }) + + for (const path of basePaths) { + await makeGetRequest({ + url: server.url, + path: path + 'ping', + statusCodeExpected: 404 + }) + + await makePostBodyRequest({ + url: server.url, + path: path + 'ping', + fields: {}, + statusCodeExpected: 404 + }) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/typings/plugins/register-server-option.model.ts b/server/typings/plugins/register-server-option.model.ts index fda9afb11..3d6217d1b 100644 --- a/server/typings/plugins/register-server-option.model.ts +++ b/server/typings/plugins/register-server-option.model.ts @@ -6,6 +6,7 @@ import { PluginVideoCategoryManager } from '../../../shared/models/plugins/plugi import { PluginVideoLanguageManager } from '../../../shared/models/plugins/plugin-video-language-manager.model' import { PluginVideoLicenceManager } from '../../../shared/models/plugins/plugin-video-licence-manager.model' import { Logger } from 'winston' +import { Router } from 'express' export type PeerTubeHelpers = { logger: Logger @@ -32,5 +33,11 @@ export type RegisterServerOptions = { videoLanguageManager: PluginVideoLanguageManager videoLicenceManager: PluginVideoLicenceManager + // Get plugin router to create custom routes + // Base routes of this router are + // * /plugins/:pluginName/:pluginVersion/router/... + // * /plugins/:pluginName/router/... + getRouter(): Router + peertubeHelpers: PeerTubeHelpers } diff --git a/support/doc/plugins/guide.md b/support/doc/plugins/guide.md index 8e720e94b..bdc9d2ad8 100644 --- a/support/doc/plugins/guide.md +++ b/support/doc/plugins/guide.md @@ -12,6 +12,7 @@ - [Settings](#settings) - [Storage](#storage) - [Update video constants](#update-video-constants) + - [Add custom routes](#add-custom-routes) - [Client helpers (themes & plugins)](#client-helpers-themes--plugins) - [Plugin static route](#plugin-static-route) - [Translate](#translate) @@ -71,7 +72,9 @@ async function register ({ storageManager, videoCategoryManager, videoLicenceManager, - videoLanguageManager + videoLanguageManager, + peertubeHelpers, + getRouter }) { registerHook({ target: 'action:application.listening', @@ -178,6 +181,20 @@ videoLicenceManager.addLicence(42, 'Best licence') videoLicenceManager.deleteLicence(7) // Public domain ``` +#### Add custom routes + +You can create custom routes using an [express Router](https://expressjs.com/en/4x/api.html#router) for your plugin: + +```js +const router = getRouter() +router.get('/ping', (req, res) => res.json({ message: 'pong' })) +``` + +The `ping` route can be accessed using: + * `/plugins/:pluginName/:pluginVersion/router/ping` + * Or `/plugins/:pluginName/router/ping` + + ### Client helpers (themes & plugins) ### Plugin static route -- 2.25.1