WIP plugins: hook on client side
This commit is contained in:
parent
2c0539420d
commit
18a6f04c07
11 changed files with 215 additions and 3 deletions
|
@ -3,6 +3,10 @@
|
||||||
"target": "http://localhost:9000",
|
"target": "http://localhost:9000",
|
||||||
"secure": false
|
"secure": false
|
||||||
},
|
},
|
||||||
|
"/plugins": {
|
||||||
|
"target": "http://localhost:9000",
|
||||||
|
"secure": false
|
||||||
|
},
|
||||||
"/static": {
|
"/static": {
|
||||||
"target": "http://localhost:9000",
|
"target": "http://localhost:9000",
|
||||||
"secure": false
|
"secure": false
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { Hotkey, HotkeysService } from 'angular2-hotkeys'
|
||||||
import { I18n } from '@ngx-translate/i18n-polyfill'
|
import { I18n } from '@ngx-translate/i18n-polyfill'
|
||||||
import { fromEvent } from 'rxjs'
|
import { fromEvent } from 'rxjs'
|
||||||
import { ViewportScroller } from '@angular/common'
|
import { ViewportScroller } from '@angular/common'
|
||||||
|
import { PluginService } from '@app/core/plugins/plugin.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-app',
|
selector: 'my-app',
|
||||||
|
@ -27,6 +28,7 @@ export class AppComponent implements OnInit {
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private serverService: ServerService,
|
private serverService: ServerService,
|
||||||
|
private pluginService: PluginService,
|
||||||
private domSanitizer: DomSanitizer,
|
private domSanitizer: DomSanitizer,
|
||||||
private redirectService: RedirectService,
|
private redirectService: RedirectService,
|
||||||
private screenService: ScreenService,
|
private screenService: ScreenService,
|
||||||
|
@ -69,6 +71,8 @@ export class AppComponent implements OnInit {
|
||||||
this.serverService.loadVideoPrivacies()
|
this.serverService.loadVideoPrivacies()
|
||||||
this.serverService.loadVideoPlaylistPrivacies()
|
this.serverService.loadVideoPlaylistPrivacies()
|
||||||
|
|
||||||
|
this.loadPlugins()
|
||||||
|
|
||||||
// Do not display menu on small screens
|
// Do not display menu on small screens
|
||||||
if (this.screenService.isInSmallView()) {
|
if (this.screenService.isInSmallView()) {
|
||||||
this.isMenuDisplayed = false
|
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 () {
|
private initHotkeys () {
|
||||||
this.hotkeysService.add([
|
this.hotkeysService.add([
|
||||||
new Hotkey(['/', 's'], (event: KeyboardEvent): boolean => {
|
new Hotkey(['/', 's'], (event: KeyboardEvent): boolean => {
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { MessageService } from 'primeng/api'
|
||||||
import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
|
import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
|
||||||
import { ServerConfigResolver } from './routing/server-config-resolver.service'
|
import { ServerConfigResolver } from './routing/server-config-resolver.service'
|
||||||
import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
|
import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
|
||||||
|
import { PluginService } from '@app/core/plugins/plugin.service'
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -61,6 +62,8 @@ import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
|
||||||
UserRightGuard,
|
UserRightGuard,
|
||||||
UnloggedGuard,
|
UnloggedGuard,
|
||||||
|
|
||||||
|
PluginService,
|
||||||
|
|
||||||
RedirectService,
|
RedirectService,
|
||||||
Notifier,
|
Notifier,
|
||||||
MessageService,
|
MessageService,
|
||||||
|
|
137
client/src/app/core/plugins/plugin.service.ts
Normal file
137
client/src/app/core/plugins/plugin.service.ts
Normal file
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,6 +42,7 @@ export class ServerService {
|
||||||
css: ''
|
css: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
plugins: [],
|
||||||
email: {
|
email: {
|
||||||
enabled: false
|
enabled: false
|
||||||
},
|
},
|
||||||
|
|
|
@ -32,6 +32,7 @@ import { Video } from '@app/shared/video/video.model'
|
||||||
import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils'
|
import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils'
|
||||||
import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
|
import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
|
||||||
import { getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
|
import { getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
|
||||||
|
import { PluginService } from '@app/core/plugins/plugin.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-video-watch',
|
selector: 'my-video-watch',
|
||||||
|
@ -85,6 +86,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
private serverService: ServerService,
|
private serverService: ServerService,
|
||||||
private restExtractor: RestExtractor,
|
private restExtractor: RestExtractor,
|
||||||
private notifier: Notifier,
|
private notifier: Notifier,
|
||||||
|
private pluginService: PluginService,
|
||||||
private markdownService: MarkdownService,
|
private markdownService: MarkdownService,
|
||||||
private zone: NgZone,
|
private zone: NgZone,
|
||||||
private redirectService: RedirectService,
|
private redirectService: RedirectService,
|
||||||
|
@ -98,7 +100,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
return this.authService.getUser()
|
return this.authService.getUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit () {
|
async ngOnInit () {
|
||||||
|
await this.pluginService.loadPluginsByScope('video-watch')
|
||||||
|
|
||||||
this.configSub = this.serverService.configLoaded
|
this.configSub = this.serverService.configLoaded
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
if (
|
if (
|
||||||
|
@ -126,6 +130,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
this.initHotkeys()
|
this.initHotkeys()
|
||||||
|
|
||||||
this.theaterEnabled = getStoredTheater()
|
this.theaterEnabled = getStoredTheater()
|
||||||
|
|
||||||
|
this.pluginService.runHook('action:video-watch.loaded')
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy () {
|
ngOnDestroy () {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
import { snakeCase } from 'lodash'
|
import { snakeCase } from 'lodash'
|
||||||
import { ServerConfig, UserRight } from '../../../shared'
|
import { ServerConfig, ServerConfigPlugin, UserRight } from '../../../shared'
|
||||||
import { About } from '../../../shared/models/server/about.model'
|
import { About } from '../../../shared/models/server/about.model'
|
||||||
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
|
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
|
||||||
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup'
|
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup'
|
||||||
|
@ -15,6 +15,8 @@ import { Emailer } from '../../lib/emailer'
|
||||||
import { isNumeric } from 'validator'
|
import { isNumeric } from 'validator'
|
||||||
import { objectConverter } from '../../helpers/core-utils'
|
import { objectConverter } from '../../helpers/core-utils'
|
||||||
import { CONFIG, reloadConfig } from '../../initializers/config'
|
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 packageJSON = require('../../../../package.json')
|
||||||
const configRouter = express.Router()
|
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)
|
.filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true)
|
||||||
.map(r => parseInt(r, 10))
|
.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 = {
|
const json: ServerConfig = {
|
||||||
instance: {
|
instance: {
|
||||||
name: CONFIG.INSTANCE.NAME,
|
name: CONFIG.INSTANCE.NAME,
|
||||||
|
@ -66,6 +82,7 @@ async function getConfig (req: express.Request, res: express.Response) {
|
||||||
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
|
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
plugins,
|
||||||
email: {
|
email: {
|
||||||
enabled: Emailer.isEnabled()
|
enabled: Emailer.isEnabled()
|
||||||
},
|
},
|
||||||
|
|
|
@ -75,6 +75,27 @@ export class PluginManager {
|
||||||
return registered
|
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) {
|
async unregister (name: string) {
|
||||||
const plugin = this.getRegisteredPlugin(name)
|
const plugin = this.getRegisteredPlugin(name)
|
||||||
|
|
||||||
|
|
1
shared/models/plugins/plugin-scope.type.ts
Normal file
1
shared/models/plugins/plugin-scope.type.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export type PluginScope = 'common' | 'video-watch'
|
|
@ -1,4 +1,4 @@
|
||||||
export type RegisterHookOptions = {
|
export interface RegisterHookOptions {
|
||||||
target: string
|
target: string
|
||||||
handler: Function
|
handler: Function
|
||||||
priority?: number
|
priority?: number
|
||||||
|
|
|
@ -1,4 +1,12 @@
|
||||||
import { NSFWPolicyType } from '../videos/nsfw-policy.type'
|
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 {
|
export interface ServerConfig {
|
||||||
serverVersion: string
|
serverVersion: string
|
||||||
|
@ -16,6 +24,8 @@ export interface ServerConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plugins: ServerConfigPlugin[]
|
||||||
|
|
||||||
email: {
|
email: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue