7cbfa856943904648eba842708fbc3e39b470a83
[oweals/peertube.git] / server / lib / plugins / plugin-manager.ts
1 import { PluginModel } from '../../models/server/plugin'
2 import { logger } from '../../helpers/logger'
3 import { RegisterHookOptions } from '../../../shared/models/plugins/register.model'
4 import { basename, join } from 'path'
5 import { CONFIG } from '../../initializers/config'
6 import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
7 import { ClientScript, PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model'
8 import { PluginLibrary } from '../../../shared/models/plugins/plugin-library.model'
9 import { createReadStream, createWriteStream } from 'fs'
10 import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
11 import { PluginType } from '../../../shared/models/plugins/plugin.type'
12 import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
13 import { outputFile } from 'fs-extra'
14
15 export interface RegisteredPlugin {
16   name: string
17   version: string
18   description: string
19   peertubeEngine: string
20
21   type: PluginType
22
23   path: string
24
25   staticDirs: { [name: string]: string }
26   clientScripts: { [name: string]: ClientScript }
27
28   css: string[]
29
30   // Only if this is a plugin
31   unregister?: Function
32 }
33
34 export interface HookInformationValue {
35   pluginName: string
36   handler: Function
37   priority: number
38 }
39
40 export class PluginManager {
41
42   private static instance: PluginManager
43
44   private registeredPlugins: { [ name: string ]: RegisteredPlugin } = {}
45   private hooks: { [ name: string ]: HookInformationValue[] } = {}
46
47   private constructor () {
48   }
49
50   async registerPlugins () {
51     await this.resetCSSGlobalFile()
52
53     const plugins = await PluginModel.listEnabledPluginsAndThemes()
54
55     for (const plugin of plugins) {
56       try {
57         await this.registerPluginOrTheme(plugin)
58       } catch (err) {
59         logger.error('Cannot register plugin %s, skipping.', plugin.name, { err })
60       }
61     }
62
63     this.sortHooksByPriority()
64   }
65
66   getRegisteredPlugin (name: string) {
67     return this.registeredPlugins[ name ]
68   }
69
70   getRegisteredTheme (name: string) {
71     const registered = this.getRegisteredPlugin(name)
72
73     if (!registered || registered.type !== PluginType.THEME) return undefined
74
75     return registered
76   }
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
99   async unregister (name: string) {
100     const plugin = this.getRegisteredPlugin(name)
101
102     if (!plugin) {
103       throw new Error(`Unknown plugin ${name} to unregister`)
104     }
105
106     if (plugin.type === PluginType.THEME) {
107       throw new Error(`Cannot unregister ${name}: this is a theme`)
108     }
109
110     await plugin.unregister()
111
112     // Remove hooks of this plugin
113     for (const key of Object.keys(this.hooks)) {
114       this.hooks[key] = this.hooks[key].filter(h => h.pluginName !== name)
115     }
116
117     delete this.registeredPlugins[plugin.name]
118
119     logger.info('Regenerating registered plugin CSS to global file.')
120     await this.regeneratePluginGlobalCSS()
121   }
122
123   async install (toInstall: string, version: string, fromDisk = false) {
124     let plugin: PluginModel
125     let name: string
126
127     logger.info('Installing plugin %s.', toInstall)
128
129     try {
130       fromDisk
131         ? await installNpmPluginFromDisk(toInstall)
132         : await installNpmPlugin(toInstall, version)
133
134       name = fromDisk ? basename(toInstall) : toInstall
135       const pluginType = name.startsWith('peertube-theme-') ? PluginType.THEME : PluginType.PLUGIN
136       const pluginName = this.normalizePluginName(name)
137
138       const packageJSON = this.getPackageJSON(pluginName, pluginType)
139       if (!isPackageJSONValid(packageJSON, pluginType)) {
140         throw new Error('PackageJSON is invalid.')
141       }
142
143       [ plugin ] = await PluginModel.upsert({
144         name: pluginName,
145         description: packageJSON.description,
146         type: pluginType,
147         version: packageJSON.version,
148         enabled: true,
149         uninstalled: false,
150         peertubeEngine: packageJSON.engine.peertube
151       }, { returning: true })
152     } catch (err) {
153       logger.error('Cannot install plugin %s, removing it...', toInstall, { err })
154
155       try {
156         await removeNpmPlugin(name)
157       } catch (err) {
158         logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err })
159       }
160
161       throw err
162     }
163
164     logger.info('Successful installation of plugin %s.', toInstall)
165
166     await this.registerPluginOrTheme(plugin)
167   }
168
169   async uninstall (packageName: string) {
170     logger.info('Uninstalling plugin %s.', packageName)
171
172     const pluginName = this.normalizePluginName(packageName)
173
174     try {
175       await this.unregister(pluginName)
176     } catch (err) {
177       logger.warn('Cannot unregister plugin %s.', pluginName, { err })
178     }
179
180     const plugin = await PluginModel.load(pluginName)
181     if (!plugin || plugin.uninstalled === true) {
182       logger.error('Cannot uninstall plugin %s: it does not exist or is already uninstalled.', packageName)
183       return
184     }
185
186     plugin.enabled = false
187     plugin.uninstalled = true
188
189     await plugin.save()
190
191     await removeNpmPlugin(packageName)
192
193     logger.info('Plugin %s uninstalled.', packageName)
194   }
195
196   private async registerPluginOrTheme (plugin: PluginModel) {
197     logger.info('Registering plugin or theme %s.', plugin.name)
198
199     const packageJSON = this.getPackageJSON(plugin.name, plugin.type)
200     const pluginPath = this.getPluginPath(plugin.name, plugin.type)
201
202     if (!isPackageJSONValid(packageJSON, plugin.type)) {
203       throw new Error('Package.JSON is invalid.')
204     }
205
206     let library: PluginLibrary
207     if (plugin.type === PluginType.PLUGIN) {
208       library = await this.registerPlugin(plugin, pluginPath, packageJSON)
209     }
210
211     const clientScripts: { [id: string]: ClientScript } = {}
212     for (const c of packageJSON.clientScripts) {
213       clientScripts[c.script] = c
214     }
215
216     this.registeredPlugins[ plugin.name ] = {
217       name: plugin.name,
218       type: plugin.type,
219       version: plugin.version,
220       description: plugin.description,
221       peertubeEngine: plugin.peertubeEngine,
222       path: pluginPath,
223       staticDirs: packageJSON.staticDirs,
224       clientScripts,
225       css: packageJSON.css,
226       unregister: library ? library.unregister : undefined
227     }
228   }
229
230   private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJson) {
231     const registerHook = (options: RegisterHookOptions) => {
232       if (!this.hooks[options.target]) this.hooks[options.target] = []
233
234       this.hooks[options.target].push({
235         pluginName: plugin.name,
236         handler: options.handler,
237         priority: options.priority || 0
238       })
239     }
240
241     const library: PluginLibrary = require(join(pluginPath, packageJSON.library))
242
243     if (!isLibraryCodeValid(library)) {
244       throw new Error('Library code is not valid (miss register or unregister function)')
245     }
246
247     library.register({ registerHook })
248
249     logger.info('Add plugin %s CSS to global file.', plugin.name)
250
251     await this.addCSSToGlobalFile(pluginPath, packageJSON.css)
252
253     return library
254   }
255
256   private sortHooksByPriority () {
257     for (const hookName of Object.keys(this.hooks)) {
258       this.hooks[hookName].sort((a, b) => {
259         return b.priority - a.priority
260       })
261     }
262   }
263
264   private resetCSSGlobalFile () {
265     return outputFile(PLUGIN_GLOBAL_CSS_PATH, '')
266   }
267
268   private async addCSSToGlobalFile (pluginPath: string, cssRelativePaths: string[]) {
269     for (const cssPath of cssRelativePaths) {
270       await this.concatFiles(join(pluginPath, cssPath), PLUGIN_GLOBAL_CSS_PATH)
271     }
272   }
273
274   private concatFiles (input: string, output: string) {
275     return new Promise<void>((res, rej) => {
276       const inputStream = createReadStream(input)
277       const outputStream = createWriteStream(output, { flags: 'a' })
278
279       inputStream.pipe(outputStream)
280
281       inputStream.on('end', () => res())
282       inputStream.on('error', err => rej(err))
283     })
284   }
285
286   private getPackageJSON (pluginName: string, pluginType: PluginType) {
287     const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json')
288
289     return require(pluginPath) as PluginPackageJson
290   }
291
292   private getPluginPath (pluginName: string, pluginType: PluginType) {
293     const prefix = pluginType === PluginType.PLUGIN ? 'peertube-plugin-' : 'peertube-theme-'
294
295     return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', prefix + pluginName)
296   }
297
298   private normalizePluginName (name: string) {
299     return name.replace(/^peertube-((theme)|(plugin))-/, '')
300   }
301
302   private async regeneratePluginGlobalCSS () {
303     await this.resetCSSGlobalFile()
304
305     for (const key of Object.keys(this.registeredPlugins)) {
306       const plugin = this.registeredPlugins[key]
307
308       await this.addCSSToGlobalFile(plugin.path, plugin.css)
309     }
310   }
311
312   static get Instance () {
313     return this.instance || (this.instance = new this())
314   }
315 }