WIP plugins: update plugin
authorChocobozzz <me@florianbigard.com>
Fri, 12 Jul 2019 09:39:58 +0000 (11:39 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 24 Jul 2019 08:58:16 +0000 (10:58 +0200)
24 files changed:
client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html
client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss
client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts
client/src/app/+admin/plugins/shared/plugin-api.service.ts
client/src/app/core/plugins/plugin.service.ts
client/src/app/shared/buttons/button.component.html
client/src/app/shared/buttons/button.component.scss
client/src/app/shared/buttons/button.component.ts
client/src/app/shared/misc/small-loader.component.html
client/src/app/shared/misc/utils.ts
package.json
scripts/plugin/install.ts [new file with mode: 0755]
scripts/plugin/uninstall.ts [new file with mode: 0755]
server.ts
server/controllers/api/plugins.ts
server/controllers/index.ts
server/controllers/plugins.ts
server/lib/plugins/plugin-manager.ts
server/middlewares/validators/plugins.ts
server/models/server/plugin.ts
server/tools/peertube-plugins.ts
shared/extra-utils/server/plugins.ts
shared/models/plugins/install-plugin.model.ts
shared/models/plugins/peertube-plugin.model.ts

index d4501490f18746b737720529812777bfaa298f52..f10b4eb8d30b59e8e5714fe9b6a95121195f7b20 100644 (file)
             <span i18n class="button-label">Homepage</span>
           </a>
 
+          <my-edit-button *ngIf="pluginType !== PluginType.THEME" [routerLink]="getShowRouterLink(plugin)" label="Settings" i18n-label></my-edit-button>
 
-          <my-edit-button [routerLink]="getShowRouterLink(plugin)" label="Settings" i18n-label></my-edit-button>
+          <my-button class="update-button" *ngIf="!isUpdateAvailable(plugin)" (click)="update(plugin)" [loading]="isUpdating(plugin)"
+                     [label]="getUpdateLabel(plugin)" icon="refresh" [attr.disabled]="isUpdating(plugin)"
+          ></my-button>
 
           <my-delete-button (click)="uninstall(plugin)" label="Uninstall" i18n-label></my-delete-button>
         </div>
index f250404ed70f325293f8d4e95701ab87e4d18449..7641c507b05eee43acb5c93c469a0b75ce2ec3c9 100644 (file)
@@ -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;
+}
index 26a9a616ea53944b1575709032c8bf338789bd90..67a11c3a895b193e8e516233c99a957f61ef6833 100644 (file)
@@ -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
+  }
 }
index 1d33cd1791c06332c6dd1fd6012a0d59b82a471d..89f190675b603b38405dc1baf65a98ae06c2c3dc 100644 (file)
@@ -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
     }
 
index 86bde2d02369bc3bdef7542147d6debd0d614c89..c6ba3dd17bf59dca7adb5846a02dc4ab14217eb2 100644 (file)
@@ -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
           }
         })
index b6df671029e3f21948b465e4da8189a1e4acc820..d2b0eb81a8a329ed856445ff8850badc09f73949 100644 (file)
@@ -1,4 +1,6 @@
 <span class="action-button" [ngClass]="className" [title]="getTitle()">
-  <my-global-icon [iconName]="icon"></my-global-icon>
+  <my-global-icon *ngIf="!loading" [iconName]="icon"></my-global-icon>
+  <my-small-loader [loading]="loading"></my-small-loader>
+
   <span class="button-label">{{ label }}</span>
 </span>
index 99d7f51c1b5bd2e7e45d852f4f282fd2015ab038..4cc2b05732fd72ca4a159fa8690120ab7e94e350 100644 (file)
@@ -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);
index cf334e8d59c1d641c4737d67ccff519ff4cb095e..cac5ad210ff9d6a609db9fba01feb5965861f2cb 100644 (file)
@@ -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
index 5a7cea7384c111f397e3a58e5b33f36a1db454b7..7886f8918f93cc664ac7ad0ccb602360d8ff61ea 100644 (file)
@@ -1,3 +1,3 @@
-<div *ngIf="loading">
+<div class="root" *ngIf="loading">
   <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div>
 </div>
index 85fc1c3a0987af8f03af863be655be1480790791..098496d45348a70fa8a57297da4d99b62c479741 100644 (file)
@@ -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,
index 306476c6ae184381e8417cfbde13d440065bff76..7811e0f39f0518a24e9ec189997640a97850b6d4 100644 (file)
@@ -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 (executable)
index 0000000..1725cbe
--- /dev/null
@@ -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 (executable)
index 0000000..b5e1dde
--- /dev/null
@@ -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)
+}
index d8e8f1e97d78c97db700dea19114f82bcd5a264d..f6fae3718f36ba30da213ed2f65f15588b569416 100644 (file)
--- 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)
index 8e59f27cf33b98f3761f021eb0be56ac98b5eef6..14675fdf30452e25681a8e5da69cc5f5c680505e 100644 (file)
@@ -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
index 869546dc739e344e99060c3b3a8ce45fee110988..8b3501712798e9ce649b3ef07e6d122af540b4fe 100644 (file)
@@ -8,4 +8,3 @@ export * from './webfinger'
 export * from './tracker'
 export * from './bots'
 export * from './plugins'
-export * from './themes'
index 05f03324d80f69f8e2b4095925a4b85678784c42..f255d13e840ec3b279ca7b06583534cf853b3186 100644 (file)
@@ -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))
+}
index 8cdeff446f0bb4c57f79bb4a72f2bb7ec2be14e3..2fa80e8789e56680c3af1454cc3dc2ba0705d21b 100644 (file)
@@ -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()
 
-    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)
+      }
 
-    // Remove hooks of this plugin
-    for (const key of Object.keys(this.hooks)) {
-      this.hooks[key] = this.hooks[key].filter(h => h.pluginName !== name)
+      logger.info('Regenerating registered plugin CSS to global file.')
+      await this.regeneratePluginGlobalCSS()
     }
 
-    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)
index a1634ded466ecc6258449ccdbd259291fc2875b7..8103ec7d327e392381a9886ceb882e9db5f37ed2 100644 (file)
@@ -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
 }
index 226c08342062aa3590195ff2a548f73bbdddd9a3..340d49f3b251f68bfd5bfd85dd1f01baa5e1db12 100644 (file)
@@ -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<PluginModel> {
   @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<PluginModel> {
     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<PluginModel> {
       : 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,
index d5e024383678d6a0f06091d0c7340adec33b95e0..10cff7dd739468303196af16c4e553300d95c4fa 100644 (file)
@@ -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 <npmName>', 'Install from npm')
   .action((options) => installPluginCLI(options))
 
+program
+  .command('update')
+  .description('Update a plugin or a theme')
+  .option('-u, --url <url>', 'Server url')
+  .option('-U, --username <username>', 'Username')
+  .option('-p, --password <token>', 'Password')
+  .option('-P --path <path>', 'Update from a path')
+  .option('-n, --npm-name <npmName>', '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')
index 6cd7cd17adcf27f69fe2e47a0f80278ab83d5125..1da313ab713ceac535c027032029b36cab2629a1 100644 (file)
@@ -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,
index b1b46fa0838faa897b303b6b918834c2fd5f5251..5a268ebe18b436d852d1eb07c6f90451133c01c0 100644 (file)
@@ -1,4 +1,4 @@
-export interface InstallPlugin {
+export interface InstallOrUpdatePlugin {
   npmName?: string
   path?: string
 }
index de3c7741bc07625511fde74b9997429976f13d60..e3c10002736b1d377ff0191616776a9c55ac3727 100644 (file)
@@ -1,6 +1,7 @@
 export interface PeerTubePlugin {
   name: string
   type: number
+  latestVersion: string
   version: string
   enabled: boolean
   uninstalled: boolean