From 18a6f04c071f7a0735eb39b8c67fd51a082d1a31 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 8 Jul 2019 15:54:08 +0200 Subject: [PATCH] WIP plugins: hook on client side --- client/proxy.config.json | 4 + client/src/app/app.component.ts | 12 ++ client/src/app/core/core.module.ts | 3 + client/src/app/core/plugins/plugin.service.ts | 137 ++++++++++++++++++ client/src/app/core/server/server.service.ts | 1 + .../+video-watch/video-watch.component.ts | 8 +- server/controllers/api/config.ts | 19 ++- server/lib/plugins/plugin-manager.ts | 21 +++ shared/models/plugins/plugin-scope.type.ts | 1 + shared/models/plugins/register.model.ts | 2 +- shared/models/server/server-config.model.ts | 10 ++ 11 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 client/src/app/core/plugins/plugin.service.ts create mode 100644 shared/models/plugins/plugin-scope.type.ts 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 @@ "target": "http://localhost:9000", "secure": false }, + "/plugins": { + "target": "http://localhost:9000", + "secure": false + }, "/static": { "target": "http://localhost:9000", "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' import { I18n } from '@ngx-translate/i18n-polyfill' import { fromEvent } from 'rxjs' import { ViewportScroller } from '@angular/common' +import { PluginService } from '@app/core/plugins/plugin.service' @Component({ selector: 'my-app', @@ -27,6 +28,7 @@ export class AppComponent implements OnInit { private router: Router, private authService: AuthService, private serverService: ServerService, + private pluginService: PluginService, private domSanitizer: DomSanitizer, private redirectService: RedirectService, private screenService: ScreenService, @@ -69,6 +71,8 @@ export class AppComponent implements OnInit { this.serverService.loadVideoPrivacies() this.serverService.loadVideoPlaylistPrivacies() + this.loadPlugins() + // Do not display menu on small screens if (this.screenService.isInSmallView()) { this.isMenuDisplayed = false @@ -196,6 +200,14 @@ export class AppComponent implements OnInit { }) } + private async loadPlugins () { + this.pluginService.initializePlugins() + + await this.pluginService.loadPluginsByScope('common') + + this.pluginService.runHook('action:application.loaded') + } + private initHotkeys () { this.hotkeysService.add([ 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' import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' import { ServerConfigResolver } from './routing/server-config-resolver.service' import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service' +import { PluginService } from '@app/core/plugins/plugin.service' @NgModule({ imports: [ @@ -61,6 +62,8 @@ import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service' UserRightGuard, UnloggedGuard, + PluginService, + RedirectService, Notifier, 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 @@ +import { Injectable } from '@angular/core' +import { Router } from '@angular/router' +import { ServerConfigPlugin } from '@shared/models' +import { ServerService } from '@app/core/server/server.service' +import { ClientScript } from '@shared/models/plugins/plugin-package-json.model' +import { PluginScope } from '@shared/models/plugins/plugin-scope.type' +import { environment } from '../../../environments/environment' +import { RegisterHookOptions } from '@shared/models/plugins/register.model' +import { ReplaySubject } from 'rxjs' +import { first } from 'rxjs/operators' + +interface HookStructValue extends RegisterHookOptions { + plugin: ServerConfigPlugin + clientScript: ClientScript +} + +@Injectable() +export class PluginService { + pluginsLoaded = new ReplaySubject(1) + + private plugins: ServerConfigPlugin[] = [] + private scopes: { [ scopeName: string ]: { plugin: ServerConfigPlugin, clientScript: ClientScript }[] } = {} + private loadedScripts: { [ script: string ]: boolean } = {} + + private hooks: { [ name: string ]: HookStructValue[] } = {} + + constructor ( + private router: Router, + private server: ServerService + ) { + } + + initializePlugins () { + this.server.configLoaded + .subscribe(() => { + this.plugins = this.server.getConfig().plugins + + this.buildScopeStruct() + + this.pluginsLoaded.next(true) + }) + } + + ensurePluginsAreLoaded () { + return this.pluginsLoaded.asObservable() + .pipe(first()) + .toPromise() + } + + async loadPluginsByScope (scope: PluginScope) { + try { + await this.ensurePluginsAreLoaded() + + const toLoad = this.scopes[ scope ] + if (!Array.isArray(toLoad)) return + + const promises: Promise[] = [] + for (const { plugin, clientScript } of toLoad) { + if (this.loadedScripts[ clientScript.script ]) continue + + promises.push(this.loadPlugin(plugin, clientScript)) + + this.loadedScripts[ clientScript.script ] = true + } + + return Promise.all(promises) + } catch (err) { + console.error('Cannot load plugins by scope %s.', scope, err) + } + } + + async runHook (hookName: string, param?: any) { + let result = param + + const wait = hookName.startsWith('static:') + + for (const hook of this.hooks[hookName]) { + try { + if (wait) result = await hook.handler(param) + else result = hook.handler() + } catch (err) { + console.error('Cannot run hook %s of script %s of plugin %s.', hookName, hook.plugin, hook.clientScript, err) + } + } + + return result + } + + private loadPlugin (plugin: ServerConfigPlugin, clientScript: ClientScript) { + const registerHook = (options: RegisterHookOptions) => { + if (!this.hooks[options.target]) this.hooks[options.target] = [] + + this.hooks[options.target].push({ + plugin, + clientScript, + target: options.target, + handler: options.handler, + priority: options.priority || 0 + }) + } + + console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name) + + const url = environment.apiUrl + `/plugins/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}` + + return import(/* webpackIgnore: true */ url) + .then(script => script.register({ registerHook })) + .then(() => this.sortHooksByPriority()) + } + + private buildScopeStruct () { + for (const plugin of this.plugins) { + for (const key of Object.keys(plugin.clientScripts)) { + const clientScript = plugin.clientScripts[key] + + for (const scope of clientScript.scopes) { + if (!this.scopes[scope]) this.scopes[scope] = [] + + this.scopes[scope].push({ + plugin, + clientScript + }) + + this.loadedScripts[clientScript.script] = false + } + } + } + } + + private sortHooksByPriority () { + for (const hookName of Object.keys(this.hooks)) { + this.hooks[hookName].sort((a, b) => { + return b.priority - a.priority + }) + } + } +} 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 { css: '' } }, + plugins: [], email: { enabled: false }, 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' import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils' import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' import { getStoredTheater } from '../../../assets/player/peertube-player-local-storage' +import { PluginService } from '@app/core/plugins/plugin.service' @Component({ selector: 'my-video-watch', @@ -85,6 +86,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private serverService: ServerService, private restExtractor: RestExtractor, private notifier: Notifier, + private pluginService: PluginService, private markdownService: MarkdownService, private zone: NgZone, private redirectService: RedirectService, @@ -98,7 +100,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy { return this.authService.getUser() } - ngOnInit () { + async ngOnInit () { + await this.pluginService.loadPluginsByScope('video-watch') + this.configSub = this.serverService.configLoaded .subscribe(() => { if ( @@ -126,6 +130,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.initHotkeys() this.theaterEnabled = getStoredTheater() + + this.pluginService.runHook('action:video-watch.loaded') } 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 @@ import * as express from 'express' import { snakeCase } from 'lodash' -import { ServerConfig, UserRight } from '../../../shared' +import { ServerConfig, ServerConfigPlugin, UserRight } from '../../../shared' import { About } from '../../../shared/models/server/about.model' import { CustomConfig } from '../../../shared/models/server/custom-config.model' import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup' @@ -15,6 +15,8 @@ import { Emailer } from '../../lib/emailer' import { isNumeric } from 'validator' import { objectConverter } from '../../helpers/core-utils' import { CONFIG, reloadConfig } from '../../initializers/config' +import { PluginManager } from '../../lib/plugins/plugin-manager' +import { PluginType } from '../../../shared/models/plugins/plugin.type' const packageJSON = require('../../../../package.json') const configRouter = express.Router() @@ -54,6 +56,20 @@ async function getConfig (req: express.Request, res: express.Response) { .filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true) .map(r => parseInt(r, 10)) + const plugins: ServerConfigPlugin[] = [] + const registeredPlugins = PluginManager.Instance.getRegisteredPlugins() + for (const pluginName of Object.keys(registeredPlugins)) { + const plugin = registeredPlugins[ pluginName ] + if (plugin.type !== PluginType.PLUGIN) continue + + plugins.push({ + name: plugin.name, + version: plugin.version, + description: plugin.description, + clientScripts: plugin.clientScripts + }) + } + const json: ServerConfig = { instance: { name: CONFIG.INSTANCE.NAME, @@ -66,6 +82,7 @@ async function getConfig (req: express.Request, res: express.Response) { css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS } }, + plugins, email: { enabled: Emailer.isEnabled() }, 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 { return registered } + getRegisteredPlugins () { + return this.registeredPlugins + } + + async runHook (hookName: string, param?: any) { + let result = param + + const wait = hookName.startsWith('static:') + + for (const hook of this.hooks[hookName]) { + try { + if (wait) result = await hook.handler(param) + else result = hook.handler() + } catch (err) { + logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err }) + } + } + + return result + } + async unregister (name: string) { const plugin = this.getRegisteredPlugin(name) 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 @@ -export type RegisterHookOptions = { +export interface RegisterHookOptions { target: string handler: Function 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 @@ import { NSFWPolicyType } from '../videos/nsfw-policy.type' +import { ClientScript } from '../plugins/plugin-package-json.model' + +export type ServerConfigPlugin = { + name: string + version: string + description: string + clientScripts: { [name: string]: ClientScript } +} export interface ServerConfig { serverVersion: string @@ -16,6 +24,8 @@ export interface ServerConfig { } } + plugins: ServerConfigPlugin[] + email: { enabled: boolean }