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