]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/plugins/plugin-manager.ts
Add ability to search available plugins
[github/Chocobozzz/PeerTube.git] / server / lib / plugins / plugin-manager.ts
CommitLineData
345da516
C
1import { PluginModel } from '../../models/server/plugin'
2import { logger } from '../../helpers/logger'
f023a19c 3import { basename, join } from 'path'
345da516
C
4import { CONFIG } from '../../initializers/config'
5import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
2c053942 6import { ClientScript, PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model'
345da516
C
7import { PluginLibrary } from '../../../shared/models/plugins/plugin-library.model'
8import { createReadStream, createWriteStream } from 'fs'
9import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
10import { PluginType } from '../../../shared/models/plugins/plugin.type'
f023a19c 11import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
2c053942 12import { outputFile } from 'fs-extra'
ad91e700
C
13import { RegisterSettingOptions } from '../../../shared/models/plugins/register-setting.model'
14import { RegisterHookOptions } from '../../../shared/models/plugins/register-hook.model'
15import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model'
b2195faf 16import { PluginStorageManager } from '../../../shared/models/plugins/plugin-storage-manager.model'
345da516
C
17
18export interface RegisteredPlugin {
b5f919ac 19 npmName: string
345da516
C
20 name: string
21 version: string
22 description: string
23 peertubeEngine: string
24
25 type: PluginType
26
27 path: string
28
29 staticDirs: { [name: string]: string }
2c053942 30 clientScripts: { [name: string]: ClientScript }
345da516
C
31
32 css: string[]
33
34 // Only if this is a plugin
35 unregister?: Function
36}
37
38export interface HookInformationValue {
b5f919ac 39 npmName: string
345da516
C
40 pluginName: string
41 handler: Function
42 priority: number
43}
44
45export class PluginManager {
46
47 private static instance: PluginManager
48
49 private registeredPlugins: { [ name: string ]: RegisteredPlugin } = {}
ad91e700 50 private settings: { [ name: string ]: RegisterSettingOptions[] } = {}
345da516
C
51 private hooks: { [ name: string ]: HookInformationValue[] } = {}
52
53 private constructor () {
54 }
55
ad91e700 56 // ###################### Getters ######################
345da516 57
6702a1b2
C
58 isRegistered (npmName: string) {
59 return !!this.getRegisteredPluginOrTheme(npmName)
60 }
61
b5f919ac
C
62 getRegisteredPluginOrTheme (npmName: string) {
63 return this.registeredPlugins[npmName]
7cd4d2ba
C
64 }
65
345da516 66 getRegisteredPlugin (name: string) {
b5f919ac
C
67 const npmName = PluginModel.buildNpmName(name, PluginType.PLUGIN)
68 const registered = this.getRegisteredPluginOrTheme(npmName)
7cd4d2ba
C
69
70 if (!registered || registered.type !== PluginType.PLUGIN) return undefined
71
72 return registered
345da516
C
73 }
74
75 getRegisteredTheme (name: string) {
b5f919ac
C
76 const npmName = PluginModel.buildNpmName(name, PluginType.THEME)
77 const registered = this.getRegisteredPluginOrTheme(npmName)
345da516
C
78
79 if (!registered || registered.type !== PluginType.THEME) return undefined
80
81 return registered
82 }
83
18a6f04c 84 getRegisteredPlugins () {
7cd4d2ba
C
85 return this.getRegisteredPluginsOrThemes(PluginType.PLUGIN)
86 }
87
88 getRegisteredThemes () {
89 return this.getRegisteredPluginsOrThemes(PluginType.THEME)
18a6f04c
C
90 }
91
b5f919ac
C
92 getRegisteredSettings (npmName: string) {
93 return this.settings[npmName] || []
ad91e700
C
94 }
95
96 // ###################### Hooks ######################
97
18a6f04c
C
98 async runHook (hookName: string, param?: any) {
99 let result = param
100
dba85a1e
C
101 if (!this.hooks[hookName]) return result
102
18a6f04c
C
103 const wait = hookName.startsWith('static:')
104
105 for (const hook of this.hooks[hookName]) {
106 try {
ad91e700
C
107 if (wait) {
108 result = await hook.handler(param)
109 } else {
110 result = hook.handler()
111 }
18a6f04c
C
112 } catch (err) {
113 logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err })
114 }
115 }
116
117 return result
118 }
119
ad91e700
C
120 // ###################### Registration ######################
121
122 async registerPluginsAndThemes () {
123 await this.resetCSSGlobalFile()
124
125 const plugins = await PluginModel.listEnabledPluginsAndThemes()
126
127 for (const plugin of plugins) {
128 try {
129 await this.registerPluginOrTheme(plugin)
130 } catch (err) {
131 logger.error('Cannot register plugin %s, skipping.', plugin.name, { err })
132 }
133 }
134
135 this.sortHooksByPriority()
136 }
137
b5f919ac
C
138 // Don't need the plugin type since themes cannot register server code
139 async unregister (npmName: string) {
140 logger.info('Unregister plugin %s.', npmName)
141
142 const plugin = this.getRegisteredPluginOrTheme(npmName)
345da516
C
143
144 if (!plugin) {
b5f919ac 145 throw new Error(`Unknown plugin ${npmName} to unregister`)
345da516
C
146 }
147
b5f919ac
C
148 if (plugin.type === PluginType.PLUGIN) {
149 await plugin.unregister()
345da516 150
b5f919ac
C
151 // Remove hooks of this plugin
152 for (const key of Object.keys(this.hooks)) {
153 this.hooks[key] = this.hooks[key].filter(h => h.pluginName !== npmName)
154 }
2c053942 155
b5f919ac
C
156 logger.info('Regenerating registered plugin CSS to global file.')
157 await this.regeneratePluginGlobalCSS()
2c053942
C
158 }
159
b5f919ac 160 delete this.registeredPlugins[plugin.npmName]
345da516
C
161 }
162
ad91e700
C
163 // ###################### Installation ######################
164
165 async install (toInstall: string, version?: string, fromDisk = false) {
f023a19c 166 let plugin: PluginModel
b5f919ac 167 let npmName: string
f023a19c
C
168
169 logger.info('Installing plugin %s.', toInstall)
170
171 try {
172 fromDisk
173 ? await installNpmPluginFromDisk(toInstall)
174 : await installNpmPlugin(toInstall, version)
175
b5f919ac
C
176 npmName = fromDisk ? basename(toInstall) : toInstall
177 const pluginType = PluginModel.getTypeFromNpmName(npmName)
178 const pluginName = PluginModel.normalizePluginName(npmName)
f023a19c
C
179
180 const packageJSON = this.getPackageJSON(pluginName, pluginType)
181 if (!isPackageJSONValid(packageJSON, pluginType)) {
182 throw new Error('PackageJSON is invalid.')
183 }
184
185 [ plugin ] = await PluginModel.upsert({
186 name: pluginName,
187 description: packageJSON.description,
dba85a1e 188 homepage: packageJSON.homepage,
f023a19c
C
189 type: pluginType,
190 version: packageJSON.version,
191 enabled: true,
192 uninstalled: false,
193 peertubeEngine: packageJSON.engine.peertube
194 }, { returning: true })
195 } catch (err) {
196 logger.error('Cannot install plugin %s, removing it...', toInstall, { err })
197
198 try {
b5f919ac 199 await removeNpmPlugin(npmName)
f023a19c
C
200 } catch (err) {
201 logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err })
202 }
203
204 throw err
205 }
206
207 logger.info('Successful installation of plugin %s.', toInstall)
208
209 await this.registerPluginOrTheme(plugin)
b5f919ac
C
210
211 return plugin
212 }
213
214 async update (toUpdate: string, version?: string, fromDisk = false) {
215 const npmName = fromDisk ? basename(toUpdate) : toUpdate
216
217 logger.info('Updating plugin %s.', npmName)
218
219 // Unregister old hooks
220 await this.unregister(npmName)
221
222 return this.install(toUpdate, version, fromDisk)
f023a19c
C
223 }
224
dba85a1e
C
225 async uninstall (npmName: string) {
226 logger.info('Uninstalling plugin %s.', npmName)
2c053942 227
2c053942 228 try {
b5f919ac 229 await this.unregister(npmName)
2c053942 230 } catch (err) {
b5f919ac 231 logger.warn('Cannot unregister plugin %s.', npmName, { err })
2c053942
C
232 }
233
dba85a1e 234 const plugin = await PluginModel.loadByNpmName(npmName)
2c053942 235 if (!plugin || plugin.uninstalled === true) {
dba85a1e 236 logger.error('Cannot uninstall plugin %s: it does not exist or is already uninstalled.', npmName)
2c053942
C
237 return
238 }
239
240 plugin.enabled = false
241 plugin.uninstalled = true
242
243 await plugin.save()
f023a19c 244
dba85a1e 245 await removeNpmPlugin(npmName)
2c053942 246
dba85a1e 247 logger.info('Plugin %s uninstalled.', npmName)
f023a19c
C
248 }
249
ad91e700
C
250 // ###################### Private register ######################
251
345da516 252 private async registerPluginOrTheme (plugin: PluginModel) {
b5f919ac
C
253 const npmName = PluginModel.buildNpmName(plugin.name, plugin.type)
254
255 logger.info('Registering plugin or theme %s.', npmName)
345da516 256
f023a19c
C
257 const packageJSON = this.getPackageJSON(plugin.name, plugin.type)
258 const pluginPath = this.getPluginPath(plugin.name, plugin.type)
345da516
C
259
260 if (!isPackageJSONValid(packageJSON, plugin.type)) {
261 throw new Error('Package.JSON is invalid.')
262 }
263
264 let library: PluginLibrary
265 if (plugin.type === PluginType.PLUGIN) {
266 library = await this.registerPlugin(plugin, pluginPath, packageJSON)
267 }
268
2c053942
C
269 const clientScripts: { [id: string]: ClientScript } = {}
270 for (const c of packageJSON.clientScripts) {
271 clientScripts[c.script] = c
272 }
273
b5f919ac
C
274 this.registeredPlugins[ npmName ] = {
275 npmName,
345da516
C
276 name: plugin.name,
277 type: plugin.type,
278 version: plugin.version,
279 description: plugin.description,
280 peertubeEngine: plugin.peertubeEngine,
281 path: pluginPath,
282 staticDirs: packageJSON.staticDirs,
2c053942 283 clientScripts,
345da516
C
284 css: packageJSON.css,
285 unregister: library ? library.unregister : undefined
286 }
287 }
288
289 private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJson) {
b5f919ac
C
290 const npmName = PluginModel.buildNpmName(plugin.name, plugin.type)
291
345da516
C
292 const registerHook = (options: RegisterHookOptions) => {
293 if (!this.hooks[options.target]) this.hooks[options.target] = []
294
295 this.hooks[options.target].push({
b5f919ac 296 npmName,
345da516
C
297 pluginName: plugin.name,
298 handler: options.handler,
299 priority: options.priority || 0
300 })
301 }
302
ad91e700 303 const registerSetting = (options: RegisterSettingOptions) => {
b5f919ac 304 if (!this.settings[npmName]) this.settings[npmName] = []
ad91e700 305
b5f919ac 306 this.settings[npmName].push(options)
ad91e700
C
307 }
308
309 const settingsManager: PluginSettingsManager = {
b5f919ac 310 getSetting: (name: string) => PluginModel.getSetting(plugin.name, plugin.type, name),
ad91e700 311
b5f919ac 312 setSetting: (name: string, value: string) => PluginModel.setSetting(plugin.name, plugin.type, name, value)
ad91e700
C
313 }
314
b2195faf
C
315 const storageManager: PluginStorageManager = {
316 getData: (key: string) => PluginModel.getData(plugin.name, plugin.type, key),
317
318 storeData: (key: string, data: any) => PluginModel.storeData(plugin.name, plugin.type, key, data)
319 }
320
345da516 321 const library: PluginLibrary = require(join(pluginPath, packageJSON.library))
f023a19c 322
345da516
C
323 if (!isLibraryCodeValid(library)) {
324 throw new Error('Library code is not valid (miss register or unregister function)')
325 }
326
b2195faf
C
327 library.register({
328 registerHook,
329 registerSetting,
330 settingsManager,
331 storageManager
332 })
345da516 333
b5f919ac 334 logger.info('Add plugin %s CSS to global file.', npmName)
345da516
C
335
336 await this.addCSSToGlobalFile(pluginPath, packageJSON.css)
337
338 return library
339 }
340
ad91e700 341 // ###################### CSS ######################
345da516 342
2c053942
C
343 private resetCSSGlobalFile () {
344 return outputFile(PLUGIN_GLOBAL_CSS_PATH, '')
345 }
346
345da516
C
347 private async addCSSToGlobalFile (pluginPath: string, cssRelativePaths: string[]) {
348 for (const cssPath of cssRelativePaths) {
349 await this.concatFiles(join(pluginPath, cssPath), PLUGIN_GLOBAL_CSS_PATH)
350 }
351 }
352
353 private concatFiles (input: string, output: string) {
354 return new Promise<void>((res, rej) => {
2c053942
C
355 const inputStream = createReadStream(input)
356 const outputStream = createWriteStream(output, { flags: 'a' })
345da516
C
357
358 inputStream.pipe(outputStream)
359
360 inputStream.on('end', () => res())
361 inputStream.on('error', err => rej(err))
362 })
363 }
364
ad91e700
C
365 private async regeneratePluginGlobalCSS () {
366 await this.resetCSSGlobalFile()
367
368 for (const key of Object.keys(this.registeredPlugins)) {
369 const plugin = this.registeredPlugins[key]
370
371 await this.addCSSToGlobalFile(plugin.path, plugin.css)
372 }
373 }
374
375 // ###################### Utils ######################
376
377 private sortHooksByPriority () {
378 for (const hookName of Object.keys(this.hooks)) {
379 this.hooks[hookName].sort((a, b) => {
380 return b.priority - a.priority
381 })
382 }
383 }
384
f023a19c
C
385 private getPackageJSON (pluginName: string, pluginType: PluginType) {
386 const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json')
387
388 return require(pluginPath) as PluginPackageJson
389 }
390
391 private getPluginPath (pluginName: string, pluginType: PluginType) {
b5f919ac 392 const npmName = PluginModel.buildNpmName(pluginName, pluginType)
f023a19c 393
b5f919ac 394 return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', npmName)
f023a19c
C
395 }
396
ad91e700 397 // ###################### Private getters ######################
2c053942 398
7cd4d2ba
C
399 private getRegisteredPluginsOrThemes (type: PluginType) {
400 const plugins: RegisteredPlugin[] = []
401
b5f919ac
C
402 for (const npmName of Object.keys(this.registeredPlugins)) {
403 const plugin = this.registeredPlugins[ npmName ]
7cd4d2ba
C
404 if (plugin.type !== type) continue
405
406 plugins.push(plugin)
407 }
408
409 return plugins
410 }
411
345da516
C
412 static get Instance () {
413 return this.instance || (this.instance = new this())
414 }
415}