3cab64142ac256d5ed0d7f446c14a6742d531925
[oweals/peertube.git] / client / src / app / core / plugins / plugin.service.ts
1 import { Observable, of, ReplaySubject } from 'rxjs'
2 import { catchError, first, map, shareReplay } from 'rxjs/operators'
3 import { HttpClient } from '@angular/common/http'
4 import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'
5 import { AuthService } from '@app/core/auth'
6 import { Notifier } from '@app/core/notification'
7 import { MarkdownService } from '@app/core/renderer'
8 import { RestExtractor } from '@app/core/rest'
9 import { ServerService } from '@app/core/server/server.service'
10 import { getDevLocale, importModule, isOnDevLocale } from '@app/helpers'
11 import { CustomModalComponent } from '@app/modal/custom-modal.component'
12 import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
13 import {
14   ClientHook,
15   ClientHookName,
16   clientHookObject,
17   ClientScript,
18   getCompleteLocale,
19   isDefaultLocale,
20   peertubeTranslate,
21   PluginClientScope,
22   PluginTranslation,
23   PluginType,
24   PublicServerSetting,
25   RegisterClientHookOptions,
26   ServerConfigPlugin
27 } from '@shared/models'
28 import { environment } from '../../../environments/environment'
29 import { ClientScript as ClientScriptModule } from '../../../types/client-script.model'
30 import { RegisterClientHelpers } from '../../../types/register-client-option.model'
31
32 interface HookStructValue extends RegisterClientHookOptions {
33   plugin: ServerConfigPlugin
34   clientScript: ClientScript
35 }
36
37 type PluginInfo = {
38   plugin: ServerConfigPlugin
39   clientScript: ClientScript
40   pluginType: PluginType
41   isTheme: boolean
42 }
43
44 @Injectable()
45 export class PluginService implements ClientHook {
46   private static BASE_PLUGIN_API_URL = environment.apiUrl + '/api/v1/plugins'
47   private static BASE_PLUGIN_URL = environment.apiUrl + '/plugins'
48
49   pluginsBuilt = new ReplaySubject<boolean>(1)
50
51   pluginsLoaded: { [ scope in PluginClientScope ]: ReplaySubject<boolean> } = {
52     common: new ReplaySubject<boolean>(1),
53     search: new ReplaySubject<boolean>(1),
54     'video-watch': new ReplaySubject<boolean>(1),
55     signup: new ReplaySubject<boolean>(1),
56     login: new ReplaySubject<boolean>(1)
57   }
58
59   translationsObservable: Observable<PluginTranslation>
60
61   customModal: CustomModalComponent
62
63   private plugins: ServerConfigPlugin[] = []
64   private scopes: { [ scopeName: string ]: PluginInfo[] } = {}
65   private loadedScripts: { [ script: string ]: boolean } = {}
66   private loadedScopes: PluginClientScope[] = []
67   private loadingScopes: { [id in PluginClientScope]?: boolean } = {}
68
69   private hooks: { [ name: string ]: HookStructValue[] } = {}
70
71   constructor (
72     private authService: AuthService,
73     private notifier: Notifier,
74     private markdownRenderer: MarkdownService,
75     private server: ServerService,
76     private zone: NgZone,
77     private authHttp: HttpClient,
78     private restExtractor: RestExtractor,
79     @Inject(LOCALE_ID) private localeId: string
80   ) {
81     this.loadTranslations()
82   }
83
84   initializePlugins () {
85     this.server.getConfig()
86       .subscribe(config => {
87         this.plugins = config.plugin.registered
88
89         this.buildScopeStruct()
90
91         this.pluginsBuilt.next(true)
92       })
93   }
94
95   initializeCustomModal (customModal: CustomModalComponent) {
96     this.customModal = customModal
97   }
98
99   ensurePluginsAreBuilt () {
100     return this.pluginsBuilt.asObservable()
101                .pipe(first(), shareReplay())
102                .toPromise()
103   }
104
105   ensurePluginsAreLoaded (scope: PluginClientScope) {
106     this.loadPluginsByScope(scope)
107
108     return this.pluginsLoaded[scope].asObservable()
109                .pipe(first(), shareReplay())
110                .toPromise()
111   }
112
113   addPlugin (plugin: ServerConfigPlugin, isTheme = false) {
114     const pathPrefix = this.getPluginPathPrefix(isTheme)
115
116     for (const key of Object.keys(plugin.clientScripts)) {
117       const clientScript = plugin.clientScripts[key]
118
119       for (const scope of clientScript.scopes) {
120         if (!this.scopes[scope]) this.scopes[scope] = []
121
122         this.scopes[scope].push({
123           plugin,
124           clientScript: {
125             script: environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`,
126             scopes: clientScript.scopes
127           },
128           pluginType: isTheme ? PluginType.THEME : PluginType.PLUGIN,
129           isTheme
130         })
131
132         this.loadedScripts[clientScript.script] = false
133       }
134     }
135   }
136
137   removePlugin (plugin: ServerConfigPlugin) {
138     for (const key of Object.keys(this.scopes)) {
139       this.scopes[key] = this.scopes[key].filter(o => o.plugin.name !== plugin.name)
140     }
141   }
142
143   async reloadLoadedScopes () {
144     for (const scope of this.loadedScopes) {
145       await this.loadPluginsByScope(scope, true)
146     }
147   }
148
149   async loadPluginsByScope (scope: PluginClientScope, isReload = false) {
150     if (this.loadingScopes[scope]) return
151     if (!isReload && this.loadedScopes.includes(scope)) return
152
153     this.loadingScopes[scope] = true
154
155     try {
156       await this.ensurePluginsAreBuilt()
157
158       if (!isReload) this.loadedScopes.push(scope)
159
160       const toLoad = this.scopes[ scope ]
161       if (!Array.isArray(toLoad)) {
162         this.loadingScopes[scope] = false
163         this.pluginsLoaded[scope].next(true)
164
165         return
166       }
167
168       const promises: Promise<any>[] = []
169       for (const pluginInfo of toLoad) {
170         const clientScript = pluginInfo.clientScript
171
172         if (this.loadedScripts[ clientScript.script ]) continue
173
174         promises.push(this.loadPlugin(pluginInfo))
175
176         this.loadedScripts[ clientScript.script ] = true
177       }
178
179       await Promise.all(promises)
180
181       this.pluginsLoaded[scope].next(true)
182       this.loadingScopes[scope] = false
183     } catch (err) {
184       console.error('Cannot load plugins by scope %s.', scope, err)
185     }
186   }
187
188   runHook <T> (hookName: ClientHookName, result?: T, params?: any): Promise<T> {
189     return this.zone.runOutsideAngular(async () => {
190       if (!this.hooks[ hookName ]) return result
191
192       const hookType = getHookType(hookName)
193
194       for (const hook of this.hooks[ hookName ]) {
195         console.log('Running hook %s of plugin %s.', hookName, hook.plugin.name)
196
197         result = await internalRunHook(hook.handler, hookType, result, params, err => {
198           console.error('Cannot run hook %s of script %s of plugin %s.', hookName, hook.clientScript.script, hook.plugin.name, err)
199         })
200       }
201
202       return result
203     })
204   }
205
206   nameToNpmName (name: string, type: PluginType) {
207     const prefix = type === PluginType.PLUGIN
208       ? 'peertube-plugin-'
209       : 'peertube-theme-'
210
211     return prefix + name
212   }
213
214   pluginTypeFromNpmName (npmName: string) {
215     return npmName.startsWith('peertube-plugin-')
216       ? PluginType.PLUGIN
217       : PluginType.THEME
218   }
219
220   private loadPlugin (pluginInfo: PluginInfo) {
221     const { plugin, clientScript } = pluginInfo
222
223     const registerHook = (options: RegisterClientHookOptions) => {
224       if (clientHookObject[options.target] !== true) {
225         console.error('Unknown hook %s of plugin %s. Skipping.', options.target, plugin.name)
226         return
227       }
228
229       if (!this.hooks[options.target]) this.hooks[options.target] = []
230
231       this.hooks[options.target].push({
232         plugin,
233         clientScript,
234         target: options.target,
235         handler: options.handler,
236         priority: options.priority || 0
237       })
238     }
239
240     const peertubeHelpers = this.buildPeerTubeHelpers(pluginInfo)
241
242     console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name)
243
244     return this.zone.runOutsideAngular(() => {
245       return importModule(clientScript.script)
246         .then((script: ClientScriptModule) => script.register({ registerHook, peertubeHelpers }))
247         .then(() => this.sortHooksByPriority())
248         .catch(err => console.error('Cannot import or register plugin %s.', pluginInfo.plugin.name, err))
249     })
250   }
251
252   private buildScopeStruct () {
253     for (const plugin of this.plugins) {
254       this.addPlugin(plugin)
255     }
256   }
257
258   private sortHooksByPriority () {
259     for (const hookName of Object.keys(this.hooks)) {
260       this.hooks[hookName].sort((a, b) => {
261         return b.priority - a.priority
262       })
263     }
264   }
265
266   private buildPeerTubeHelpers (pluginInfo: PluginInfo): RegisterClientHelpers {
267     const { plugin } = pluginInfo
268     const npmName = this.nameToNpmName(pluginInfo.plugin.name, pluginInfo.pluginType)
269
270     return {
271       getBaseStaticRoute: () => {
272         const pathPrefix = this.getPluginPathPrefix(pluginInfo.isTheme)
273         return environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/static`
274       },
275
276       getSettings: () => {
277         const path = PluginService.BASE_PLUGIN_API_URL + '/' + npmName + '/public-settings'
278
279         return this.authHttp.get<PublicServerSetting>(path)
280                    .pipe(
281                      map(p => p.publicSettings),
282                      catchError(res => this.restExtractor.handleError(res))
283                    )
284                    .toPromise()
285       },
286
287       isLoggedIn: () => {
288         return this.authService.isLoggedIn()
289       },
290
291       notifier: {
292         info: (text: string, title?: string, timeout?: number) => this.notifier.info(text, title, timeout),
293         error: (text: string, title?: string, timeout?: number) => this.notifier.error(text, title, timeout),
294         success: (text: string, title?: string, timeout?: number) => this.notifier.success(text, title, timeout)
295       },
296
297       showModal: (input: {
298         title: string,
299         content: string,
300         close?: boolean,
301         cancel?: { value: string, action?: () => void },
302         confirm?: { value: string, action?: () => void }
303       }) => {
304         this.customModal.show(input)
305       },
306
307       markdownRenderer: {
308         textMarkdownToHTML: (textMarkdown: string) => {
309           return this.markdownRenderer.textMarkdownToHTML(textMarkdown)
310         },
311
312         enhancedMarkdownToHTML: (enhancedMarkdown: string) => {
313           return this.markdownRenderer.enhancedMarkdownToHTML(enhancedMarkdown)
314         }
315       },
316
317       translate: (value: string) => {
318         return this.translationsObservable
319             .pipe(map(allTranslations => allTranslations[npmName]))
320             .pipe(map(translations => peertubeTranslate(value, translations)))
321             .toPromise()
322       }
323     }
324   }
325
326   private loadTranslations () {
327     const completeLocale = isOnDevLocale() ? getDevLocale() : getCompleteLocale(this.localeId)
328
329     // Default locale, nothing to translate
330     if (isDefaultLocale(completeLocale)) this.translationsObservable = of({}).pipe(shareReplay())
331
332     this.translationsObservable = this.authHttp
333         .get<PluginTranslation>(PluginService.BASE_PLUGIN_URL + '/translations/' + completeLocale + '.json')
334         .pipe(shareReplay())
335   }
336
337   private getPluginPathPrefix (isTheme: boolean) {
338     return isTheme ? '/themes' : '/plugins'
339   }
340 }