aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html5
-rw-r--r--client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.scss4
-rw-r--r--client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts43
-rw-r--r--client/src/app/+admin/plugins/shared/plugin-api.service.ts13
-rw-r--r--client/src/app/core/plugins/plugin.service.ts6
-rw-r--r--client/src/app/shared/buttons/button.component.html4
-rw-r--r--client/src/app/shared/buttons/button.component.scss6
-rw-r--r--client/src/app/shared/buttons/button.component.ts1
-rw-r--r--client/src/app/shared/misc/small-loader.component.html2
-rw-r--r--client/src/app/shared/misc/utils.ts18
-rw-r--r--package.json2
-rwxr-xr-xscripts/plugin/install.ts39
-rwxr-xr-xscripts/plugin/uninstall.ts26
-rw-r--r--server.ts4
-rw-r--r--server/controllers/api/plugins.ts38
-rw-r--r--server/controllers/index.ts1
-rw-r--r--server/controllers/plugins.ts40
-rw-r--r--server/lib/plugins/plugin-manager.ts102
-rw-r--r--server/middlewares/validators/plugins.ts20
-rw-r--r--server/models/server/plugin.ts32
-rw-r--r--server/tools/peertube-plugins.ts44
-rw-r--r--shared/extra-utils/server/plugins.ts22
-rw-r--r--shared/models/plugins/install-plugin.model.ts2
-rw-r--r--shared/models/plugins/peertube-plugin.model.ts1
24 files changed, 387 insertions, 88 deletions
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 @@
26 <span i18n class="button-label">Homepage</span> 26 <span i18n class="button-label">Homepage</span>
27 </a> 27 </a>
28 28
29 <my-edit-button *ngIf="pluginType !== PluginType.THEME" [routerLink]="getShowRouterLink(plugin)" label="Settings" i18n-label></my-edit-button>
29 30
30 <my-edit-button [routerLink]="getShowRouterLink(plugin)" label="Settings" i18n-label></my-edit-button> 31 <my-button class="update-button" *ngIf="!isUpdateAvailable(plugin)" (click)="update(plugin)" [loading]="isUpdating(plugin)"
32 [label]="getUpdateLabel(plugin)" icon="refresh" [attr.disabled]="isUpdating(plugin)"
33 ></my-button>
31 34
32 <my-delete-button (click)="uninstall(plugin)" label="Uninstall" i18n-label></my-delete-button> 35 <my-delete-button (click)="uninstall(plugin)" label="Uninstall" i18n-label></my-delete-button>
33 </div> 36 </div>
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 @@
35 @include peertube-button-link; 35 @include peertube-button-link;
36 @include button-with-icon(21px, 0, -2px); 36 @include button-with-icon(21px, 0, -2px);
37} 37}
38
39.update-button[disabled="true"] /deep/ .action-button {
40 cursor: default !important;
41}
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
6import { ConfirmService, Notifier } from '@app/core' 6import { ConfirmService, Notifier } from '@app/core'
7import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model' 7import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
8import { ActivatedRoute, Router } from '@angular/router' 8import { ActivatedRoute, Router } from '@angular/router'
9import { compareSemVer } from '@app/shared/misc/utils'
9 10
10@Component({ 11@Component({
11 selector: 'my-plugin-list-installed', 12 selector: 'my-plugin-list-installed',
@@ -26,6 +27,9 @@ export class PluginListInstalledComponent implements OnInit {
26 sort = 'name' 27 sort = 'name'
27 28
28 plugins: PeerTubePlugin[] = [] 29 plugins: PeerTubePlugin[] = []
30 updating: { [name: string]: boolean } = {}
31
32 PluginType = PluginType
29 33
30 constructor ( 34 constructor (
31 private i18n: I18n, 35 private i18n: I18n,
@@ -49,7 +53,7 @@ export class PluginListInstalledComponent implements OnInit {
49 this.pagination.currentPage = 1 53 this.pagination.currentPage = 1
50 this.plugins = [] 54 this.plugins = []
51 55
52 this.router.navigate([], { queryParams: { pluginType: this.pluginType }}) 56 this.router.navigate([], { queryParams: { pluginType: this.pluginType } })
53 57
54 this.loadMorePlugins() 58 this.loadMorePlugins()
55 } 59 }
@@ -82,6 +86,18 @@ export class PluginListInstalledComponent implements OnInit {
82 return this.i18n('You don\'t have themes installed yet.') 86 return this.i18n('You don\'t have themes installed yet.')
83 } 87 }
84 88
89 isUpdateAvailable (plugin: PeerTubePlugin) {
90 return plugin.latestVersion && compareSemVer(plugin.latestVersion, plugin.version) > 0
91 }
92
93 getUpdateLabel (plugin: PeerTubePlugin) {
94 return this.i18n('Update to {{version}}', { version: plugin.latestVersion })
95 }
96
97 isUpdating (plugin: PeerTubePlugin) {
98 return !!this.updating[this.getUpdatingKey(plugin)]
99 }
100
85 async uninstall (plugin: PeerTubePlugin) { 101 async uninstall (plugin: PeerTubePlugin) {
86 const res = await this.confirmService.confirm( 102 const res = await this.confirmService.confirm(
87 this.i18n('Do you really want to uninstall {{pluginName}}?', { pluginName: plugin.name }), 103 this.i18n('Do you really want to uninstall {{pluginName}}?', { pluginName: plugin.name }),
@@ -102,7 +118,32 @@ export class PluginListInstalledComponent implements OnInit {
102 ) 118 )
103 } 119 }
104 120
121 async update (plugin: PeerTubePlugin) {
122 const updatingKey = this.getUpdatingKey(plugin)
123 if (this.updating[updatingKey]) return
124
125 this.updating[updatingKey] = true
126
127 this.pluginService.update(plugin.name, plugin.type)
128 .pipe()
129 .subscribe(
130 res => {
131 this.updating[updatingKey] = false
132
133 this.notifier.success(this.i18n('{{pluginName}} updated.', { pluginName: plugin.name }))
134
135 Object.assign(plugin, res)
136 },
137
138 err => this.notifier.error(err.message)
139 )
140 }
141
105 getShowRouterLink (plugin: PeerTubePlugin) { 142 getShowRouterLink (plugin: PeerTubePlugin) {
106 return [ '/admin', 'plugins', 'show', this.pluginService.nameToNpmName(plugin.name, plugin.type) ] 143 return [ '/admin', 'plugins', 'show', this.pluginService.nameToNpmName(plugin.name, plugin.type) ]
107 } 144 }
145
146 private getUpdatingKey (plugin: PeerTubePlugin) {
147 return plugin.name + plugin.type
148 }
108} 149}
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
9import { ResultList } from '@shared/models' 9import { ResultList } from '@shared/models'
10import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model' 10import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
11import { ManagePlugin } from '@shared/models/plugins/manage-plugin.model' 11import { ManagePlugin } from '@shared/models/plugins/manage-plugin.model'
12import { InstallPlugin } from '@shared/models/plugins/install-plugin.model' 12import { InstallOrUpdatePlugin } from '@shared/models/plugins/install-plugin.model'
13import { RegisterSettingOptions } from '@shared/models/plugins/register-setting.model' 13import { RegisterSettingOptions } from '@shared/models/plugins/register-setting.model'
14 14
15@Injectable() 15@Injectable()
@@ -89,8 +89,17 @@ export class PluginApiService {
89 .pipe(catchError(res => this.restExtractor.handleError(res))) 89 .pipe(catchError(res => this.restExtractor.handleError(res)))
90 } 90 }
91 91
92 update (pluginName: string, pluginType: PluginType) {
93 const body: ManagePlugin = {
94 npmName: this.nameToNpmName(pluginName, pluginType)
95 }
96
97 return this.authHttp.post(PluginApiService.BASE_APPLICATION_URL + '/update', body)
98 .pipe(catchError(res => this.restExtractor.handleError(res)))
99 }
100
92 install (npmName: string) { 101 install (npmName: string) {
93 const body: InstallPlugin = { 102 const body: InstallOrUpdatePlugin = {
94 npmName 103 npmName
95 } 104 }
96 105
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 {
48 .toPromise() 48 .toPromise()
49 } 49 }
50 50
51 addPlugin (plugin: ServerConfigPlugin) { 51 addPlugin (plugin: ServerConfigPlugin, isTheme = false) {
52 const pathPrefix = isTheme ? '/themes' : '/plugins'
53
52 for (const key of Object.keys(plugin.clientScripts)) { 54 for (const key of Object.keys(plugin.clientScripts)) {
53 const clientScript = plugin.clientScripts[key] 55 const clientScript = plugin.clientScripts[key]
54 56
@@ -58,7 +60,7 @@ export class PluginService {
58 this.scopes[scope].push({ 60 this.scopes[scope].push({
59 plugin, 61 plugin,
60 clientScript: { 62 clientScript: {
61 script: environment.apiUrl + `/plugins/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`, 63 script: environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`,
62 scopes: clientScript.scopes 64 scopes: clientScript.scopes
63 } 65 }
64 }) 66 })
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 @@
1<span class="action-button" [ngClass]="className" [title]="getTitle()"> 1<span class="action-button" [ngClass]="className" [title]="getTitle()">
2 <my-global-icon [iconName]="icon"></my-global-icon> 2 <my-global-icon *ngIf="!loading" [iconName]="icon"></my-global-icon>
3 <my-small-loader [loading]="loading"></my-small-loader>
4
3 <span class="button-label">{{ label }}</span> 5 <span class="button-label">{{ label }}</span>
4</span> 6</span>
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 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4my-small-loader /deep/ .root {
5 display: inline-block;
6 margin: 0 3px 0 0;
7 width: 20px;
8}
9
4.action-button { 10.action-button {
5 @include peertube-button-link; 11 @include peertube-button-link;
6 @include button-with-icon(21px, 0, -2px); 12 @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 {
12 @Input() className = 'grey-button' 12 @Input() className = 'grey-button'
13 @Input() icon: GlobalIconName = undefined 13 @Input() icon: GlobalIconName = undefined
14 @Input() title: string = undefined 14 @Input() title: string = undefined
15 @Input() loading = false
15 16
16 getTitle () { 17 getTitle () {
17 return this.title || this.label 18 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 @@
1<div *ngIf="loading"> 1<div class="root" *ngIf="loading">
2 <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div> 2 <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div>
3</div> 3</div>
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 () {
134 window.scroll(0, 0) 134 window.scroll(0, 0)
135} 135}
136 136
137// Thanks https://stackoverflow.com/a/16187766
138function compareSemVer (a: string, b: string) {
139 const regExStrip0 = /(\.0+)+$/
140 const segmentsA = a.replace(regExStrip0, '').split('.')
141 const segmentsB = b.replace(regExStrip0, '').split('.')
142
143 const l = Math.min(segmentsA.length, segmentsB.length)
144
145 for (let i = 0; i < l; i++) {
146 const diff = parseInt(segmentsA[ i ], 10) - parseInt(segmentsB[ i ], 10)
147
148 if (diff) return diff
149 }
150
151 return segmentsA.length - segmentsB.length
152}
153
137export { 154export {
138 sortBy, 155 sortBy,
139 durationToString, 156 durationToString,
@@ -144,6 +161,7 @@ export {
144 getAbsoluteAPIUrl, 161 getAbsoluteAPIUrl,
145 dateToHuman, 162 dateToHuman,
146 immutableAssign, 163 immutableAssign,
164 compareSemVer,
147 objectToFormData, 165 objectToFormData,
148 objectLineFeedToHtml, 166 objectLineFeedToHtml,
149 removeElementFromArray, 167 removeElementFromArray,
diff --git a/package.json b/package.json
index 306476c6a..7811e0f39 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,8 @@
36 "danger:clean:prod": "scripty", 36 "danger:clean:prod": "scripty",
37 "danger:clean:modules": "scripty", 37 "danger:clean:modules": "scripty",
38 "i18n:generate": "scripty", 38 "i18n:generate": "scripty",
39 "plugin:install": "node ./dist/scripts/plugin/install.js",
40 "plugin:uninstall": "node ./dist/scripts/plugin/uninstall.js",
39 "i18n:xliff2json": "node ./dist/scripts/i18n/xliff2json.js", 41 "i18n:xliff2json": "node ./dist/scripts/i18n/xliff2json.js",
40 "i18n:create-custom-files": "node ./dist/scripts/i18n/create-custom-files.js", 42 "i18n:create-custom-files": "node ./dist/scripts/i18n/create-custom-files.js",
41 "reset-password": "node ./dist/scripts/reset-password.js", 43 "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 @@
1import { initDatabaseModels } from '../../server/initializers/database'
2import * as program from 'commander'
3import { PluginManager } from '../../server/lib/plugins/plugin-manager'
4import { isAbsolute } from 'path'
5
6program
7 .option('-n, --plugin-name [pluginName]', 'Plugin name to install')
8 .option('-v, --plugin-version [pluginVersion]', 'Plugin version to install')
9 .option('-p, --plugin-path [pluginPath]', 'Path of the plugin you want to install')
10 .parse(process.argv)
11
12if (!program['pluginName'] && !program['pluginPath']) {
13 console.error('You need to specify a plugin name with the desired version, or a plugin path.')
14 process.exit(-1)
15}
16
17if (program['pluginName'] && !program['pluginVersion']) {
18 console.error('You need to specify a the version of the plugin you want to install.')
19 process.exit(-1)
20}
21
22if (program['pluginPath'] && !isAbsolute(program['pluginPath'])) {
23 console.error('Plugin path should be absolute.')
24 process.exit(-1)
25}
26
27run()
28 .then(() => process.exit(0))
29 .catch(err => {
30 console.error(err)
31 process.exit(-1)
32 })
33
34async function run () {
35 await initDatabaseModels(true)
36
37 const toInstall = program['pluginName'] || program['pluginPath']
38 await PluginManager.Instance.install(toInstall, program['pluginVersion'], !!program['pluginPath'])
39}
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 @@
1import { initDatabaseModels } from '../../server/initializers/database'
2import * as program from 'commander'
3import { PluginManager } from '../../server/lib/plugins/plugin-manager'
4
5program
6 .option('-n, --npm-name [npmName]', 'Package name to install')
7 .parse(process.argv)
8
9if (!program['npmName']) {
10 console.error('You need to specify the plugin name.')
11 process.exit(-1)
12}
13
14run()
15 .then(() => process.exit(0))
16 .catch(err => {
17 console.error(err)
18 process.exit(-1)
19 })
20
21async function run () {
22 await initDatabaseModels(true)
23
24 const toUninstall = program['npmName']
25 await PluginManager.Instance.uninstall(toUninstall)
26}
diff --git a/server.ts b/server.ts
index d8e8f1e97..f6fae3718 100644
--- a/server.ts
+++ b/server.ts
@@ -97,7 +97,6 @@ import {
97 staticRouter, 97 staticRouter,
98 servicesRouter, 98 servicesRouter,
99 pluginsRouter, 99 pluginsRouter,
100 themesRouter,
101 webfingerRouter, 100 webfingerRouter,
102 trackerRouter, 101 trackerRouter,
103 createWebsocketTrackerServer, botsRouter 102 createWebsocketTrackerServer, botsRouter
@@ -178,8 +177,7 @@ app.use(apiRoute, apiRouter)
178app.use('/services', servicesRouter) 177app.use('/services', servicesRouter)
179 178
180// Plugins & themes 179// Plugins & themes
181app.use('/plugins', pluginsRouter) 180app.use('/', pluginsRouter)
182app.use('/themes', themesRouter)
183 181
184app.use('/', activityPubRouter) 182app.use('/', activityPubRouter)
185app.use('/', feedsRouter) 183app.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'
13import { UserRight } from '../../../shared/models/users' 13import { UserRight } from '../../../shared/models/users'
14import { 14import {
15 existingPluginValidator, 15 existingPluginValidator,
16 installPluginValidator, 16 installOrUpdatePluginValidator,
17 listPluginsValidator, 17 listPluginsValidator,
18 uninstallPluginValidator, 18 uninstallPluginValidator,
19 updatePluginSettingsValidator 19 updatePluginSettingsValidator
20} from '../../middlewares/validators/plugins' 20} from '../../middlewares/validators/plugins'
21import { PluginManager } from '../../lib/plugins/plugin-manager' 21import { PluginManager } from '../../lib/plugins/plugin-manager'
22import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model' 22import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-plugin.model'
23import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model' 23import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model'
24import { logger } from '../../helpers/logger' 24import { logger } from '../../helpers/logger'
25 25
@@ -61,10 +61,17 @@ pluginRouter.put('/:npmName/settings',
61pluginRouter.post('/install', 61pluginRouter.post('/install',
62 authenticate, 62 authenticate,
63 ensureUserHasRight(UserRight.MANAGE_PLUGINS), 63 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
64 installPluginValidator, 64 installOrUpdatePluginValidator,
65 asyncMiddleware(installPlugin) 65 asyncMiddleware(installPlugin)
66) 66)
67 67
68pluginRouter.post('/update',
69 authenticate,
70 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
71 installOrUpdatePluginValidator,
72 asyncMiddleware(updatePlugin)
73)
74
68pluginRouter.post('/uninstall', 75pluginRouter.post('/uninstall',
69 authenticate, 76 authenticate,
70 ensureUserHasRight(UserRight.MANAGE_PLUGINS), 77 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
@@ -100,18 +107,33 @@ function getPlugin (req: express.Request, res: express.Response) {
100} 107}
101 108
102async function installPlugin (req: express.Request, res: express.Response) { 109async function installPlugin (req: express.Request, res: express.Response) {
103 const body: InstallPlugin = req.body 110 const body: InstallOrUpdatePlugin = req.body
104 111
105 const fromDisk = !!body.path 112 const fromDisk = !!body.path
106 const toInstall = body.npmName || body.path 113 const toInstall = body.npmName || body.path
107 try { 114 try {
108 await PluginManager.Instance.install(toInstall, undefined, fromDisk) 115 const plugin = await PluginManager.Instance.install(toInstall, undefined, fromDisk)
116
117 return res.json(plugin.toFormattedJSON())
109 } catch (err) { 118 } catch (err) {
110 logger.warn('Cannot install plugin %s.', toInstall, { err }) 119 logger.warn('Cannot install plugin %s.', toInstall, { err })
111 return res.sendStatus(400) 120 return res.sendStatus(400)
112 } 121 }
122}
113 123
114 return res.sendStatus(204) 124async function updatePlugin (req: express.Request, res: express.Response) {
125 const body: InstallOrUpdatePlugin = req.body
126
127 const fromDisk = !!body.path
128 const toUpdate = body.npmName || body.path
129 try {
130 const plugin = await PluginManager.Instance.update(toUpdate, undefined, fromDisk)
131
132 return res.json(plugin.toFormattedJSON())
133 } catch (err) {
134 logger.warn('Cannot update plugin %s.', toUpdate, { err })
135 return res.sendStatus(400)
136 }
115} 137}
116 138
117async function uninstallPlugin (req: express.Request, res: express.Response) { 139async function uninstallPlugin (req: express.Request, res: express.Response) {
@@ -123,9 +145,7 @@ async function uninstallPlugin (req: express.Request, res: express.Response) {
123} 145}
124 146
125function getPluginRegisteredSettings (req: express.Request, res: express.Response) { 147function getPluginRegisteredSettings (req: express.Request, res: express.Response) {
126 const plugin = res.locals.plugin 148 const settings = PluginManager.Instance.getRegisteredSettings(req.params.npmName)
127
128 const settings = PluginManager.Instance.getSettings(plugin.name)
129 149
130 return res.json({ 150 return res.json({
131 settings 151 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'
8export * from './tracker' 8export * from './tracker'
9export * from './bots' 9export * from './bots'
10export * from './plugins' 10export * from './plugins'
11export * 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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants' 2import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants'
3import { basename, join } from 'path' 3import { join } from 'path'
4import { RegisteredPlugin } from '../lib/plugins/plugin-manager' 4import { RegisteredPlugin } from '../lib/plugins/plugin-manager'
5import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins' 5import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins'
6import { serveThemeCSSValidator } from '../middlewares/validators/themes'
7import { PluginType } from '../../shared/models/plugins/plugin.type'
6 8
7const pluginsRouter = express.Router() 9const pluginsRouter = express.Router()
8 10
9pluginsRouter.get('/global.css', 11pluginsRouter.get('/plugins/global.css',
10 servePluginGlobalCSS 12 servePluginGlobalCSS
11) 13)
12 14
13pluginsRouter.get('/:pluginName/:pluginVersion/static/:staticEndpoint(*)', 15pluginsRouter.get('/plugins/:pluginName/:pluginVersion/static/:staticEndpoint(*)',
14 servePluginStaticDirectoryValidator, 16 servePluginStaticDirectoryValidator(PluginType.PLUGIN),
15 servePluginStaticDirectory 17 servePluginStaticDirectory
16) 18)
17 19
18pluginsRouter.get('/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', 20pluginsRouter.get('/plugins/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)',
19 servePluginStaticDirectoryValidator, 21 servePluginStaticDirectoryValidator(PluginType.PLUGIN),
20 servePluginClientScripts 22 servePluginClientScripts
21) 23)
22 24
25pluginsRouter.get('/themes/:pluginName/:pluginVersion/static/:staticEndpoint(*)',
26 servePluginStaticDirectoryValidator(PluginType.THEME),
27 servePluginStaticDirectory
28)
29
30pluginsRouter.get('/themes/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)',
31 servePluginStaticDirectoryValidator(PluginType.THEME),
32 servePluginClientScripts
33)
34
35pluginsRouter.get('/themes/:themeName/:themeVersion/css/:staticEndpoint(*)',
36 serveThemeCSSValidator,
37 serveThemeCSSDirectory
38)
39
23// --------------------------------------------------------------------------- 40// ---------------------------------------------------------------------------
24 41
25export { 42export {
@@ -58,3 +75,14 @@ function servePluginClientScripts (req: express.Request, res: express.Response)
58 75
59 return res.sendFile(join(plugin.path, staticEndpoint)) 76 return res.sendFile(join(plugin.path, staticEndpoint))
60} 77}
78
79function serveThemeCSSDirectory (req: express.Request, res: express.Response) {
80 const plugin: RegisteredPlugin = res.locals.registeredPlugin
81 const staticEndpoint = req.params.staticEndpoint
82
83 if (plugin.css.includes(staticEndpoint) === false) {
84 return res.sendStatus(404)
85 }
86
87 return res.sendFile(join(plugin.path, staticEndpoint))
88}
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
15import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model' 15import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model'
16 16
17export interface RegisteredPlugin { 17export interface RegisteredPlugin {
18 npmName: string
18 name: string 19 name: string
19 version: string 20 version: string
20 description: string 21 description: string
@@ -34,6 +35,7 @@ export interface RegisteredPlugin {
34} 35}
35 36
36export interface HookInformationValue { 37export interface HookInformationValue {
38 npmName: string
37 pluginName: string 39 pluginName: string
38 handler: Function 40 handler: Function
39 priority: number 41 priority: number
@@ -52,12 +54,13 @@ export class PluginManager {
52 54
53 // ###################### Getters ###################### 55 // ###################### Getters ######################
54 56
55 getRegisteredPluginOrTheme (name: string) { 57 getRegisteredPluginOrTheme (npmName: string) {
56 return this.registeredPlugins[name] 58 return this.registeredPlugins[npmName]
57 } 59 }
58 60
59 getRegisteredPlugin (name: string) { 61 getRegisteredPlugin (name: string) {
60 const registered = this.getRegisteredPluginOrTheme(name) 62 const npmName = PluginModel.buildNpmName(name, PluginType.PLUGIN)
63 const registered = this.getRegisteredPluginOrTheme(npmName)
61 64
62 if (!registered || registered.type !== PluginType.PLUGIN) return undefined 65 if (!registered || registered.type !== PluginType.PLUGIN) return undefined
63 66
@@ -65,7 +68,8 @@ export class PluginManager {
65 } 68 }
66 69
67 getRegisteredTheme (name: string) { 70 getRegisteredTheme (name: string) {
68 const registered = this.getRegisteredPluginOrTheme(name) 71 const npmName = PluginModel.buildNpmName(name, PluginType.THEME)
72 const registered = this.getRegisteredPluginOrTheme(npmName)
69 73
70 if (!registered || registered.type !== PluginType.THEME) return undefined 74 if (!registered || registered.type !== PluginType.THEME) return undefined
71 75
@@ -80,8 +84,8 @@ export class PluginManager {
80 return this.getRegisteredPluginsOrThemes(PluginType.THEME) 84 return this.getRegisteredPluginsOrThemes(PluginType.THEME)
81 } 85 }
82 86
83 getSettings (name: string) { 87 getRegisteredSettings (npmName: string) {
84 return this.settings[name] || [] 88 return this.settings[npmName] || []
85 } 89 }
86 90
87 // ###################### Hooks ###################### 91 // ###################### Hooks ######################
@@ -126,35 +130,36 @@ export class PluginManager {
126 this.sortHooksByPriority() 130 this.sortHooksByPriority()
127 } 131 }
128 132
129 async unregister (name: string) { 133 // Don't need the plugin type since themes cannot register server code
130 const plugin = this.getRegisteredPlugin(name) 134 async unregister (npmName: string) {
135 logger.info('Unregister plugin %s.', npmName)
136
137 const plugin = this.getRegisteredPluginOrTheme(npmName)
131 138
132 if (!plugin) { 139 if (!plugin) {
133 throw new Error(`Unknown plugin ${name} to unregister`) 140 throw new Error(`Unknown plugin ${npmName} to unregister`)
134 } 141 }
135 142
136 if (plugin.type === PluginType.THEME) { 143 if (plugin.type === PluginType.PLUGIN) {
137 throw new Error(`Cannot unregister ${name}: this is a theme`) 144 await plugin.unregister()
138 }
139 145
140 await plugin.unregister() 146 // Remove hooks of this plugin
147 for (const key of Object.keys(this.hooks)) {
148 this.hooks[key] = this.hooks[key].filter(h => h.pluginName !== npmName)
149 }
141 150
142 // Remove hooks of this plugin 151 logger.info('Regenerating registered plugin CSS to global file.')
143 for (const key of Object.keys(this.hooks)) { 152 await this.regeneratePluginGlobalCSS()
144 this.hooks[key] = this.hooks[key].filter(h => h.pluginName !== name)
145 } 153 }
146 154
147 delete this.registeredPlugins[plugin.name] 155 delete this.registeredPlugins[plugin.npmName]
148
149 logger.info('Regenerating registered plugin CSS to global file.')
150 await this.regeneratePluginGlobalCSS()
151 } 156 }
152 157
153 // ###################### Installation ###################### 158 // ###################### Installation ######################
154 159
155 async install (toInstall: string, version?: string, fromDisk = false) { 160 async install (toInstall: string, version?: string, fromDisk = false) {
156 let plugin: PluginModel 161 let plugin: PluginModel
157 let name: string 162 let npmName: string
158 163
159 logger.info('Installing plugin %s.', toInstall) 164 logger.info('Installing plugin %s.', toInstall)
160 165
@@ -163,9 +168,9 @@ export class PluginManager {
163 ? await installNpmPluginFromDisk(toInstall) 168 ? await installNpmPluginFromDisk(toInstall)
164 : await installNpmPlugin(toInstall, version) 169 : await installNpmPlugin(toInstall, version)
165 170
166 name = fromDisk ? basename(toInstall) : toInstall 171 npmName = fromDisk ? basename(toInstall) : toInstall
167 const pluginType = PluginModel.getTypeFromNpmName(name) 172 const pluginType = PluginModel.getTypeFromNpmName(npmName)
168 const pluginName = PluginModel.normalizePluginName(name) 173 const pluginName = PluginModel.normalizePluginName(npmName)
169 174
170 const packageJSON = this.getPackageJSON(pluginName, pluginType) 175 const packageJSON = this.getPackageJSON(pluginName, pluginType)
171 if (!isPackageJSONValid(packageJSON, pluginType)) { 176 if (!isPackageJSONValid(packageJSON, pluginType)) {
@@ -186,7 +191,7 @@ export class PluginManager {
186 logger.error('Cannot install plugin %s, removing it...', toInstall, { err }) 191 logger.error('Cannot install plugin %s, removing it...', toInstall, { err })
187 192
188 try { 193 try {
189 await removeNpmPlugin(name) 194 await removeNpmPlugin(npmName)
190 } catch (err) { 195 } catch (err) {
191 logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err }) 196 logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err })
192 } 197 }
@@ -197,17 +202,28 @@ export class PluginManager {
197 logger.info('Successful installation of plugin %s.', toInstall) 202 logger.info('Successful installation of plugin %s.', toInstall)
198 203
199 await this.registerPluginOrTheme(plugin) 204 await this.registerPluginOrTheme(plugin)
205
206 return plugin
207 }
208
209 async update (toUpdate: string, version?: string, fromDisk = false) {
210 const npmName = fromDisk ? basename(toUpdate) : toUpdate
211
212 logger.info('Updating plugin %s.', npmName)
213
214 // Unregister old hooks
215 await this.unregister(npmName)
216
217 return this.install(toUpdate, version, fromDisk)
200 } 218 }
201 219
202 async uninstall (npmName: string) { 220 async uninstall (npmName: string) {
203 logger.info('Uninstalling plugin %s.', npmName) 221 logger.info('Uninstalling plugin %s.', npmName)
204 222
205 const pluginName = PluginModel.normalizePluginName(npmName)
206
207 try { 223 try {
208 await this.unregister(pluginName) 224 await this.unregister(npmName)
209 } catch (err) { 225 } catch (err) {
210 logger.warn('Cannot unregister plugin %s.', pluginName, { err }) 226 logger.warn('Cannot unregister plugin %s.', npmName, { err })
211 } 227 }
212 228
213 const plugin = await PluginModel.loadByNpmName(npmName) 229 const plugin = await PluginModel.loadByNpmName(npmName)
@@ -229,7 +245,9 @@ export class PluginManager {
229 // ###################### Private register ###################### 245 // ###################### Private register ######################
230 246
231 private async registerPluginOrTheme (plugin: PluginModel) { 247 private async registerPluginOrTheme (plugin: PluginModel) {
232 logger.info('Registering plugin or theme %s.', plugin.name) 248 const npmName = PluginModel.buildNpmName(plugin.name, plugin.type)
249
250 logger.info('Registering plugin or theme %s.', npmName)
233 251
234 const packageJSON = this.getPackageJSON(plugin.name, plugin.type) 252 const packageJSON = this.getPackageJSON(plugin.name, plugin.type)
235 const pluginPath = this.getPluginPath(plugin.name, plugin.type) 253 const pluginPath = this.getPluginPath(plugin.name, plugin.type)
@@ -248,7 +266,8 @@ export class PluginManager {
248 clientScripts[c.script] = c 266 clientScripts[c.script] = c
249 } 267 }
250 268
251 this.registeredPlugins[ plugin.name ] = { 269 this.registeredPlugins[ npmName ] = {
270 npmName,
252 name: plugin.name, 271 name: plugin.name,
253 type: plugin.type, 272 type: plugin.type,
254 version: plugin.version, 273 version: plugin.version,
@@ -263,10 +282,13 @@ export class PluginManager {
263 } 282 }
264 283
265 private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJson) { 284 private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJson) {
285 const npmName = PluginModel.buildNpmName(plugin.name, plugin.type)
286
266 const registerHook = (options: RegisterHookOptions) => { 287 const registerHook = (options: RegisterHookOptions) => {
267 if (!this.hooks[options.target]) this.hooks[options.target] = [] 288 if (!this.hooks[options.target]) this.hooks[options.target] = []
268 289
269 this.hooks[options.target].push({ 290 this.hooks[options.target].push({
291 npmName,
270 pluginName: plugin.name, 292 pluginName: plugin.name,
271 handler: options.handler, 293 handler: options.handler,
272 priority: options.priority || 0 294 priority: options.priority || 0
@@ -274,15 +296,15 @@ export class PluginManager {
274 } 296 }
275 297
276 const registerSetting = (options: RegisterSettingOptions) => { 298 const registerSetting = (options: RegisterSettingOptions) => {
277 if (!this.settings[plugin.name]) this.settings[plugin.name] = [] 299 if (!this.settings[npmName]) this.settings[npmName] = []
278 300
279 this.settings[plugin.name].push(options) 301 this.settings[npmName].push(options)
280 } 302 }
281 303
282 const settingsManager: PluginSettingsManager = { 304 const settingsManager: PluginSettingsManager = {
283 getSetting: (name: string) => PluginModel.getSetting(plugin.name, name), 305 getSetting: (name: string) => PluginModel.getSetting(plugin.name, plugin.type, name),
284 306
285 setSetting: (name: string, value: string) => PluginModel.setSetting(plugin.name, name, value) 307 setSetting: (name: string, value: string) => PluginModel.setSetting(plugin.name, plugin.type, name, value)
286 } 308 }
287 309
288 const library: PluginLibrary = require(join(pluginPath, packageJSON.library)) 310 const library: PluginLibrary = require(join(pluginPath, packageJSON.library))
@@ -293,7 +315,7 @@ export class PluginManager {
293 315
294 library.register({ registerHook, registerSetting, settingsManager }) 316 library.register({ registerHook, registerSetting, settingsManager })
295 317
296 logger.info('Add plugin %s CSS to global file.', plugin.name) 318 logger.info('Add plugin %s CSS to global file.', npmName)
297 319
298 await this.addCSSToGlobalFile(pluginPath, packageJSON.css) 320 await this.addCSSToGlobalFile(pluginPath, packageJSON.css)
299 321
@@ -351,9 +373,9 @@ export class PluginManager {
351 } 373 }
352 374
353 private getPluginPath (pluginName: string, pluginType: PluginType) { 375 private getPluginPath (pluginName: string, pluginType: PluginType) {
354 const prefix = pluginType === PluginType.PLUGIN ? 'peertube-plugin-' : 'peertube-theme-' 376 const npmName = PluginModel.buildNpmName(pluginName, pluginType)
355 377
356 return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', prefix + pluginName) 378 return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', npmName)
357 } 379 }
358 380
359 // ###################### Private getters ###################### 381 // ###################### Private getters ######################
@@ -361,8 +383,8 @@ export class PluginManager {
361 private getRegisteredPluginsOrThemes (type: PluginType) { 383 private getRegisteredPluginsOrThemes (type: PluginType) {
362 const plugins: RegisteredPlugin[] = [] 384 const plugins: RegisteredPlugin[] = []
363 385
364 for (const pluginName of Object.keys(this.registeredPlugins)) { 386 for (const npmName of Object.keys(this.registeredPlugins)) {
365 const plugin = this.registeredPlugins[ pluginName ] 387 const plugin = this.registeredPlugins[ npmName ]
366 if (plugin.type !== type) continue 388 if (plugin.type !== type) continue
367 389
368 plugins.push(plugin) 390 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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { param, query, body } from 'express-validator/check' 2import { body, param, query } from 'express-validator/check'
3import { logger } from '../../helpers/logger' 3import { logger } from '../../helpers/logger'
4import { areValidationErrors } from './utils' 4import { areValidationErrors } from './utils'
5import { isPluginNameValid, isPluginTypeValid, isPluginVersionValid, isNpmPluginNameValid } from '../../helpers/custom-validators/plugins' 5import { isNpmPluginNameValid, isPluginNameValid, isPluginTypeValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins'
6import { PluginManager } from '../../lib/plugins/plugin-manager' 6import { PluginManager } from '../../lib/plugins/plugin-manager'
7import { isBooleanValid, isSafePath } from '../../helpers/custom-validators/misc' 7import { isBooleanValid, isSafePath } from '../../helpers/custom-validators/misc'
8import { PluginModel } from '../../models/server/plugin' 8import { PluginModel } from '../../models/server/plugin'
9import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model' 9import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-plugin.model'
10import { PluginType } from '../../../shared/models/plugins/plugin.type'
10 11
11const servePluginStaticDirectoryValidator = [ 12const servePluginStaticDirectoryValidator = (pluginType: PluginType) => [
12 param('pluginName').custom(isPluginNameValid).withMessage('Should have a valid plugin name'), 13 param('pluginName').custom(isPluginNameValid).withMessage('Should have a valid plugin name'),
13 param('pluginVersion').custom(isPluginVersionValid).withMessage('Should have a valid plugin version'), 14 param('pluginVersion').custom(isPluginVersionValid).withMessage('Should have a valid plugin version'),
14 param('staticEndpoint').custom(isSafePath).withMessage('Should have a valid static endpoint'), 15 param('staticEndpoint').custom(isSafePath).withMessage('Should have a valid static endpoint'),
@@ -18,7 +19,8 @@ const servePluginStaticDirectoryValidator = [
18 19
19 if (areValidationErrors(req, res)) return 20 if (areValidationErrors(req, res)) return
20 21
21 const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(req.params.pluginName) 22 const npmName = PluginModel.buildNpmName(req.params.pluginName, pluginType)
23 const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(npmName)
22 24
23 if (!plugin || plugin.version !== req.params.pluginVersion) { 25 if (!plugin || plugin.version !== req.params.pluginVersion) {
24 return res.sendStatus(404) 26 return res.sendStatus(404)
@@ -48,7 +50,7 @@ const listPluginsValidator = [
48 } 50 }
49] 51]
50 52
51const installPluginValidator = [ 53const installOrUpdatePluginValidator = [
52 body('npmName') 54 body('npmName')
53 .optional() 55 .optional()
54 .custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'), 56 .custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'),
@@ -57,11 +59,11 @@ const installPluginValidator = [
57 .custom(isSafePath).withMessage('Should have a valid safe path'), 59 .custom(isSafePath).withMessage('Should have a valid safe path'),
58 60
59 (req: express.Request, res: express.Response, next: express.NextFunction) => { 61 (req: express.Request, res: express.Response, next: express.NextFunction) => {
60 logger.debug('Checking installPluginValidator parameters', { parameters: req.body }) 62 logger.debug('Checking installOrUpdatePluginValidator parameters', { parameters: req.body })
61 63
62 if (areValidationErrors(req, res)) return 64 if (areValidationErrors(req, res)) return
63 65
64 const body: InstallPlugin = req.body 66 const body: InstallOrUpdatePlugin = req.body
65 if (!body.path && !body.npmName) { 67 if (!body.path && !body.npmName) {
66 return res.status(400) 68 return res.status(400)
67 .json({ error: 'Should have either a npmName or a path' }) 69 .json({ error: 'Should have either a npmName or a path' })
@@ -124,6 +126,6 @@ export {
124 updatePluginSettingsValidator, 126 updatePluginSettingsValidator,
125 uninstallPluginValidator, 127 uninstallPluginValidator,
126 existingPluginValidator, 128 existingPluginValidator,
127 installPluginValidator, 129 installOrUpdatePluginValidator,
128 listPluginsValidator 130 listPluginsValidator
129} 131}
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 @@
1import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { getSort, throwIfNotValid } from '../utils' 2import { getSort, throwIfNotValid } from '../utils'
3import { 3import {
4 isPluginDescriptionValid, isPluginHomepage, 4 isPluginDescriptionValid,
5 isPluginHomepage,
5 isPluginNameValid, 6 isPluginNameValid,
6 isPluginTypeValid, 7 isPluginTypeValid,
7 isPluginVersionValid 8 isPluginVersionValid
@@ -42,6 +43,11 @@ export class PluginModel extends Model<PluginModel> {
42 @Column 43 @Column
43 version: string 44 version: string
44 45
46 @AllowNull(true)
47 @Is('PluginLatestVersion', value => throwIfNotValid(value, isPluginVersionValid, 'version'))
48 @Column
49 latestVersion: string
50
45 @AllowNull(false) 51 @AllowNull(false)
46 @Column 52 @Column
47 enabled: boolean 53 enabled: boolean
@@ -103,27 +109,28 @@ export class PluginModel extends Model<PluginModel> {
103 return PluginModel.findOne(query) 109 return PluginModel.findOne(query)
104 } 110 }
105 111
106 static getSetting (pluginName: string, settingName: string) { 112 static getSetting (pluginName: string, pluginType: PluginType, settingName: string) {
107 const query = { 113 const query = {
108 attributes: [ 'settings' ], 114 attributes: [ 'settings' ],
109 where: { 115 where: {
110 name: pluginName 116 name: pluginName,
117 type: pluginType
111 } 118 }
112 } 119 }
113 120
114 return PluginModel.findOne(query) 121 return PluginModel.findOne(query)
115 .then(p => p.settings) 122 .then(p => {
116 .then(settings => { 123 if (!p || !p.settings) return undefined
117 if (!settings) return undefined
118 124
119 return settings[settingName] 125 return p.settings[settingName]
120 }) 126 })
121 } 127 }
122 128
123 static setSetting (pluginName: string, settingName: string, settingValue: string) { 129 static setSetting (pluginName: string, pluginType: PluginType, settingName: string, settingValue: string) {
124 const query = { 130 const query = {
125 where: { 131 where: {
126 name: pluginName 132 name: pluginName,
133 type: pluginType
127 } 134 }
128 } 135 }
129 136
@@ -171,11 +178,18 @@ export class PluginModel extends Model<PluginModel> {
171 : PluginType.THEME 178 : PluginType.THEME
172 } 179 }
173 180
181 static buildNpmName (name: string, type: PluginType) {
182 if (type === PluginType.THEME) return 'peertube-theme-' + name
183
184 return 'peertube-plugin-' + name
185 }
186
174 toFormattedJSON (): PeerTubePlugin { 187 toFormattedJSON (): PeerTubePlugin {
175 return { 188 return {
176 name: this.name, 189 name: this.name,
177 type: this.type, 190 type: this.type,
178 version: this.version, 191 version: this.version,
192 latestVersion: this.latestVersion,
179 enabled: this.enabled, 193 enabled: this.enabled,
180 uninstalled: this.uninstalled, 194 uninstalled: this.uninstalled,
181 peertubeEngine: this.peertubeEngine, 195 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'
2import { PluginType } from '../../shared/models/plugins/plugin.type' 2import { PluginType } from '../../shared/models/plugins/plugin.type'
3import { getAccessToken } from '../../shared/extra-utils/users/login' 3import { getAccessToken } from '../../shared/extra-utils/users/login'
4import { getMyUserInformation } from '../../shared/extra-utils/users/users' 4import { getMyUserInformation } from '../../shared/extra-utils/users/users'
5import { installPlugin, listPlugins, uninstallPlugin } from '../../shared/extra-utils/server/plugins' 5import { installPlugin, listPlugins, uninstallPlugin, updatePlugin } from '../../shared/extra-utils/server/plugins'
6import { getServerCredentials } from './cli' 6import { getServerCredentials } from './cli'
7import { User, UserRole } from '../../shared/models/users' 7import { User, UserRole } from '../../shared/models/users'
8import { PeerTubePlugin } from '../../shared/models/plugins/peertube-plugin.model' 8import { PeerTubePlugin } from '../../shared/models/plugins/peertube-plugin.model'
@@ -35,6 +35,16 @@ program
35 .action((options) => installPluginCLI(options)) 35 .action((options) => installPluginCLI(options))
36 36
37program 37program
38 .command('update')
39 .description('Update a plugin or a theme')
40 .option('-u, --url <url>', 'Server url')
41 .option('-U, --username <username>', 'Username')
42 .option('-p, --password <token>', 'Password')
43 .option('-P --path <path>', 'Update from a path')
44 .option('-n, --npm-name <npmName>', 'Update from npm')
45 .action((options) => updatePluginCLI(options))
46
47program
38 .command('uninstall') 48 .command('uninstall')
39 .description('Uninstall a plugin or a theme') 49 .description('Uninstall a plugin or a theme')
40 .option('-u, --url <url>', 'Server url') 50 .option('-u, --url <url>', 'Server url')
@@ -122,6 +132,38 @@ async function installPluginCLI (options: any) {
122 process.exit(0) 132 process.exit(0)
123} 133}
124 134
135async function updatePluginCLI (options: any) {
136 if (!options['path'] && !options['npmName']) {
137 console.error('You need to specify the npm name or the path of the plugin you want to update.\n')
138 program.outputHelp()
139 process.exit(-1)
140 }
141
142 if (options['path'] && !isAbsolute(options['path'])) {
143 console.error('Path should be absolute.')
144 process.exit(-1)
145 }
146
147 const { url, username, password } = await getServerCredentials(options)
148 const accessToken = await getAdminTokenOrDie(url, username, password)
149
150 try {
151 await updatePlugin({
152 url,
153 accessToken,
154 npmName: options['npmName'],
155 path: options['path']
156 })
157 } catch (err) {
158 console.error('Cannot update plugin.', err)
159 process.exit(-1)
160 return
161 }
162
163 console.log('Plugin updated.')
164 process.exit(0)
165}
166
125async function uninstallPluginCLI (options: any) { 167async function uninstallPluginCLI (options: any) {
126 if (!options['npmName']) { 168 if (!options['npmName']) {
127 console.error('You need to specify the npm name of the plugin/theme you want to uninstall.\n') 169 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: {
85 npmName?: string 85 npmName?: string
86 expectedStatus?: number 86 expectedStatus?: number
87}) { 87}) {
88 const { url, accessToken, npmName, path, expectedStatus = 204 } = parameters 88 const { url, accessToken, npmName, path, expectedStatus = 200 } = parameters
89 const apiPath = '/api/v1/plugins/install' 89 const apiPath = '/api/v1/plugins/install'
90 90
91 return makePostBodyRequest({ 91 return makePostBodyRequest({
@@ -97,6 +97,25 @@ function installPlugin (parameters: {
97 }) 97 })
98} 98}
99 99
100function updatePlugin (parameters: {
101 url: string,
102 accessToken: string,
103 path?: string,
104 npmName?: string
105 expectedStatus?: number
106}) {
107 const { url, accessToken, npmName, path, expectedStatus = 200 } = parameters
108 const apiPath = '/api/v1/plugins/update'
109
110 return makePostBodyRequest({
111 url,
112 path: apiPath,
113 token: accessToken,
114 fields: { npmName, path },
115 statusCodeExpected: expectedStatus
116 })
117}
118
100function uninstallPlugin (parameters: { 119function uninstallPlugin (parameters: {
101 url: string, 120 url: string,
102 accessToken: string, 121 accessToken: string,
@@ -118,6 +137,7 @@ function uninstallPlugin (parameters: {
118export { 137export {
119 listPlugins, 138 listPlugins,
120 installPlugin, 139 installPlugin,
140 updatePlugin,
121 getPlugin, 141 getPlugin,
122 uninstallPlugin, 142 uninstallPlugin,
123 getPluginSettings, 143 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 @@
1export interface InstallPlugin { 1export interface InstallOrUpdatePlugin {
2 npmName?: string 2 npmName?: string
3 path?: string 3 path?: string
4} 4}
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 @@
1export interface PeerTubePlugin { 1export interface PeerTubePlugin {
2 name: string 2 name: string
3 type: number 3 type: number
4 latestVersion: string
4 version: string 5 version: string
5 enabled: boolean 6 enabled: boolean
6 uninstalled: boolean 7 uninstalled: boolean