diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html index d4501490f..f10b4eb8d 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html @@ -26,8 +26,11 @@ Homepage + - + diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss index f250404ed..7641c507b 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss @@ -35,3 +35,7 @@ @include peertube-button-link; @include button-with-icon(21px, 0, -2px); } + +.update-button[disabled="true"] /deep/ .action-button { + cursor: default !important; +} diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts index 26a9a616e..67a11c3a8 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts @@ -6,6 +6,7 @@ import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pa import { ConfirmService, Notifier } from '@app/core' import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model' import { ActivatedRoute, Router } from '@angular/router' +import { compareSemVer } from '@app/shared/misc/utils' @Component({ selector: 'my-plugin-list-installed', @@ -26,6 +27,9 @@ export class PluginListInstalledComponent implements OnInit { sort = 'name' plugins: PeerTubePlugin[] = [] + updating: { [name: string]: boolean } = {} + + PluginType = PluginType constructor ( private i18n: I18n, @@ -49,7 +53,7 @@ export class PluginListInstalledComponent implements OnInit { this.pagination.currentPage = 1 this.plugins = [] - this.router.navigate([], { queryParams: { pluginType: this.pluginType }}) + this.router.navigate([], { queryParams: { pluginType: this.pluginType } }) this.loadMorePlugins() } @@ -82,6 +86,18 @@ export class PluginListInstalledComponent implements OnInit { return this.i18n('You don\'t have themes installed yet.') } + isUpdateAvailable (plugin: PeerTubePlugin) { + return plugin.latestVersion && compareSemVer(plugin.latestVersion, plugin.version) > 0 + } + + getUpdateLabel (plugin: PeerTubePlugin) { + return this.i18n('Update to {{version}}', { version: plugin.latestVersion }) + } + + isUpdating (plugin: PeerTubePlugin) { + return !!this.updating[this.getUpdatingKey(plugin)] + } + async uninstall (plugin: PeerTubePlugin) { const res = await this.confirmService.confirm( this.i18n('Do you really want to uninstall {{pluginName}}?', { pluginName: plugin.name }), @@ -102,7 +118,32 @@ export class PluginListInstalledComponent implements OnInit { ) } + async update (plugin: PeerTubePlugin) { + const updatingKey = this.getUpdatingKey(plugin) + if (this.updating[updatingKey]) return + + this.updating[updatingKey] = true + + this.pluginService.update(plugin.name, plugin.type) + .pipe() + .subscribe( + res => { + this.updating[updatingKey] = false + + this.notifier.success(this.i18n('{{pluginName}} updated.', { pluginName: plugin.name })) + + Object.assign(plugin, res) + }, + + err => this.notifier.error(err.message) + ) + } + getShowRouterLink (plugin: PeerTubePlugin) { return [ '/admin', 'plugins', 'show', this.pluginService.nameToNpmName(plugin.name, plugin.type) ] } + + private getUpdatingKey (plugin: PeerTubePlugin) { + return plugin.name + plugin.type + } } diff --git a/client/src/app/+admin/plugins/shared/plugin-api.service.ts b/client/src/app/+admin/plugins/shared/plugin-api.service.ts index 1d33cd179..89f190675 100644 --- a/client/src/app/+admin/plugins/shared/plugin-api.service.ts +++ b/client/src/app/+admin/plugins/shared/plugin-api.service.ts @@ -9,7 +9,7 @@ import { ComponentPagination } from '@app/shared/rest/component-pagination.model import { ResultList } from '@shared/models' import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model' import { ManagePlugin } from '@shared/models/plugins/manage-plugin.model' -import { InstallPlugin } from '@shared/models/plugins/install-plugin.model' +import { InstallOrUpdatePlugin } from '@shared/models/plugins/install-plugin.model' import { RegisterSettingOptions } from '@shared/models/plugins/register-setting.model' @Injectable() @@ -89,8 +89,17 @@ export class PluginApiService { .pipe(catchError(res => this.restExtractor.handleError(res))) } + update (pluginName: string, pluginType: PluginType) { + const body: ManagePlugin = { + npmName: this.nameToNpmName(pluginName, pluginType) + } + + return this.authHttp.post(PluginApiService.BASE_APPLICATION_URL + '/update', body) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + install (npmName: string) { - const body: InstallPlugin = { + const body: InstallOrUpdatePlugin = { npmName } diff --git a/client/src/app/core/plugins/plugin.service.ts b/client/src/app/core/plugins/plugin.service.ts index 86bde2d02..c6ba3dd17 100644 --- a/client/src/app/core/plugins/plugin.service.ts +++ b/client/src/app/core/plugins/plugin.service.ts @@ -48,7 +48,9 @@ export class PluginService { .toPromise() } - addPlugin (plugin: ServerConfigPlugin) { + addPlugin (plugin: ServerConfigPlugin, isTheme = false) { + const pathPrefix = isTheme ? '/themes' : '/plugins' + for (const key of Object.keys(plugin.clientScripts)) { const clientScript = plugin.clientScripts[key] @@ -58,7 +60,7 @@ export class PluginService { this.scopes[scope].push({ plugin, clientScript: { - script: environment.apiUrl + `/plugins/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`, + script: environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`, scopes: clientScript.scopes } }) diff --git a/client/src/app/shared/buttons/button.component.html b/client/src/app/shared/buttons/button.component.html index b6df67102..d2b0eb81a 100644 --- a/client/src/app/shared/buttons/button.component.html +++ b/client/src/app/shared/buttons/button.component.html @@ -1,4 +1,6 @@ - + + + {{ label }} diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss index 99d7f51c1..4cc2b0573 100644 --- a/client/src/app/shared/buttons/button.component.scss +++ b/client/src/app/shared/buttons/button.component.scss @@ -1,6 +1,12 @@ @import '_variables'; @import '_mixins'; +my-small-loader /deep/ .root { + display: inline-block; + margin: 0 3px 0 0; + width: 20px; +} + .action-button { @include peertube-button-link; @include button-with-icon(21px, 0, -2px); diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts index cf334e8d5..cac5ad210 100644 --- a/client/src/app/shared/buttons/button.component.ts +++ b/client/src/app/shared/buttons/button.component.ts @@ -12,6 +12,7 @@ export class ButtonComponent { @Input() className = 'grey-button' @Input() icon: GlobalIconName = undefined @Input() title: string = undefined + @Input() loading = false getTitle () { return this.title || this.label diff --git a/client/src/app/shared/misc/small-loader.component.html b/client/src/app/shared/misc/small-loader.component.html index 5a7cea738..7886f8918 100644 --- a/client/src/app/shared/misc/small-loader.component.html +++ b/client/src/app/shared/misc/small-loader.component.html @@ -1,3 +1,3 @@ -
+
diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts index 85fc1c3a0..098496d45 100644 --- a/client/src/app/shared/misc/utils.ts +++ b/client/src/app/shared/misc/utils.ts @@ -134,6 +134,23 @@ function scrollToTop () { window.scroll(0, 0) } +// Thanks https://stackoverflow.com/a/16187766 +function compareSemVer (a: string, b: string) { + const regExStrip0 = /(\.0+)+$/ + const segmentsA = a.replace(regExStrip0, '').split('.') + const segmentsB = b.replace(regExStrip0, '').split('.') + + const l = Math.min(segmentsA.length, segmentsB.length) + + for (let i = 0; i < l; i++) { + const diff = parseInt(segmentsA[ i ], 10) - parseInt(segmentsB[ i ], 10) + + if (diff) return diff + } + + return segmentsA.length - segmentsB.length +} + export { sortBy, durationToString, @@ -144,6 +161,7 @@ export { getAbsoluteAPIUrl, dateToHuman, immutableAssign, + compareSemVer, objectToFormData, objectLineFeedToHtml, removeElementFromArray, diff --git a/package.json b/package.json index 306476c6a..7811e0f39 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "danger:clean:prod": "scripty", "danger:clean:modules": "scripty", "i18n:generate": "scripty", + "plugin:install": "node ./dist/scripts/plugin/install.js", + "plugin:uninstall": "node ./dist/scripts/plugin/uninstall.js", "i18n:xliff2json": "node ./dist/scripts/i18n/xliff2json.js", "i18n:create-custom-files": "node ./dist/scripts/i18n/create-custom-files.js", "reset-password": "node ./dist/scripts/reset-password.js", diff --git a/scripts/plugin/install.ts b/scripts/plugin/install.ts new file mode 100755 index 000000000..1725cbeb6 --- /dev/null +++ b/scripts/plugin/install.ts @@ -0,0 +1,39 @@ +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, --plugin-name [pluginName]', 'Plugin name to install') + .option('-v, --plugin-version [pluginVersion]', 'Plugin version to install') + .option('-p, --plugin-path [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']) +} diff --git a/scripts/plugin/uninstall.ts b/scripts/plugin/uninstall.ts new file mode 100755 index 000000000..b5e1ddea2 --- /dev/null +++ b/scripts/plugin/uninstall.ts @@ -0,0 +1,26 @@ +import { initDatabaseModels } from '../../server/initializers/database' +import * as program from 'commander' +import { PluginManager } from '../../server/lib/plugins/plugin-manager' + +program + .option('-n, --npm-name [npmName]', 'Package name to install') + .parse(process.argv) + +if (!program['npmName']) { + console.error('You need to specify the plugin name.') + process.exit(-1) +} + +run() + .then(() => process.exit(0)) + .catch(err => { + console.error(err) + process.exit(-1) + }) + +async function run () { + await initDatabaseModels(true) + + const toUninstall = program['npmName'] + await PluginManager.Instance.uninstall(toUninstall) +} diff --git a/server.ts b/server.ts index d8e8f1e97..f6fae3718 100644 --- a/server.ts +++ b/server.ts @@ -97,7 +97,6 @@ import { staticRouter, servicesRouter, pluginsRouter, - themesRouter, webfingerRouter, trackerRouter, createWebsocketTrackerServer, botsRouter @@ -178,8 +177,7 @@ app.use(apiRoute, apiRouter) app.use('/services', servicesRouter) // Plugins & themes -app.use('/plugins', pluginsRouter) -app.use('/themes', themesRouter) +app.use('/', pluginsRouter) app.use('/', activityPubRouter) app.use('/', feedsRouter) diff --git a/server/controllers/api/plugins.ts b/server/controllers/api/plugins.ts index 8e59f27cf..14675fdf3 100644 --- a/server/controllers/api/plugins.ts +++ b/server/controllers/api/plugins.ts @@ -13,13 +13,13 @@ import { PluginModel } from '../../models/server/plugin' import { UserRight } from '../../../shared/models/users' import { existingPluginValidator, - installPluginValidator, + installOrUpdatePluginValidator, listPluginsValidator, uninstallPluginValidator, updatePluginSettingsValidator } from '../../middlewares/validators/plugins' import { PluginManager } from '../../lib/plugins/plugin-manager' -import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model' +import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-plugin.model' import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model' import { logger } from '../../helpers/logger' @@ -61,10 +61,17 @@ pluginRouter.put('/:npmName/settings', pluginRouter.post('/install', authenticate, ensureUserHasRight(UserRight.MANAGE_PLUGINS), - installPluginValidator, + installOrUpdatePluginValidator, asyncMiddleware(installPlugin) ) +pluginRouter.post('/update', + authenticate, + ensureUserHasRight(UserRight.MANAGE_PLUGINS), + installOrUpdatePluginValidator, + asyncMiddleware(updatePlugin) +) + pluginRouter.post('/uninstall', authenticate, ensureUserHasRight(UserRight.MANAGE_PLUGINS), @@ -100,18 +107,33 @@ function getPlugin (req: express.Request, res: express.Response) { } async function installPlugin (req: express.Request, res: express.Response) { - const body: InstallPlugin = req.body + const body: InstallOrUpdatePlugin = req.body const fromDisk = !!body.path const toInstall = body.npmName || body.path try { - await PluginManager.Instance.install(toInstall, undefined, fromDisk) + const plugin = await PluginManager.Instance.install(toInstall, undefined, fromDisk) + + return res.json(plugin.toFormattedJSON()) } catch (err) { logger.warn('Cannot install plugin %s.', toInstall, { err }) return res.sendStatus(400) } +} - return res.sendStatus(204) +async function updatePlugin (req: express.Request, res: express.Response) { + const body: InstallOrUpdatePlugin = req.body + + const fromDisk = !!body.path + const toUpdate = body.npmName || body.path + try { + const plugin = await PluginManager.Instance.update(toUpdate, undefined, fromDisk) + + return res.json(plugin.toFormattedJSON()) + } catch (err) { + logger.warn('Cannot update plugin %s.', toUpdate, { err }) + return res.sendStatus(400) + } } async function uninstallPlugin (req: express.Request, res: express.Response) { @@ -123,9 +145,7 @@ async function uninstallPlugin (req: express.Request, res: express.Response) { } function getPluginRegisteredSettings (req: express.Request, res: express.Response) { - const plugin = res.locals.plugin - - const settings = PluginManager.Instance.getSettings(plugin.name) + const settings = PluginManager.Instance.getRegisteredSettings(req.params.npmName) return res.json({ settings diff --git a/server/controllers/index.ts b/server/controllers/index.ts index 869546dc7..8b3501712 100644 --- a/server/controllers/index.ts +++ b/server/controllers/index.ts @@ -8,4 +8,3 @@ export * from './webfinger' export * from './tracker' export * from './bots' export * from './plugins' -export * from './themes' diff --git a/server/controllers/plugins.ts b/server/controllers/plugins.ts index 05f03324d..f255d13e8 100644 --- a/server/controllers/plugins.ts +++ b/server/controllers/plugins.ts @@ -1,25 +1,42 @@ import * as express from 'express' import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants' -import { basename, join } from 'path' +import { join } from 'path' import { RegisteredPlugin } from '../lib/plugins/plugin-manager' import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins' +import { serveThemeCSSValidator } from '../middlewares/validators/themes' +import { PluginType } from '../../shared/models/plugins/plugin.type' const pluginsRouter = express.Router() -pluginsRouter.get('/global.css', +pluginsRouter.get('/plugins/global.css', servePluginGlobalCSS ) -pluginsRouter.get('/:pluginName/:pluginVersion/static/:staticEndpoint(*)', - servePluginStaticDirectoryValidator, +pluginsRouter.get('/plugins/:pluginName/:pluginVersion/static/:staticEndpoint(*)', + servePluginStaticDirectoryValidator(PluginType.PLUGIN), servePluginStaticDirectory ) -pluginsRouter.get('/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', - servePluginStaticDirectoryValidator, +pluginsRouter.get('/plugins/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', + servePluginStaticDirectoryValidator(PluginType.PLUGIN), servePluginClientScripts ) +pluginsRouter.get('/themes/:pluginName/:pluginVersion/static/:staticEndpoint(*)', + servePluginStaticDirectoryValidator(PluginType.THEME), + servePluginStaticDirectory +) + +pluginsRouter.get('/themes/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', + servePluginStaticDirectoryValidator(PluginType.THEME), + servePluginClientScripts +) + +pluginsRouter.get('/themes/:themeName/:themeVersion/css/:staticEndpoint(*)', + serveThemeCSSValidator, + serveThemeCSSDirectory +) + // --------------------------------------------------------------------------- export { @@ -58,3 +75,14 @@ function servePluginClientScripts (req: express.Request, res: express.Response) return res.sendFile(join(plugin.path, staticEndpoint)) } + +function serveThemeCSSDirectory (req: express.Request, res: express.Response) { + const plugin: RegisteredPlugin = res.locals.registeredPlugin + const staticEndpoint = req.params.staticEndpoint + + if (plugin.css.includes(staticEndpoint) === false) { + return res.sendStatus(404) + } + + return res.sendFile(join(plugin.path, staticEndpoint)) +} diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts index 8cdeff446..2fa80e878 100644 --- a/server/lib/plugins/plugin-manager.ts +++ b/server/lib/plugins/plugin-manager.ts @@ -15,6 +15,7 @@ import { RegisterHookOptions } from '../../../shared/models/plugins/register-hoo import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model' export interface RegisteredPlugin { + npmName: string name: string version: string description: string @@ -34,6 +35,7 @@ export interface RegisteredPlugin { } export interface HookInformationValue { + npmName: string pluginName: string handler: Function priority: number @@ -52,12 +54,13 @@ export class PluginManager { // ###################### Getters ###################### - getRegisteredPluginOrTheme (name: string) { - return this.registeredPlugins[name] + getRegisteredPluginOrTheme (npmName: string) { + return this.registeredPlugins[npmName] } getRegisteredPlugin (name: string) { - const registered = this.getRegisteredPluginOrTheme(name) + const npmName = PluginModel.buildNpmName(name, PluginType.PLUGIN) + const registered = this.getRegisteredPluginOrTheme(npmName) if (!registered || registered.type !== PluginType.PLUGIN) return undefined @@ -65,7 +68,8 @@ export class PluginManager { } getRegisteredTheme (name: string) { - const registered = this.getRegisteredPluginOrTheme(name) + const npmName = PluginModel.buildNpmName(name, PluginType.THEME) + const registered = this.getRegisteredPluginOrTheme(npmName) if (!registered || registered.type !== PluginType.THEME) return undefined @@ -80,8 +84,8 @@ export class PluginManager { return this.getRegisteredPluginsOrThemes(PluginType.THEME) } - getSettings (name: string) { - return this.settings[name] || [] + getRegisteredSettings (npmName: string) { + return this.settings[npmName] || [] } // ###################### Hooks ###################### @@ -126,35 +130,36 @@ export class PluginManager { this.sortHooksByPriority() } - async unregister (name: string) { - const plugin = this.getRegisteredPlugin(name) + // Don't need the plugin type since themes cannot register server code + async unregister (npmName: string) { + logger.info('Unregister plugin %s.', npmName) + + const plugin = this.getRegisteredPluginOrTheme(npmName) if (!plugin) { - throw new Error(`Unknown plugin ${name} to unregister`) + throw new Error(`Unknown plugin ${npmName} to unregister`) } - if (plugin.type === PluginType.THEME) { - throw new Error(`Cannot unregister ${name}: this is a theme`) + if (plugin.type === PluginType.PLUGIN) { + await plugin.unregister() + + // Remove hooks of this plugin + for (const key of Object.keys(this.hooks)) { + this.hooks[key] = this.hooks[key].filter(h => h.pluginName !== npmName) + } + + logger.info('Regenerating registered plugin CSS to global file.') + await this.regeneratePluginGlobalCSS() } - await plugin.unregister() - - // Remove hooks of this plugin - for (const key of Object.keys(this.hooks)) { - this.hooks[key] = this.hooks[key].filter(h => h.pluginName !== name) - } - - delete this.registeredPlugins[plugin.name] - - logger.info('Regenerating registered plugin CSS to global file.') - await this.regeneratePluginGlobalCSS() + delete this.registeredPlugins[plugin.npmName] } // ###################### Installation ###################### async install (toInstall: string, version?: string, fromDisk = false) { let plugin: PluginModel - let name: string + let npmName: string logger.info('Installing plugin %s.', toInstall) @@ -163,9 +168,9 @@ export class PluginManager { ? await installNpmPluginFromDisk(toInstall) : await installNpmPlugin(toInstall, version) - name = fromDisk ? basename(toInstall) : toInstall - const pluginType = PluginModel.getTypeFromNpmName(name) - const pluginName = PluginModel.normalizePluginName(name) + npmName = fromDisk ? basename(toInstall) : toInstall + const pluginType = PluginModel.getTypeFromNpmName(npmName) + const pluginName = PluginModel.normalizePluginName(npmName) const packageJSON = this.getPackageJSON(pluginName, pluginType) if (!isPackageJSONValid(packageJSON, pluginType)) { @@ -186,7 +191,7 @@ export class PluginManager { logger.error('Cannot install plugin %s, removing it...', toInstall, { err }) try { - await removeNpmPlugin(name) + await removeNpmPlugin(npmName) } catch (err) { logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err }) } @@ -197,17 +202,28 @@ export class PluginManager { logger.info('Successful installation of plugin %s.', toInstall) await this.registerPluginOrTheme(plugin) + + return plugin + } + + async update (toUpdate: string, version?: string, fromDisk = false) { + const npmName = fromDisk ? basename(toUpdate) : toUpdate + + logger.info('Updating plugin %s.', npmName) + + // Unregister old hooks + await this.unregister(npmName) + + return this.install(toUpdate, version, fromDisk) } async uninstall (npmName: string) { logger.info('Uninstalling plugin %s.', npmName) - const pluginName = PluginModel.normalizePluginName(npmName) - try { - await this.unregister(pluginName) + await this.unregister(npmName) } catch (err) { - logger.warn('Cannot unregister plugin %s.', pluginName, { err }) + logger.warn('Cannot unregister plugin %s.', npmName, { err }) } const plugin = await PluginModel.loadByNpmName(npmName) @@ -229,7 +245,9 @@ export class PluginManager { // ###################### Private register ###################### private async registerPluginOrTheme (plugin: PluginModel) { - logger.info('Registering plugin or theme %s.', plugin.name) + const npmName = PluginModel.buildNpmName(plugin.name, plugin.type) + + logger.info('Registering plugin or theme %s.', npmName) const packageJSON = this.getPackageJSON(plugin.name, plugin.type) const pluginPath = this.getPluginPath(plugin.name, plugin.type) @@ -248,7 +266,8 @@ export class PluginManager { clientScripts[c.script] = c } - this.registeredPlugins[ plugin.name ] = { + this.registeredPlugins[ npmName ] = { + npmName, name: plugin.name, type: plugin.type, version: plugin.version, @@ -263,10 +282,13 @@ export class PluginManager { } private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJson) { + const npmName = PluginModel.buildNpmName(plugin.name, plugin.type) + const registerHook = (options: RegisterHookOptions) => { if (!this.hooks[options.target]) this.hooks[options.target] = [] this.hooks[options.target].push({ + npmName, pluginName: plugin.name, handler: options.handler, priority: options.priority || 0 @@ -274,15 +296,15 @@ export class PluginManager { } const registerSetting = (options: RegisterSettingOptions) => { - if (!this.settings[plugin.name]) this.settings[plugin.name] = [] + if (!this.settings[npmName]) this.settings[npmName] = [] - this.settings[plugin.name].push(options) + this.settings[npmName].push(options) } const settingsManager: PluginSettingsManager = { - getSetting: (name: string) => PluginModel.getSetting(plugin.name, name), + getSetting: (name: string) => PluginModel.getSetting(plugin.name, plugin.type, name), - setSetting: (name: string, value: string) => PluginModel.setSetting(plugin.name, name, value) + setSetting: (name: string, value: string) => PluginModel.setSetting(plugin.name, plugin.type, name, value) } const library: PluginLibrary = require(join(pluginPath, packageJSON.library)) @@ -293,7 +315,7 @@ export class PluginManager { library.register({ registerHook, registerSetting, settingsManager }) - logger.info('Add plugin %s CSS to global file.', plugin.name) + logger.info('Add plugin %s CSS to global file.', npmName) await this.addCSSToGlobalFile(pluginPath, packageJSON.css) @@ -351,9 +373,9 @@ export class PluginManager { } private getPluginPath (pluginName: string, pluginType: PluginType) { - const prefix = pluginType === PluginType.PLUGIN ? 'peertube-plugin-' : 'peertube-theme-' + const npmName = PluginModel.buildNpmName(pluginName, pluginType) - return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', prefix + pluginName) + return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', npmName) } // ###################### Private getters ###################### @@ -361,8 +383,8 @@ export class PluginManager { private getRegisteredPluginsOrThemes (type: PluginType) { const plugins: RegisteredPlugin[] = [] - for (const pluginName of Object.keys(this.registeredPlugins)) { - const plugin = this.registeredPlugins[ pluginName ] + for (const npmName of Object.keys(this.registeredPlugins)) { + const plugin = this.registeredPlugins[ npmName ] if (plugin.type !== type) continue plugins.push(plugin) diff --git a/server/middlewares/validators/plugins.ts b/server/middlewares/validators/plugins.ts index a1634ded4..8103ec7d3 100644 --- a/server/middlewares/validators/plugins.ts +++ b/server/middlewares/validators/plugins.ts @@ -1,14 +1,15 @@ import * as express from 'express' -import { param, query, body } from 'express-validator/check' +import { body, param, query } from 'express-validator/check' import { logger } from '../../helpers/logger' import { areValidationErrors } from './utils' -import { isPluginNameValid, isPluginTypeValid, isPluginVersionValid, isNpmPluginNameValid } from '../../helpers/custom-validators/plugins' +import { isNpmPluginNameValid, isPluginNameValid, isPluginTypeValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins' import { PluginManager } from '../../lib/plugins/plugin-manager' import { isBooleanValid, isSafePath } from '../../helpers/custom-validators/misc' import { PluginModel } from '../../models/server/plugin' -import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model' +import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-plugin.model' +import { PluginType } from '../../../shared/models/plugins/plugin.type' -const servePluginStaticDirectoryValidator = [ +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'), @@ -18,7 +19,8 @@ const servePluginStaticDirectoryValidator = [ if (areValidationErrors(req, res)) return - const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(req.params.pluginName) + const npmName = PluginModel.buildNpmName(req.params.pluginName, pluginType) + const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(npmName) if (!plugin || plugin.version !== req.params.pluginVersion) { return res.sendStatus(404) @@ -48,7 +50,7 @@ const listPluginsValidator = [ } ] -const installPluginValidator = [ +const installOrUpdatePluginValidator = [ body('npmName') .optional() .custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'), @@ -57,11 +59,11 @@ const installPluginValidator = [ .custom(isSafePath).withMessage('Should have a valid safe path'), (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking installPluginValidator parameters', { parameters: req.body }) + logger.debug('Checking installOrUpdatePluginValidator parameters', { parameters: req.body }) if (areValidationErrors(req, res)) return - const body: InstallPlugin = req.body + const body: InstallOrUpdatePlugin = req.body if (!body.path && !body.npmName) { return res.status(400) .json({ error: 'Should have either a npmName or a path' }) @@ -124,6 +126,6 @@ export { updatePluginSettingsValidator, uninstallPluginValidator, existingPluginValidator, - installPluginValidator, + installOrUpdatePluginValidator, listPluginsValidator } diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts index 226c08342..340d49f3b 100644 --- a/server/models/server/plugin.ts +++ b/server/models/server/plugin.ts @@ -1,7 +1,8 @@ import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' import { getSort, throwIfNotValid } from '../utils' import { - isPluginDescriptionValid, isPluginHomepage, + isPluginDescriptionValid, + isPluginHomepage, isPluginNameValid, isPluginTypeValid, isPluginVersionValid @@ -42,6 +43,11 @@ export class PluginModel extends Model { @Column version: string + @AllowNull(true) + @Is('PluginLatestVersion', value => throwIfNotValid(value, isPluginVersionValid, 'version')) + @Column + latestVersion: string + @AllowNull(false) @Column enabled: boolean @@ -103,27 +109,28 @@ export class PluginModel extends Model { return PluginModel.findOne(query) } - static getSetting (pluginName: string, settingName: string) { + static getSetting (pluginName: string, pluginType: PluginType, settingName: string) { const query = { attributes: [ 'settings' ], where: { - name: pluginName + name: pluginName, + type: pluginType } } return PluginModel.findOne(query) - .then(p => p.settings) - .then(settings => { - if (!settings) return undefined + .then(p => { + if (!p || !p.settings) return undefined - return settings[settingName] + return p.settings[settingName] }) } - static setSetting (pluginName: string, settingName: string, settingValue: string) { + static setSetting (pluginName: string, pluginType: PluginType, settingName: string, settingValue: string) { const query = { where: { - name: pluginName + name: pluginName, + type: pluginType } } @@ -171,11 +178,18 @@ export class PluginModel extends Model { : PluginType.THEME } + static buildNpmName (name: string, type: PluginType) { + if (type === PluginType.THEME) return 'peertube-theme-' + name + + return 'peertube-plugin-' + name + } + toFormattedJSON (): PeerTubePlugin { return { name: this.name, type: this.type, version: this.version, + latestVersion: this.latestVersion, enabled: this.enabled, uninstalled: this.uninstalled, peertubeEngine: this.peertubeEngine, diff --git a/server/tools/peertube-plugins.ts b/server/tools/peertube-plugins.ts index d5e024383..10cff7dd7 100644 --- a/server/tools/peertube-plugins.ts +++ b/server/tools/peertube-plugins.ts @@ -2,7 +2,7 @@ import * as program from 'commander' import { PluginType } from '../../shared/models/plugins/plugin.type' import { getAccessToken } from '../../shared/extra-utils/users/login' import { getMyUserInformation } from '../../shared/extra-utils/users/users' -import { installPlugin, listPlugins, uninstallPlugin } from '../../shared/extra-utils/server/plugins' +import { installPlugin, listPlugins, uninstallPlugin, updatePlugin } from '../../shared/extra-utils/server/plugins' import { getServerCredentials } from './cli' import { User, UserRole } from '../../shared/models/users' import { PeerTubePlugin } from '../../shared/models/plugins/peertube-plugin.model' @@ -34,6 +34,16 @@ program .option('-n, --npm-name ', 'Install from npm') .action((options) => installPluginCLI(options)) +program + .command('update') + .description('Update a plugin or a theme') + .option('-u, --url ', 'Server url') + .option('-U, --username ', 'Username') + .option('-p, --password ', 'Password') + .option('-P --path ', 'Update from a path') + .option('-n, --npm-name ', 'Update from npm') + .action((options) => updatePluginCLI(options)) + program .command('uninstall') .description('Uninstall a plugin or a theme') @@ -122,6 +132,38 @@ async function installPluginCLI (options: any) { process.exit(0) } +async function updatePluginCLI (options: any) { + if (!options['path'] && !options['npmName']) { + console.error('You need to specify the npm name or the path of the plugin you want to update.\n') + program.outputHelp() + process.exit(-1) + } + + if (options['path'] && !isAbsolute(options['path'])) { + console.error('Path should be absolute.') + process.exit(-1) + } + + const { url, username, password } = await getServerCredentials(options) + const accessToken = await getAdminTokenOrDie(url, username, password) + + try { + await updatePlugin({ + url, + accessToken, + npmName: options['npmName'], + path: options['path'] + }) + } catch (err) { + console.error('Cannot update plugin.', err) + process.exit(-1) + return + } + + console.log('Plugin updated.') + process.exit(0) +} + async function uninstallPluginCLI (options: any) { if (!options['npmName']) { console.error('You need to specify the npm name of the plugin/theme you want to uninstall.\n') diff --git a/shared/extra-utils/server/plugins.ts b/shared/extra-utils/server/plugins.ts index 6cd7cd17a..1da313ab7 100644 --- a/shared/extra-utils/server/plugins.ts +++ b/shared/extra-utils/server/plugins.ts @@ -85,7 +85,7 @@ function installPlugin (parameters: { npmName?: string expectedStatus?: number }) { - const { url, accessToken, npmName, path, expectedStatus = 204 } = parameters + const { url, accessToken, npmName, path, expectedStatus = 200 } = parameters const apiPath = '/api/v1/plugins/install' return makePostBodyRequest({ @@ -97,6 +97,25 @@ function installPlugin (parameters: { }) } +function updatePlugin (parameters: { + url: string, + accessToken: string, + path?: string, + npmName?: string + expectedStatus?: number +}) { + const { url, accessToken, npmName, path, expectedStatus = 200 } = parameters + const apiPath = '/api/v1/plugins/update' + + return makePostBodyRequest({ + url, + path: apiPath, + token: accessToken, + fields: { npmName, path }, + statusCodeExpected: expectedStatus + }) +} + function uninstallPlugin (parameters: { url: string, accessToken: string, @@ -118,6 +137,7 @@ function uninstallPlugin (parameters: { export { listPlugins, installPlugin, + updatePlugin, getPlugin, uninstallPlugin, getPluginSettings, diff --git a/shared/models/plugins/install-plugin.model.ts b/shared/models/plugins/install-plugin.model.ts index b1b46fa08..5a268ebe1 100644 --- a/shared/models/plugins/install-plugin.model.ts +++ b/shared/models/plugins/install-plugin.model.ts @@ -1,4 +1,4 @@ -export interface InstallPlugin { +export interface InstallOrUpdatePlugin { npmName?: string path?: string } diff --git a/shared/models/plugins/peertube-plugin.model.ts b/shared/models/plugins/peertube-plugin.model.ts index de3c7741b..e3c100027 100644 --- a/shared/models/plugins/peertube-plugin.model.ts +++ b/shared/models/plugins/peertube-plugin.model.ts @@ -1,6 +1,7 @@ export interface PeerTubePlugin { name: string type: number + latestVersion: string version: string enabled: boolean uninstalled: boolean