]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
WIP plugins: hook on client side
authorChocobozzz <me@florianbigard.com>
Mon, 8 Jul 2019 13:54:08 +0000 (15:54 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 24 Jul 2019 08:58:16 +0000 (10:58 +0200)
client/proxy.config.json
client/src/app/app.component.ts
client/src/app/core/core.module.ts
client/src/app/core/plugins/plugin.service.ts [new file with mode: 0644]
client/src/app/core/server/server.service.ts
client/src/app/videos/+video-watch/video-watch.component.ts
server/controllers/api/config.ts
server/lib/plugins/plugin-manager.ts
shared/models/plugins/plugin-scope.type.ts [new file with mode: 0644]
shared/models/plugins/register.model.ts
shared/models/server/server-config.model.ts

index 4a72f1826337f49fb4d1a687ce6b81e876188f65..c6300a4126b93c09bfa366a541cea737a76ba662 100644 (file)
@@ -3,6 +3,10 @@
     "target": "http://localhost:9000",
     "secure": false
   },
+  "/plugins": {
+    "target": "http://localhost:9000",
+    "secure": false
+  },
   "/static": {
     "target": "http://localhost:9000",
     "secure": false
index 915466af73a38c331a1de34f2b7cbbbbee011572..548173f61eb2e88e3746e7878c1832146db50ec6 100644 (file)
@@ -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 => {
index 06fa8fcf1fd86c66e90b80a0a22d74cf7387e5d1..436c0dfb8bba25e09a014c4b76e57bf065870ecb 100644 (file)
@@ -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 (file)
index 0000000..6c567d3
--- /dev/null
@@ -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<boolean>(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<any>[] = []
+      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
+      })
+    }
+  }
+}
index 689f25a405c980415a3adbd67c28f9d52841dd5e..80c52164d578f3bb6323380e3c559ed952f7c2ad 100644 (file)
@@ -42,6 +42,7 @@ export class ServerService {
         css: ''
       }
     },
+    plugins: [],
     email: {
       enabled: false
     },
index 3f1a98f89b3130a075bf65de6acd2deaed804d79..6d8bb4b3f1403c12f0eacda0ba571888e20e831f 100644 (file)
@@ -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 () {
index 1d12f701b2aaeff4025a00721da482d202cb9486..8563b74370bd3f277d825f98d503e28605e83ac7 100644 (file)
@@ -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()
     },
index b898e64fa2084a16cd7de0809c96cc6e4da7ff89..7cbfa856943904648eba842708fbc3e39b470a83 100644 (file)
@@ -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 (file)
index 0000000..b63ae43
--- /dev/null
@@ -0,0 +1 @@
+export type PluginScope = 'common' | 'video-watch'
index 3817007aeb1e2c9b9ad791dbfeffbfe4e73ea11a..0ed2157bd59d98f1076a7f956a899b804d852c33 100644 (file)
@@ -1,4 +1,4 @@
-export type RegisterHookOptions = {
+export interface RegisterHookOptions {
   target: string
   handler: Function
   priority?: number
index d937e9c05a2d39b1c8039697cde01c022bcfc350..c259a849a4b6e82e2b9f084d6c8665cd0f80afed 100644 (file)
@@ -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
   }