diff options
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 | |||
6 | import { ConfirmService, Notifier } from '@app/core' | 6 | import { ConfirmService, Notifier } from '@app/core' |
7 | import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model' | 7 | import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model' |
8 | import { ActivatedRoute, Router } from '@angular/router' | 8 | import { ActivatedRoute, Router } from '@angular/router' |
9 | import { 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 | |||
9 | import { ResultList } from '@shared/models' | 9 | import { ResultList } from '@shared/models' |
10 | import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model' | 10 | import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model' |
11 | import { ManagePlugin } from '@shared/models/plugins/manage-plugin.model' | 11 | import { ManagePlugin } from '@shared/models/plugins/manage-plugin.model' |
12 | import { InstallPlugin } from '@shared/models/plugins/install-plugin.model' | 12 | import { InstallOrUpdatePlugin } from '@shared/models/plugins/install-plugin.model' |
13 | import { RegisterSettingOptions } from '@shared/models/plugins/register-setting.model' | 13 | import { 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 | ||
4 | my-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 | ||
138 | function 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 | |||
137 | export { | 154 | export { |
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 @@ | |||
1 | import { initDatabaseModels } from '../../server/initializers/database' | ||
2 | import * as program from 'commander' | ||
3 | import { PluginManager } from '../../server/lib/plugins/plugin-manager' | ||
4 | import { isAbsolute } from 'path' | ||
5 | |||
6 | program | ||
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 | |||
12 | if (!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 | |||
17 | if (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 | |||
22 | if (program['pluginPath'] && !isAbsolute(program['pluginPath'])) { | ||
23 | console.error('Plugin path should be absolute.') | ||
24 | process.exit(-1) | ||
25 | } | ||
26 | |||
27 | run() | ||
28 | .then(() => process.exit(0)) | ||
29 | .catch(err => { | ||
30 | console.error(err) | ||
31 | process.exit(-1) | ||
32 | }) | ||
33 | |||
34 | async 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 @@ | |||
1 | import { initDatabaseModels } from '../../server/initializers/database' | ||
2 | import * as program from 'commander' | ||
3 | import { PluginManager } from '../../server/lib/plugins/plugin-manager' | ||
4 | |||
5 | program | ||
6 | .option('-n, --npm-name [npmName]', 'Package name to install') | ||
7 | .parse(process.argv) | ||
8 | |||
9 | if (!program['npmName']) { | ||
10 | console.error('You need to specify the plugin name.') | ||
11 | process.exit(-1) | ||
12 | } | ||
13 | |||
14 | run() | ||
15 | .then(() => process.exit(0)) | ||
16 | .catch(err => { | ||
17 | console.error(err) | ||
18 | process.exit(-1) | ||
19 | }) | ||
20 | |||
21 | async function run () { | ||
22 | await initDatabaseModels(true) | ||
23 | |||
24 | const toUninstall = program['npmName'] | ||
25 | await PluginManager.Instance.uninstall(toUninstall) | ||
26 | } | ||
@@ -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) | |||
178 | app.use('/services', servicesRouter) | 177 | app.use('/services', servicesRouter) |
179 | 178 | ||
180 | // Plugins & themes | 179 | // Plugins & themes |
181 | app.use('/plugins', pluginsRouter) | 180 | app.use('/', pluginsRouter) |
182 | app.use('/themes', themesRouter) | ||
183 | 181 | ||
184 | app.use('/', activityPubRouter) | 182 | app.use('/', activityPubRouter) |
185 | app.use('/', feedsRouter) | 183 | 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' | |||
13 | import { UserRight } from '../../../shared/models/users' | 13 | import { UserRight } from '../../../shared/models/users' |
14 | import { | 14 | import { |
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' |
21 | import { PluginManager } from '../../lib/plugins/plugin-manager' | 21 | import { PluginManager } from '../../lib/plugins/plugin-manager' |
22 | import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model' | 22 | import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-plugin.model' |
23 | import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model' | 23 | import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model' |
24 | import { logger } from '../../helpers/logger' | 24 | import { logger } from '../../helpers/logger' |
25 | 25 | ||
@@ -61,10 +61,17 @@ pluginRouter.put('/:npmName/settings', | |||
61 | pluginRouter.post('/install', | 61 | pluginRouter.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 | ||
68 | pluginRouter.post('/update', | ||
69 | authenticate, | ||
70 | ensureUserHasRight(UserRight.MANAGE_PLUGINS), | ||
71 | installOrUpdatePluginValidator, | ||
72 | asyncMiddleware(updatePlugin) | ||
73 | ) | ||
74 | |||
68 | pluginRouter.post('/uninstall', | 75 | pluginRouter.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 | ||
102 | async function installPlugin (req: express.Request, res: express.Response) { | 109 | async 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) | 124 | async 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 | ||
117 | async function uninstallPlugin (req: express.Request, res: express.Response) { | 139 | async 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 | ||
125 | function getPluginRegisteredSettings (req: express.Request, res: express.Response) { | 147 | function 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' | |||
8 | export * from './tracker' | 8 | export * from './tracker' |
9 | export * from './bots' | 9 | export * from './bots' |
10 | export * from './plugins' | 10 | export * from './plugins' |
11 | 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants' | 2 | import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants' |
3 | import { basename, join } from 'path' | 3 | import { join } from 'path' |
4 | import { RegisteredPlugin } from '../lib/plugins/plugin-manager' | 4 | import { RegisteredPlugin } from '../lib/plugins/plugin-manager' |
5 | import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins' | 5 | import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins' |
6 | import { serveThemeCSSValidator } from '../middlewares/validators/themes' | ||
7 | import { PluginType } from '../../shared/models/plugins/plugin.type' | ||
6 | 8 | ||
7 | const pluginsRouter = express.Router() | 9 | const pluginsRouter = express.Router() |
8 | 10 | ||
9 | pluginsRouter.get('/global.css', | 11 | pluginsRouter.get('/plugins/global.css', |
10 | servePluginGlobalCSS | 12 | servePluginGlobalCSS |
11 | ) | 13 | ) |
12 | 14 | ||
13 | pluginsRouter.get('/:pluginName/:pluginVersion/static/:staticEndpoint(*)', | 15 | pluginsRouter.get('/plugins/:pluginName/:pluginVersion/static/:staticEndpoint(*)', |
14 | servePluginStaticDirectoryValidator, | 16 | servePluginStaticDirectoryValidator(PluginType.PLUGIN), |
15 | servePluginStaticDirectory | 17 | servePluginStaticDirectory |
16 | ) | 18 | ) |
17 | 19 | ||
18 | pluginsRouter.get('/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', | 20 | pluginsRouter.get('/plugins/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', |
19 | servePluginStaticDirectoryValidator, | 21 | servePluginStaticDirectoryValidator(PluginType.PLUGIN), |
20 | servePluginClientScripts | 22 | servePluginClientScripts |
21 | ) | 23 | ) |
22 | 24 | ||
25 | pluginsRouter.get('/themes/:pluginName/:pluginVersion/static/:staticEndpoint(*)', | ||
26 | servePluginStaticDirectoryValidator(PluginType.THEME), | ||
27 | servePluginStaticDirectory | ||
28 | ) | ||
29 | |||
30 | pluginsRouter.get('/themes/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', | ||
31 | servePluginStaticDirectoryValidator(PluginType.THEME), | ||
32 | servePluginClientScripts | ||
33 | ) | ||
34 | |||
35 | pluginsRouter.get('/themes/:themeName/:themeVersion/css/:staticEndpoint(*)', | ||
36 | serveThemeCSSValidator, | ||
37 | serveThemeCSSDirectory | ||
38 | ) | ||
39 | |||
23 | // --------------------------------------------------------------------------- | 40 | // --------------------------------------------------------------------------- |
24 | 41 | ||
25 | export { | 42 | export { |
@@ -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 | |||
79 | function 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 | |||
15 | import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model' | 15 | import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model' |
16 | 16 | ||
17 | export interface RegisteredPlugin { | 17 | export 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 | ||
36 | export interface HookInformationValue { | 37 | export 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { param, query, body } from 'express-validator/check' | 2 | import { body, param, query } from 'express-validator/check' |
3 | import { logger } from '../../helpers/logger' | 3 | import { logger } from '../../helpers/logger' |
4 | import { areValidationErrors } from './utils' | 4 | import { areValidationErrors } from './utils' |
5 | import { isPluginNameValid, isPluginTypeValid, isPluginVersionValid, isNpmPluginNameValid } from '../../helpers/custom-validators/plugins' | 5 | import { isNpmPluginNameValid, isPluginNameValid, isPluginTypeValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins' |
6 | import { PluginManager } from '../../lib/plugins/plugin-manager' | 6 | import { PluginManager } from '../../lib/plugins/plugin-manager' |
7 | import { isBooleanValid, isSafePath } from '../../helpers/custom-validators/misc' | 7 | import { isBooleanValid, isSafePath } from '../../helpers/custom-validators/misc' |
8 | import { PluginModel } from '../../models/server/plugin' | 8 | import { PluginModel } from '../../models/server/plugin' |
9 | import { InstallPlugin } from '../../../shared/models/plugins/install-plugin.model' | 9 | import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-plugin.model' |
10 | import { PluginType } from '../../../shared/models/plugins/plugin.type' | ||
10 | 11 | ||
11 | const servePluginStaticDirectoryValidator = [ | 12 | const 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 | ||
51 | const installPluginValidator = [ | 53 | const 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 @@ | |||
1 | import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | 1 | import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' |
2 | import { getSort, throwIfNotValid } from '../utils' | 2 | import { getSort, throwIfNotValid } from '../utils' |
3 | import { | 3 | import { |
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' | |||
2 | import { PluginType } from '../../shared/models/plugins/plugin.type' | 2 | import { PluginType } from '../../shared/models/plugins/plugin.type' |
3 | import { getAccessToken } from '../../shared/extra-utils/users/login' | 3 | import { getAccessToken } from '../../shared/extra-utils/users/login' |
4 | import { getMyUserInformation } from '../../shared/extra-utils/users/users' | 4 | import { getMyUserInformation } from '../../shared/extra-utils/users/users' |
5 | import { installPlugin, listPlugins, uninstallPlugin } from '../../shared/extra-utils/server/plugins' | 5 | import { installPlugin, listPlugins, uninstallPlugin, updatePlugin } from '../../shared/extra-utils/server/plugins' |
6 | import { getServerCredentials } from './cli' | 6 | import { getServerCredentials } from './cli' |
7 | import { User, UserRole } from '../../shared/models/users' | 7 | import { User, UserRole } from '../../shared/models/users' |
8 | import { PeerTubePlugin } from '../../shared/models/plugins/peertube-plugin.model' | 8 | import { 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 | ||
37 | program | 37 | program |
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 | |||
47 | program | ||
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 | ||
135 | async 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 | |||
125 | async function uninstallPluginCLI (options: any) { | 167 | async 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 | ||
100 | function 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 | |||
100 | function uninstallPlugin (parameters: { | 119 | function uninstallPlugin (parameters: { |
101 | url: string, | 120 | url: string, |
102 | accessToken: string, | 121 | accessToken: string, |
@@ -118,6 +137,7 @@ function uninstallPlugin (parameters: { | |||
118 | export { | 137 | export { |
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 @@ | |||
1 | export interface InstallPlugin { | 1 | export 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 @@ | |||
1 | export interface PeerTubePlugin { | 1 | export 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 |