]>
Commit | Line | Data |
---|---|---|
345da516 C |
1 | import { PluginModel } from '../../models/server/plugin' |
2 | import { logger } from '../../helpers/logger' | |
3 | import { RegisterHookOptions } from '../../../shared/models/plugins/register.model' | |
f023a19c | 4 | import { basename, join } from 'path' |
345da516 C |
5 | import { CONFIG } from '../../initializers/config' |
6 | import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins' | |
2c053942 | 7 | import { ClientScript, PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model' |
345da516 C |
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' | |
f023a19c | 12 | import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn' |
2c053942 | 13 | import { outputFile } from 'fs-extra' |
345da516 C |
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 } | |
2c053942 | 26 | clientScripts: { [name: string]: ClientScript } |
345da516 C |
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 () { | |
2c053942 C |
51 | await this.resetCSSGlobalFile() |
52 | ||
345da516 C |
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 | ||
18a6f04c C |
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 | ||
345da516 C |
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() | |
2c053942 C |
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() | |
345da516 C |
121 | } |
122 | ||
f023a19c C |
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) { | |
2c053942 C |
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() | |
f023a19c C |
190 | |
191 | await removeNpmPlugin(packageName) | |
2c053942 C |
192 | |
193 | logger.info('Plugin %s uninstalled.', packageName) | |
f023a19c C |
194 | } |
195 | ||
345da516 C |
196 | private async registerPluginOrTheme (plugin: PluginModel) { |
197 | logger.info('Registering plugin or theme %s.', plugin.name) | |
198 | ||
f023a19c C |
199 | const packageJSON = this.getPackageJSON(plugin.name, plugin.type) |
200 | const pluginPath = this.getPluginPath(plugin.name, plugin.type) | |
345da516 C |
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 | ||
2c053942 C |
211 | const clientScripts: { [id: string]: ClientScript } = {} |
212 | for (const c of packageJSON.clientScripts) { | |
213 | clientScripts[c.script] = c | |
214 | } | |
215 | ||
345da516 C |
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, | |
2c053942 | 224 | clientScripts, |
345da516 C |
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)) | |
f023a19c | 242 | |
345da516 C |
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 | ||
2c053942 C |
264 | private resetCSSGlobalFile () { |
265 | return outputFile(PLUGIN_GLOBAL_CSS_PATH, '') | |
266 | } | |
267 | ||
345da516 C |
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) => { | |
2c053942 C |
276 | const inputStream = createReadStream(input) |
277 | const outputStream = createWriteStream(output, { flags: 'a' }) | |
345da516 C |
278 | |
279 | inputStream.pipe(outputStream) | |
280 | ||
281 | inputStream.on('end', () => res()) | |
282 | inputStream.on('error', err => rej(err)) | |
283 | }) | |
284 | } | |
285 | ||
f023a19c C |
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 | ||
2c053942 C |
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 | ||
345da516 C |
312 | static get Instance () { |
313 | return this.instance || (this.instance = new this()) | |
314 | } | |
315 | } |