diff options
-rw-r--r-- | client/proxy.config.json | 4 | ||||
-rw-r--r-- | client/src/app/app.component.ts | 12 | ||||
-rw-r--r-- | client/src/app/core/core.module.ts | 3 | ||||
-rw-r--r-- | client/src/app/core/plugins/plugin.service.ts | 137 | ||||
-rw-r--r-- | client/src/app/core/server/server.service.ts | 1 | ||||
-rw-r--r-- | client/src/app/videos/+video-watch/video-watch.component.ts | 8 | ||||
-rw-r--r-- | server/controllers/api/config.ts | 19 | ||||
-rw-r--r-- | server/lib/plugins/plugin-manager.ts | 21 | ||||
-rw-r--r-- | shared/models/plugins/plugin-scope.type.ts | 1 | ||||
-rw-r--r-- | shared/models/plugins/register.model.ts | 2 | ||||
-rw-r--r-- | shared/models/server/server-config.model.ts | 10 |
11 files changed, 215 insertions, 3 deletions
diff --git a/client/proxy.config.json b/client/proxy.config.json index 4a72f1826..c6300a412 100644 --- a/client/proxy.config.json +++ b/client/proxy.config.json | |||
@@ -3,6 +3,10 @@ | |||
3 | "target": "http://localhost:9000", | 3 | "target": "http://localhost:9000", |
4 | "secure": false | 4 | "secure": false |
5 | }, | 5 | }, |
6 | "/plugins": { | ||
7 | "target": "http://localhost:9000", | ||
8 | "secure": false | ||
9 | }, | ||
6 | "/static": { | 10 | "/static": { |
7 | "target": "http://localhost:9000", | 11 | "target": "http://localhost:9000", |
8 | "secure": false | 12 | "secure": false |
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 915466af7..548173f61 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts | |||
@@ -9,6 +9,7 @@ import { Hotkey, HotkeysService } from 'angular2-hotkeys' | |||
9 | import { I18n } from '@ngx-translate/i18n-polyfill' | 9 | import { I18n } from '@ngx-translate/i18n-polyfill' |
10 | import { fromEvent } from 'rxjs' | 10 | import { fromEvent } from 'rxjs' |
11 | import { ViewportScroller } from '@angular/common' | 11 | import { ViewportScroller } from '@angular/common' |
12 | import { PluginService } from '@app/core/plugins/plugin.service' | ||
12 | 13 | ||
13 | @Component({ | 14 | @Component({ |
14 | selector: 'my-app', | 15 | selector: 'my-app', |
@@ -27,6 +28,7 @@ export class AppComponent implements OnInit { | |||
27 | private router: Router, | 28 | private router: Router, |
28 | private authService: AuthService, | 29 | private authService: AuthService, |
29 | private serverService: ServerService, | 30 | private serverService: ServerService, |
31 | private pluginService: PluginService, | ||
30 | private domSanitizer: DomSanitizer, | 32 | private domSanitizer: DomSanitizer, |
31 | private redirectService: RedirectService, | 33 | private redirectService: RedirectService, |
32 | private screenService: ScreenService, | 34 | private screenService: ScreenService, |
@@ -69,6 +71,8 @@ export class AppComponent implements OnInit { | |||
69 | this.serverService.loadVideoPrivacies() | 71 | this.serverService.loadVideoPrivacies() |
70 | this.serverService.loadVideoPlaylistPrivacies() | 72 | this.serverService.loadVideoPlaylistPrivacies() |
71 | 73 | ||
74 | this.loadPlugins() | ||
75 | |||
72 | // Do not display menu on small screens | 76 | // Do not display menu on small screens |
73 | if (this.screenService.isInSmallView()) { | 77 | if (this.screenService.isInSmallView()) { |
74 | this.isMenuDisplayed = false | 78 | this.isMenuDisplayed = false |
@@ -196,6 +200,14 @@ export class AppComponent implements OnInit { | |||
196 | }) | 200 | }) |
197 | } | 201 | } |
198 | 202 | ||
203 | private async loadPlugins () { | ||
204 | this.pluginService.initializePlugins() | ||
205 | |||
206 | await this.pluginService.loadPluginsByScope('common') | ||
207 | |||
208 | this.pluginService.runHook('action:application.loaded') | ||
209 | } | ||
210 | |||
199 | private initHotkeys () { | 211 | private initHotkeys () { |
200 | this.hotkeysService.add([ | 212 | this.hotkeysService.add([ |
201 | new Hotkey(['/', 's'], (event: KeyboardEvent): boolean => { | 213 | new Hotkey(['/', 's'], (event: KeyboardEvent): boolean => { |
diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts index 06fa8fcf1..436c0dfb8 100644 --- a/client/src/app/core/core.module.ts +++ b/client/src/app/core/core.module.ts | |||
@@ -21,6 +21,7 @@ import { MessageService } from 'primeng/api' | |||
21 | import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' | 21 | import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' |
22 | import { ServerConfigResolver } from './routing/server-config-resolver.service' | 22 | import { ServerConfigResolver } from './routing/server-config-resolver.service' |
23 | import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service' | 23 | import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service' |
24 | import { PluginService } from '@app/core/plugins/plugin.service' | ||
24 | 25 | ||
25 | @NgModule({ | 26 | @NgModule({ |
26 | imports: [ | 27 | imports: [ |
@@ -61,6 +62,8 @@ import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service' | |||
61 | UserRightGuard, | 62 | UserRightGuard, |
62 | UnloggedGuard, | 63 | UnloggedGuard, |
63 | 64 | ||
65 | PluginService, | ||
66 | |||
64 | RedirectService, | 67 | RedirectService, |
65 | Notifier, | 68 | Notifier, |
66 | MessageService, | 69 | MessageService, |
diff --git a/client/src/app/core/plugins/plugin.service.ts b/client/src/app/core/plugins/plugin.service.ts new file mode 100644 index 000000000..6c567d3ca --- /dev/null +++ b/client/src/app/core/plugins/plugin.service.ts | |||
@@ -0,0 +1,137 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | import { ServerConfigPlugin } from '@shared/models' | ||
4 | import { ServerService } from '@app/core/server/server.service' | ||
5 | import { ClientScript } from '@shared/models/plugins/plugin-package-json.model' | ||
6 | import { PluginScope } from '@shared/models/plugins/plugin-scope.type' | ||
7 | import { environment } from '../../../environments/environment' | ||
8 | import { RegisterHookOptions } from '@shared/models/plugins/register.model' | ||
9 | import { ReplaySubject } from 'rxjs' | ||
10 | import { first } from 'rxjs/operators' | ||
11 | |||
12 | interface HookStructValue extends RegisterHookOptions { | ||
13 | plugin: ServerConfigPlugin | ||
14 | clientScript: ClientScript | ||
15 | } | ||
16 | |||
17 | @Injectable() | ||
18 | export class PluginService { | ||
19 | pluginsLoaded = new ReplaySubject<boolean>(1) | ||
20 | |||
21 | private plugins: ServerConfigPlugin[] = [] | ||
22 | private scopes: { [ scopeName: string ]: { plugin: ServerConfigPlugin, clientScript: ClientScript }[] } = {} | ||
23 | private loadedScripts: { [ script: string ]: boolean } = {} | ||
24 | |||
25 | private hooks: { [ name: string ]: HookStructValue[] } = {} | ||
26 | |||
27 | constructor ( | ||
28 | private router: Router, | ||
29 | private server: ServerService | ||
30 | ) { | ||
31 | } | ||
32 | |||
33 | initializePlugins () { | ||
34 | this.server.configLoaded | ||
35 | .subscribe(() => { | ||
36 | this.plugins = this.server.getConfig().plugins | ||
37 | |||
38 | this.buildScopeStruct() | ||
39 | |||
40 | this.pluginsLoaded.next(true) | ||
41 | }) | ||
42 | } | ||
43 | |||
44 | ensurePluginsAreLoaded () { | ||
45 | return this.pluginsLoaded.asObservable() | ||
46 | .pipe(first()) | ||
47 | .toPromise() | ||
48 | } | ||
49 | |||
50 | async loadPluginsByScope (scope: PluginScope) { | ||
51 | try { | ||
52 | await this.ensurePluginsAreLoaded() | ||
53 | |||
54 | const toLoad = this.scopes[ scope ] | ||
55 | if (!Array.isArray(toLoad)) return | ||
56 | |||
57 | const promises: Promise<any>[] = [] | ||
58 | for (const { plugin, clientScript } of toLoad) { | ||
59 | if (this.loadedScripts[ clientScript.script ]) continue | ||
60 | |||
61 | promises.push(this.loadPlugin(plugin, clientScript)) | ||
62 | |||
63 | this.loadedScripts[ clientScript.script ] = true | ||
64 | } | ||
65 | |||
66 | return Promise.all(promises) | ||
67 | } catch (err) { | ||
68 | console.error('Cannot load plugins by scope %s.', scope, err) | ||
69 | } | ||
70 | } | ||
71 | |||
72 | async runHook (hookName: string, param?: any) { | ||
73 | let result = param | ||
74 | |||
75 | const wait = hookName.startsWith('static:') | ||
76 | |||
77 | for (const hook of this.hooks[hookName]) { | ||
78 | try { | ||
79 | if (wait) result = await hook.handler(param) | ||
80 | else result = hook.handler() | ||
81 | } catch (err) { | ||
82 | console.error('Cannot run hook %s of script %s of plugin %s.', hookName, hook.plugin, hook.clientScript, err) | ||
83 | } | ||
84 | } | ||
85 | |||
86 | return result | ||
87 | } | ||
88 | |||
89 | private loadPlugin (plugin: ServerConfigPlugin, clientScript: ClientScript) { | ||
90 | const registerHook = (options: RegisterHookOptions) => { | ||
91 | if (!this.hooks[options.target]) this.hooks[options.target] = [] | ||
92 | |||
93 | this.hooks[options.target].push({ | ||
94 | plugin, | ||
95 | clientScript, | ||
96 | target: options.target, | ||
97 | handler: options.handler, | ||
98 | priority: options.priority || 0 | ||
99 | }) | ||
100 | } | ||
101 | |||
102 | console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name) | ||
103 | |||
104 | const url = environment.apiUrl + `/plugins/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}` | ||
105 | |||
106 | return import(/* webpackIgnore: true */ url) | ||
107 | .then(script => script.register({ registerHook })) | ||
108 | .then(() => this.sortHooksByPriority()) | ||
109 | } | ||
110 | |||
111 | private buildScopeStruct () { | ||
112 | for (const plugin of this.plugins) { | ||
113 | for (const key of Object.keys(plugin.clientScripts)) { | ||
114 | const clientScript = plugin.clientScripts[key] | ||
115 | |||
116 | for (const scope of clientScript.scopes) { | ||
117 | if (!this.scopes[scope]) this.scopes[scope] = [] | ||
118 | |||
119 | this.scopes[scope].push({ | ||
120 | plugin, | ||
121 | clientScript | ||
122 | }) | ||
123 | |||
124 | this.loadedScripts[clientScript.script] = false | ||
125 | } | ||
126 | } | ||
127 | } | ||
128 | } | ||
129 | |||
130 | private sortHooksByPriority () { | ||
131 | for (const hookName of Object.keys(this.hooks)) { | ||
132 | this.hooks[hookName].sort((a, b) => { | ||
133 | return b.priority - a.priority | ||
134 | }) | ||
135 | } | ||
136 | } | ||
137 | } | ||
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index 689f25a40..80c52164d 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts | |||
@@ -42,6 +42,7 @@ export class ServerService { | |||
42 | css: '' | 42 | css: '' |
43 | } | 43 | } |
44 | }, | 44 | }, |
45 | plugins: [], | ||
45 | email: { | 46 | email: { |
46 | enabled: false | 47 | enabled: false |
47 | }, | 48 | }, |
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index 3f1a98f89..6d8bb4b3f 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts | |||
@@ -32,6 +32,7 @@ import { Video } from '@app/shared/video/video.model' | |||
32 | import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils' | 32 | import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils' |
33 | import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' | 33 | import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' |
34 | import { getStoredTheater } from '../../../assets/player/peertube-player-local-storage' | 34 | import { getStoredTheater } from '../../../assets/player/peertube-player-local-storage' |
35 | import { PluginService } from '@app/core/plugins/plugin.service' | ||
35 | 36 | ||
36 | @Component({ | 37 | @Component({ |
37 | selector: 'my-video-watch', | 38 | selector: 'my-video-watch', |
@@ -85,6 +86,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
85 | private serverService: ServerService, | 86 | private serverService: ServerService, |
86 | private restExtractor: RestExtractor, | 87 | private restExtractor: RestExtractor, |
87 | private notifier: Notifier, | 88 | private notifier: Notifier, |
89 | private pluginService: PluginService, | ||
88 | private markdownService: MarkdownService, | 90 | private markdownService: MarkdownService, |
89 | private zone: NgZone, | 91 | private zone: NgZone, |
90 | private redirectService: RedirectService, | 92 | private redirectService: RedirectService, |
@@ -98,7 +100,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
98 | return this.authService.getUser() | 100 | return this.authService.getUser() |
99 | } | 101 | } |
100 | 102 | ||
101 | ngOnInit () { | 103 | async ngOnInit () { |
104 | await this.pluginService.loadPluginsByScope('video-watch') | ||
105 | |||
102 | this.configSub = this.serverService.configLoaded | 106 | this.configSub = this.serverService.configLoaded |
103 | .subscribe(() => { | 107 | .subscribe(() => { |
104 | if ( | 108 | if ( |
@@ -126,6 +130,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
126 | this.initHotkeys() | 130 | this.initHotkeys() |
127 | 131 | ||
128 | this.theaterEnabled = getStoredTheater() | 132 | this.theaterEnabled = getStoredTheater() |
133 | |||
134 | this.pluginService.runHook('action:video-watch.loaded') | ||
129 | } | 135 | } |
130 | 136 | ||
131 | ngOnDestroy () { | 137 | ngOnDestroy () { |
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index 1d12f701b..8563b7437 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { snakeCase } from 'lodash' | 2 | import { snakeCase } from 'lodash' |
3 | import { ServerConfig, UserRight } from '../../../shared' | 3 | import { ServerConfig, ServerConfigPlugin, UserRight } from '../../../shared' |
4 | import { About } from '../../../shared/models/server/about.model' | 4 | import { About } from '../../../shared/models/server/about.model' |
5 | import { CustomConfig } from '../../../shared/models/server/custom-config.model' | 5 | import { CustomConfig } from '../../../shared/models/server/custom-config.model' |
6 | import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup' | 6 | import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup' |
@@ -15,6 +15,8 @@ import { Emailer } from '../../lib/emailer' | |||
15 | import { isNumeric } from 'validator' | 15 | import { isNumeric } from 'validator' |
16 | import { objectConverter } from '../../helpers/core-utils' | 16 | import { objectConverter } from '../../helpers/core-utils' |
17 | import { CONFIG, reloadConfig } from '../../initializers/config' | 17 | import { CONFIG, reloadConfig } from '../../initializers/config' |
18 | import { PluginManager } from '../../lib/plugins/plugin-manager' | ||
19 | import { PluginType } from '../../../shared/models/plugins/plugin.type' | ||
18 | 20 | ||
19 | const packageJSON = require('../../../../package.json') | 21 | const packageJSON = require('../../../../package.json') |
20 | const configRouter = express.Router() | 22 | const configRouter = express.Router() |
@@ -54,6 +56,20 @@ async function getConfig (req: express.Request, res: express.Response) { | |||
54 | .filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true) | 56 | .filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true) |
55 | .map(r => parseInt(r, 10)) | 57 | .map(r => parseInt(r, 10)) |
56 | 58 | ||
59 | const plugins: ServerConfigPlugin[] = [] | ||
60 | const registeredPlugins = PluginManager.Instance.getRegisteredPlugins() | ||
61 | for (const pluginName of Object.keys(registeredPlugins)) { | ||
62 | const plugin = registeredPlugins[ pluginName ] | ||
63 | if (plugin.type !== PluginType.PLUGIN) continue | ||
64 | |||
65 | plugins.push({ | ||
66 | name: plugin.name, | ||
67 | version: plugin.version, | ||
68 | description: plugin.description, | ||
69 | clientScripts: plugin.clientScripts | ||
70 | }) | ||
71 | } | ||
72 | |||
57 | const json: ServerConfig = { | 73 | const json: ServerConfig = { |
58 | instance: { | 74 | instance: { |
59 | name: CONFIG.INSTANCE.NAME, | 75 | name: CONFIG.INSTANCE.NAME, |
@@ -66,6 +82,7 @@ async function getConfig (req: express.Request, res: express.Response) { | |||
66 | css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS | 82 | css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS |
67 | } | 83 | } |
68 | }, | 84 | }, |
85 | plugins, | ||
69 | email: { | 86 | email: { |
70 | enabled: Emailer.isEnabled() | 87 | enabled: Emailer.isEnabled() |
71 | }, | 88 | }, |
diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts index b898e64fa..7cbfa8569 100644 --- a/server/lib/plugins/plugin-manager.ts +++ b/server/lib/plugins/plugin-manager.ts | |||
@@ -75,6 +75,27 @@ export class PluginManager { | |||
75 | return registered | 75 | return registered |
76 | } | 76 | } |
77 | 77 | ||
78 | getRegisteredPlugins () { | ||
79 | return this.registeredPlugins | ||
80 | } | ||
81 | |||
82 | async runHook (hookName: string, param?: any) { | ||
83 | let result = param | ||
84 | |||
85 | const wait = hookName.startsWith('static:') | ||
86 | |||
87 | for (const hook of this.hooks[hookName]) { | ||
88 | try { | ||
89 | if (wait) result = await hook.handler(param) | ||
90 | else result = hook.handler() | ||
91 | } catch (err) { | ||
92 | logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err }) | ||
93 | } | ||
94 | } | ||
95 | |||
96 | return result | ||
97 | } | ||
98 | |||
78 | async unregister (name: string) { | 99 | async unregister (name: string) { |
79 | const plugin = this.getRegisteredPlugin(name) | 100 | const plugin = this.getRegisteredPlugin(name) |
80 | 101 | ||
diff --git a/shared/models/plugins/plugin-scope.type.ts b/shared/models/plugins/plugin-scope.type.ts new file mode 100644 index 000000000..b63ae43ec --- /dev/null +++ b/shared/models/plugins/plugin-scope.type.ts | |||
@@ -0,0 +1 @@ | |||
export type PluginScope = 'common' | 'video-watch' | |||
diff --git a/shared/models/plugins/register.model.ts b/shared/models/plugins/register.model.ts index 3817007ae..0ed2157bd 100644 --- a/shared/models/plugins/register.model.ts +++ b/shared/models/plugins/register.model.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | export type RegisterHookOptions = { | 1 | export interface RegisterHookOptions { |
2 | target: string | 2 | target: string |
3 | handler: Function | 3 | handler: Function |
4 | priority?: number | 4 | priority?: number |
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index d937e9c05..c259a849a 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts | |||
@@ -1,4 +1,12 @@ | |||
1 | import { NSFWPolicyType } from '../videos/nsfw-policy.type' | 1 | import { NSFWPolicyType } from '../videos/nsfw-policy.type' |
2 | import { ClientScript } from '../plugins/plugin-package-json.model' | ||
3 | |||
4 | export type ServerConfigPlugin = { | ||
5 | name: string | ||
6 | version: string | ||
7 | description: string | ||
8 | clientScripts: { [name: string]: ClientScript } | ||
9 | } | ||
2 | 10 | ||
3 | export interface ServerConfig { | 11 | export interface ServerConfig { |
4 | serverVersion: string | 12 | serverVersion: string |
@@ -16,6 +24,8 @@ export interface ServerConfig { | |||
16 | } | 24 | } |
17 | } | 25 | } |
18 | 26 | ||
27 | plugins: ServerConfigPlugin[] | ||
28 | |||
19 | email: { | 29 | email: { |
20 | enabled: boolean | 30 | enabled: boolean |
21 | } | 31 | } |