diff options
23 files changed, 553 insertions, 3 deletions
diff --git a/config/default.yaml b/config/default.yaml index be5c8993c..ff3d6d54c 100644 --- a/config/default.yaml +++ b/config/default.yaml | |||
@@ -80,6 +80,7 @@ storage: | |||
80 | torrents: 'storage/torrents/' | 80 | torrents: 'storage/torrents/' |
81 | captions: 'storage/captions/' | 81 | captions: 'storage/captions/' |
82 | cache: 'storage/cache/' | 82 | cache: 'storage/cache/' |
83 | plugins: 'storage/plugins/' | ||
83 | 84 | ||
84 | log: | 85 | log: |
85 | level: 'info' # debug/info/warning/error | 86 | level: 'info' # debug/info/warning/error |
diff --git a/config/production.yaml.example b/config/production.yaml.example index f55f5c096..7158e076b 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example | |||
@@ -81,6 +81,7 @@ storage: | |||
81 | torrents: '/var/www/peertube/storage/torrents/' | 81 | torrents: '/var/www/peertube/storage/torrents/' |
82 | captions: '/var/www/peertube/storage/captions/' | 82 | captions: '/var/www/peertube/storage/captions/' |
83 | cache: '/var/www/peertube/storage/cache/' | 83 | cache: '/var/www/peertube/storage/cache/' |
84 | plugins: '/var/www/peertube/storage/plugins/' | ||
84 | 85 | ||
85 | log: | 86 | log: |
86 | level: 'info' # debug/info/warning/error | 87 | level: 'info' # debug/info/warning/error |
@@ -94,6 +94,8 @@ import { | |||
94 | feedsRouter, | 94 | feedsRouter, |
95 | staticRouter, | 95 | staticRouter, |
96 | servicesRouter, | 96 | servicesRouter, |
97 | pluginsRouter, | ||
98 | themesRouter, | ||
97 | webfingerRouter, | 99 | webfingerRouter, |
98 | trackerRouter, | 100 | trackerRouter, |
99 | createWebsocketTrackerServer, botsRouter | 101 | createWebsocketTrackerServer, botsRouter |
@@ -173,6 +175,10 @@ app.use(apiRoute, apiRouter) | |||
173 | // Services (oembed...) | 175 | // Services (oembed...) |
174 | app.use('/services', servicesRouter) | 176 | app.use('/services', servicesRouter) |
175 | 177 | ||
178 | // Plugins & themes | ||
179 | app.use('/plugins', pluginsRouter) | ||
180 | app.use('/themes', themesRouter) | ||
181 | |||
176 | app.use('/', activityPubRouter) | 182 | app.use('/', activityPubRouter) |
177 | app.use('/', feedsRouter) | 183 | app.use('/', feedsRouter) |
178 | app.use('/', webfingerRouter) | 184 | app.use('/', webfingerRouter) |
diff --git a/server/controllers/index.ts b/server/controllers/index.ts index a88a03c79..869546dc7 100644 --- a/server/controllers/index.ts +++ b/server/controllers/index.ts | |||
@@ -7,3 +7,5 @@ export * from './static' | |||
7 | export * from './webfinger' | 7 | export * from './webfinger' |
8 | export * from './tracker' | 8 | export * from './tracker' |
9 | export * from './bots' | 9 | export * from './bots' |
10 | export * from './plugins' | ||
11 | export * from './themes' | ||
diff --git a/server/controllers/plugins.ts b/server/controllers/plugins.ts new file mode 100644 index 000000000..a6705d9c7 --- /dev/null +++ b/server/controllers/plugins.ts | |||
@@ -0,0 +1,48 @@ | |||
1 | import * as express from 'express' | ||
2 | import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants' | ||
3 | import { join } from 'path' | ||
4 | import { RegisteredPlugin } from '../lib/plugins/plugin-manager' | ||
5 | import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins' | ||
6 | |||
7 | const pluginsRouter = express.Router() | ||
8 | |||
9 | pluginsRouter.get('/global.css', | ||
10 | express.static(PLUGIN_GLOBAL_CSS_PATH, { fallthrough: false }) | ||
11 | ) | ||
12 | |||
13 | pluginsRouter.get('/:pluginName/:pluginVersion/statics/:staticEndpoint', | ||
14 | servePluginStaticDirectoryValidator, | ||
15 | servePluginStaticDirectory | ||
16 | ) | ||
17 | |||
18 | pluginsRouter.get('/:pluginName/:pluginVersion/client-scripts/:staticEndpoint', | ||
19 | servePluginStaticDirectoryValidator, | ||
20 | servePluginClientScripts | ||
21 | ) | ||
22 | |||
23 | // --------------------------------------------------------------------------- | ||
24 | |||
25 | export { | ||
26 | pluginsRouter | ||
27 | } | ||
28 | |||
29 | // --------------------------------------------------------------------------- | ||
30 | |||
31 | function servePluginStaticDirectory (req: express.Request, res: express.Response) { | ||
32 | const plugin: RegisteredPlugin = res.locals.registeredPlugin | ||
33 | const staticEndpoint = req.params.staticEndpoint | ||
34 | |||
35 | const staticPath = plugin.staticDirs[staticEndpoint] | ||
36 | if (!staticPath) { | ||
37 | return res.sendStatus(404) | ||
38 | } | ||
39 | |||
40 | return express.static(join(plugin.path, staticPath), { fallthrough: false }) | ||
41 | } | ||
42 | |||
43 | function servePluginClientScripts (req: express.Request, res: express.Response) { | ||
44 | const plugin: RegisteredPlugin = res.locals.registeredPlugin | ||
45 | const staticEndpoint = req.params.staticEndpoint | ||
46 | |||
47 | return express.static(join(plugin.path, staticEndpoint), { fallthrough: false }) | ||
48 | } | ||
diff --git a/server/controllers/themes.ts b/server/controllers/themes.ts new file mode 100644 index 000000000..20e7062d0 --- /dev/null +++ b/server/controllers/themes.ts | |||
@@ -0,0 +1,28 @@ | |||
1 | import * as express from 'express' | ||
2 | import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants' | ||
3 | import { join } from 'path' | ||
4 | import { RegisteredPlugin } from '../lib/plugins/plugin-manager' | ||
5 | import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins' | ||
6 | import { serveThemeCSSValidator } from '../middlewares/validators/themes' | ||
7 | |||
8 | const themesRouter = express.Router() | ||
9 | |||
10 | themesRouter.get('/:themeName/:themeVersion/css/:staticEndpoint', | ||
11 | serveThemeCSSValidator, | ||
12 | serveThemeCSSDirectory | ||
13 | ) | ||
14 | |||
15 | // --------------------------------------------------------------------------- | ||
16 | |||
17 | export { | ||
18 | themesRouter | ||
19 | } | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | function serveThemeCSSDirectory (req: express.Request, res: express.Response) { | ||
24 | const plugin: RegisteredPlugin = res.locals.registeredPlugin | ||
25 | const staticEndpoint = req.params.staticEndpoint | ||
26 | |||
27 | return express.static(join(plugin.path, staticEndpoint), { fallthrough: false }) | ||
28 | } | ||
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts index 3a3deab0c..f72513c1c 100644 --- a/server/helpers/custom-validators/misc.ts +++ b/server/helpers/custom-validators/misc.ts | |||
@@ -1,10 +1,18 @@ | |||
1 | import 'multer' | 1 | import 'multer' |
2 | import * as validator from 'validator' | 2 | import * as validator from 'validator' |
3 | import { sep } from 'path' | ||
3 | 4 | ||
4 | function exists (value: any) { | 5 | function exists (value: any) { |
5 | return value !== undefined && value !== null | 6 | return value !== undefined && value !== null |
6 | } | 7 | } |
7 | 8 | ||
9 | function isSafePath (p: string) { | ||
10 | return exists(p) && | ||
11 | (p + '').split(sep).every(part => { | ||
12 | return [ '', '.', '..' ].includes(part) === false | ||
13 | }) | ||
14 | } | ||
15 | |||
8 | function isArray (value: any) { | 16 | function isArray (value: any) { |
9 | return Array.isArray(value) | 17 | return Array.isArray(value) |
10 | } | 18 | } |
@@ -97,6 +105,7 @@ export { | |||
97 | isNotEmptyIntArray, | 105 | isNotEmptyIntArray, |
98 | isArray, | 106 | isArray, |
99 | isIdValid, | 107 | isIdValid, |
108 | isSafePath, | ||
100 | isUUIDValid, | 109 | isUUIDValid, |
101 | isIdOrUUIDValid, | 110 | isIdOrUUIDValid, |
102 | isDateValid, | 111 | isDateValid, |
diff --git a/server/helpers/custom-validators/plugins.ts b/server/helpers/custom-validators/plugins.ts new file mode 100644 index 000000000..ff687dc3f --- /dev/null +++ b/server/helpers/custom-validators/plugins.ts | |||
@@ -0,0 +1,82 @@ | |||
1 | import { exists, isArray, isSafePath } from './misc' | ||
2 | import * as validator from 'validator' | ||
3 | import { PluginType } from '../../../shared/models/plugins/plugin.type' | ||
4 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | ||
5 | import { PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model' | ||
6 | import { isUrlValid } from './activitypub/misc' | ||
7 | |||
8 | const PLUGINS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.PLUGINS | ||
9 | |||
10 | function isPluginTypeValid (value: any) { | ||
11 | return exists(value) && validator.isInt('' + value) && PluginType[value] !== undefined | ||
12 | } | ||
13 | |||
14 | function isPluginNameValid (value: string) { | ||
15 | return exists(value) && | ||
16 | validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.NAME) && | ||
17 | validator.matches(value, /^[a-z\-]+$/) | ||
18 | } | ||
19 | |||
20 | function isPluginDescriptionValid (value: string) { | ||
21 | return exists(value) && validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.DESCRIPTION) | ||
22 | } | ||
23 | |||
24 | function isPluginVersionValid (value: string) { | ||
25 | if (!exists(value)) return false | ||
26 | |||
27 | const parts = (value + '').split('.') | ||
28 | |||
29 | return parts.length === 3 && parts.every(p => validator.isInt(p)) | ||
30 | } | ||
31 | |||
32 | function isPluginEngineValid (engine: any) { | ||
33 | return exists(engine) && exists(engine.peertube) | ||
34 | } | ||
35 | |||
36 | function isStaticDirectoriesValid (staticDirs: any) { | ||
37 | if (!exists(staticDirs) || typeof staticDirs !== 'object') return false | ||
38 | |||
39 | for (const key of Object.keys(staticDirs)) { | ||
40 | if (!isSafePath(staticDirs[key])) return false | ||
41 | } | ||
42 | |||
43 | return true | ||
44 | } | ||
45 | |||
46 | function isClientScriptsValid (clientScripts: any[]) { | ||
47 | return isArray(clientScripts) && | ||
48 | clientScripts.every(c => { | ||
49 | return isSafePath(c.script) && isArray(c.scopes) | ||
50 | }) | ||
51 | } | ||
52 | |||
53 | function isCSSPathsValid (css: any[]) { | ||
54 | return isArray(css) && css.every(c => isSafePath(c)) | ||
55 | } | ||
56 | |||
57 | function isPackageJSONValid (packageJSON: PluginPackageJson, pluginType: PluginType) { | ||
58 | return isPluginNameValid(packageJSON.name) && | ||
59 | isPluginDescriptionValid(packageJSON.description) && | ||
60 | isPluginEngineValid(packageJSON.engine) && | ||
61 | isUrlValid(packageJSON.homepage) && | ||
62 | exists(packageJSON.author) && | ||
63 | isUrlValid(packageJSON.bugs) && | ||
64 | (pluginType === PluginType.THEME || isSafePath(packageJSON.library)) && | ||
65 | isStaticDirectoriesValid(packageJSON.staticDirs) && | ||
66 | isCSSPathsValid(packageJSON.css) && | ||
67 | isClientScriptsValid(packageJSON.clientScripts) | ||
68 | } | ||
69 | |||
70 | function isLibraryCodeValid (library: any) { | ||
71 | return typeof library.register === 'function' | ||
72 | && typeof library.unregister === 'function' | ||
73 | } | ||
74 | |||
75 | export { | ||
76 | isPluginTypeValid, | ||
77 | isPackageJSONValid, | ||
78 | isPluginVersionValid, | ||
79 | isPluginNameValid, | ||
80 | isPluginDescriptionValid, | ||
81 | isLibraryCodeValid | ||
82 | } | ||
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts index c211d725c..1f5ec20df 100644 --- a/server/initializers/checker-before-init.ts +++ b/server/initializers/checker-before-init.ts | |||
@@ -12,7 +12,7 @@ function checkMissedConfig () { | |||
12 | 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', | 12 | 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', |
13 | 'email.body.signature', 'email.object.prefix', | 13 | 'email.body.signature', 'email.object.prefix', |
14 | 'storage.avatars', 'storage.videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache', | 14 | 'storage.avatars', 'storage.videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache', |
15 | 'storage.redundancy', 'storage.tmp', 'storage.streaming_playlists', | 15 | 'storage.redundancy', 'storage.tmp', 'storage.streaming_playlists', 'storage.plugins', |
16 | 'log.level', | 16 | 'log.level', |
17 | 'user.video_quota', 'user.video_quota_daily', | 17 | 'user.video_quota', 'user.video_quota_daily', |
18 | 'csp.enabled', 'csp.report_only', 'csp.report_uri', | 18 | 'csp.enabled', 'csp.report_only', 'csp.report_uri', |
diff --git a/server/initializers/config.ts b/server/initializers/config.ts index eefb45fb9..6737edcd6 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts | |||
@@ -63,7 +63,8 @@ const CONFIG = { | |||
63 | PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')), | 63 | PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')), |
64 | CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')), | 64 | CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')), |
65 | TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')), | 65 | TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')), |
66 | CACHE_DIR: buildPath(config.get<string>('storage.cache')) | 66 | CACHE_DIR: buildPath(config.get<string>('storage.cache')), |
67 | PLUGINS_DIR: buildPath(config.get<string>('storage.plugins')) | ||
67 | }, | 68 | }, |
68 | WEBSERVER: { | 69 | WEBSERVER: { |
69 | SCHEME: config.get<boolean>('webserver.https') === true ? 'https' : 'http', | 70 | SCHEME: config.get<boolean>('webserver.https') === true ? 'https' : 'http', |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index abd9c2003..8ceefbd0e 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -277,6 +277,10 @@ let CONSTRAINTS_FIELDS = { | |||
277 | CONTACT_FORM: { | 277 | CONTACT_FORM: { |
278 | FROM_NAME: { min: 1, max: 120 }, // Length | 278 | FROM_NAME: { min: 1, max: 120 }, // Length |
279 | BODY: { min: 3, max: 5000 } // Length | 279 | BODY: { min: 3, max: 5000 } // Length |
280 | }, | ||
281 | PLUGINS: { | ||
282 | NAME: { min: 1, max: 214 }, // Length | ||
283 | DESCRIPTION: { min: 1, max: 20000 } // Length | ||
280 | } | 284 | } |
281 | } | 285 | } |
282 | 286 | ||
@@ -578,6 +582,11 @@ const P2P_MEDIA_LOADER_PEER_VERSION = 2 | |||
578 | 582 | ||
579 | // --------------------------------------------------------------------------- | 583 | // --------------------------------------------------------------------------- |
580 | 584 | ||
585 | const PLUGIN_GLOBAL_CSS_FILE_NAME = 'plugins-global.css' | ||
586 | const PLUGIN_GLOBAL_CSS_PATH = join(CONFIG.STORAGE.TMP_DIR, PLUGIN_GLOBAL_CSS_FILE_NAME) | ||
587 | |||
588 | // --------------------------------------------------------------------------- | ||
589 | |||
581 | // Special constants for a test instance | 590 | // Special constants for a test instance |
582 | if (isTestInstance() === true) { | 591 | if (isTestInstance() === true) { |
583 | PRIVATE_RSA_KEY_SIZE = 1024 | 592 | PRIVATE_RSA_KEY_SIZE = 1024 |
@@ -650,6 +659,8 @@ export { | |||
650 | REMOTE_SCHEME, | 659 | REMOTE_SCHEME, |
651 | FOLLOW_STATES, | 660 | FOLLOW_STATES, |
652 | SERVER_ACTOR_NAME, | 661 | SERVER_ACTOR_NAME, |
662 | PLUGIN_GLOBAL_CSS_FILE_NAME, | ||
663 | PLUGIN_GLOBAL_CSS_PATH, | ||
653 | PRIVATE_RSA_KEY_SIZE, | 664 | PRIVATE_RSA_KEY_SIZE, |
654 | ROUTE_CACHE_LIFETIME, | 665 | ROUTE_CACHE_LIFETIME, |
655 | SORTABLE_COLUMNS, | 666 | SORTABLE_COLUMNS, |
diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts new file mode 100644 index 000000000..b48ecc991 --- /dev/null +++ b/server/lib/plugins/plugin-manager.ts | |||
@@ -0,0 +1,169 @@ | |||
1 | import { PluginModel } from '../../models/server/plugin' | ||
2 | import { logger } from '../../helpers/logger' | ||
3 | import { RegisterHookOptions } from '../../../shared/models/plugins/register.model' | ||
4 | import { join } from 'path' | ||
5 | import { CONFIG } from '../../initializers/config' | ||
6 | import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins' | ||
7 | import { 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 | |||
13 | export interface RegisteredPlugin { | ||
14 | name: string | ||
15 | version: string | ||
16 | description: string | ||
17 | peertubeEngine: string | ||
18 | |||
19 | type: PluginType | ||
20 | |||
21 | path: string | ||
22 | |||
23 | staticDirs: { [name: string]: string } | ||
24 | |||
25 | css: string[] | ||
26 | |||
27 | // Only if this is a plugin | ||
28 | unregister?: Function | ||
29 | } | ||
30 | |||
31 | export interface HookInformationValue { | ||
32 | pluginName: string | ||
33 | handler: Function | ||
34 | priority: number | ||
35 | } | ||
36 | |||
37 | export class PluginManager { | ||
38 | |||
39 | private static instance: PluginManager | ||
40 | |||
41 | private registeredPlugins: { [ name: string ]: RegisteredPlugin } = {} | ||
42 | private hooks: { [ name: string ]: HookInformationValue[] } = {} | ||
43 | |||
44 | private constructor () { | ||
45 | } | ||
46 | |||
47 | async registerPlugins () { | ||
48 | const plugins = await PluginModel.listEnabledPluginsAndThemes() | ||
49 | |||
50 | for (const plugin of plugins) { | ||
51 | try { | ||
52 | await this.registerPluginOrTheme(plugin) | ||
53 | } catch (err) { | ||
54 | logger.error('Cannot register plugin %s, skipping.', plugin.name, { err }) | ||
55 | } | ||
56 | } | ||
57 | |||
58 | this.sortHooksByPriority() | ||
59 | } | ||
60 | |||
61 | getRegisteredPlugin (name: string) { | ||
62 | return this.registeredPlugins[ name ] | ||
63 | } | ||
64 | |||
65 | getRegisteredTheme (name: string) { | ||
66 | const registered = this.getRegisteredPlugin(name) | ||
67 | |||
68 | if (!registered || registered.type !== PluginType.THEME) return undefined | ||
69 | |||
70 | return registered | ||
71 | } | ||
72 | |||
73 | async unregister (name: string) { | ||
74 | const plugin = this.getRegisteredPlugin(name) | ||
75 | |||
76 | if (!plugin) { | ||
77 | throw new Error(`Unknown plugin ${name} to unregister`) | ||
78 | } | ||
79 | |||
80 | if (plugin.type === PluginType.THEME) { | ||
81 | throw new Error(`Cannot unregister ${name}: this is a theme`) | ||
82 | } | ||
83 | |||
84 | await plugin.unregister() | ||
85 | } | ||
86 | |||
87 | private async registerPluginOrTheme (plugin: PluginModel) { | ||
88 | logger.info('Registering plugin or theme %s.', plugin.name) | ||
89 | |||
90 | const pluginPath = join(CONFIG.STORAGE.PLUGINS_DIR, plugin.name, plugin.version) | ||
91 | const packageJSON: PluginPackageJson = require(join(pluginPath, 'package.json')) | ||
92 | |||
93 | if (!isPackageJSONValid(packageJSON, plugin.type)) { | ||
94 | throw new Error('Package.JSON is invalid.') | ||
95 | } | ||
96 | |||
97 | let library: PluginLibrary | ||
98 | if (plugin.type === PluginType.PLUGIN) { | ||
99 | library = await this.registerPlugin(plugin, pluginPath, packageJSON) | ||
100 | } | ||
101 | |||
102 | this.registeredPlugins[ plugin.name ] = { | ||
103 | name: plugin.name, | ||
104 | type: plugin.type, | ||
105 | version: plugin.version, | ||
106 | description: plugin.description, | ||
107 | peertubeEngine: plugin.peertubeEngine, | ||
108 | path: pluginPath, | ||
109 | staticDirs: packageJSON.staticDirs, | ||
110 | css: packageJSON.css, | ||
111 | unregister: library ? library.unregister : undefined | ||
112 | } | ||
113 | } | ||
114 | |||
115 | private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJson) { | ||
116 | const registerHook = (options: RegisterHookOptions) => { | ||
117 | if (!this.hooks[options.target]) this.hooks[options.target] = [] | ||
118 | |||
119 | this.hooks[options.target].push({ | ||
120 | pluginName: plugin.name, | ||
121 | handler: options.handler, | ||
122 | priority: options.priority || 0 | ||
123 | }) | ||
124 | } | ||
125 | |||
126 | const library: PluginLibrary = require(join(pluginPath, packageJSON.library)) | ||
127 | if (!isLibraryCodeValid(library)) { | ||
128 | throw new Error('Library code is not valid (miss register or unregister function)') | ||
129 | } | ||
130 | |||
131 | library.register({ registerHook }) | ||
132 | |||
133 | logger.info('Add plugin %s CSS to global file.', plugin.name) | ||
134 | |||
135 | await this.addCSSToGlobalFile(pluginPath, packageJSON.css) | ||
136 | |||
137 | return library | ||
138 | } | ||
139 | |||
140 | private sortHooksByPriority () { | ||
141 | for (const hookName of Object.keys(this.hooks)) { | ||
142 | this.hooks[hookName].sort((a, b) => { | ||
143 | return b.priority - a.priority | ||
144 | }) | ||
145 | } | ||
146 | } | ||
147 | |||
148 | private async addCSSToGlobalFile (pluginPath: string, cssRelativePaths: string[]) { | ||
149 | for (const cssPath of cssRelativePaths) { | ||
150 | await this.concatFiles(join(pluginPath, cssPath), PLUGIN_GLOBAL_CSS_PATH) | ||
151 | } | ||
152 | } | ||
153 | |||
154 | private concatFiles (input: string, output: string) { | ||
155 | return new Promise<void>((res, rej) => { | ||
156 | const outputStream = createWriteStream(input) | ||
157 | const inputStream = createReadStream(output) | ||
158 | |||
159 | inputStream.pipe(outputStream) | ||
160 | |||
161 | inputStream.on('end', () => res()) | ||
162 | inputStream.on('error', err => rej(err)) | ||
163 | }) | ||
164 | } | ||
165 | |||
166 | static get Instance () { | ||
167 | return this.instance || (this.instance = new this()) | ||
168 | } | ||
169 | } | ||
diff --git a/server/lib/schedulers/remove-old-history-scheduler.ts b/server/lib/schedulers/remove-old-history-scheduler.ts index 1b5ff8394..17a42b2c4 100644 --- a/server/lib/schedulers/remove-old-history-scheduler.ts +++ b/server/lib/schedulers/remove-old-history-scheduler.ts | |||
@@ -3,7 +3,6 @@ import { AbstractScheduler } from './abstract-scheduler' | |||
3 | import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants' | 3 | import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants' |
4 | import { UserVideoHistoryModel } from '../../models/account/user-video-history' | 4 | import { UserVideoHistoryModel } from '../../models/account/user-video-history' |
5 | import { CONFIG } from '../../initializers/config' | 5 | import { CONFIG } from '../../initializers/config' |
6 | import { isTestInstance } from '../../helpers/core-utils' | ||
7 | 6 | ||
8 | export class RemoveOldHistoryScheduler extends AbstractScheduler { | 7 | export class RemoveOldHistoryScheduler extends AbstractScheduler { |
9 | 8 | ||
diff --git a/server/middlewares/validators/plugins.ts b/server/middlewares/validators/plugins.ts new file mode 100644 index 000000000..672299ee1 --- /dev/null +++ b/server/middlewares/validators/plugins.ts | |||
@@ -0,0 +1,35 @@ | |||
1 | import * as express from 'express' | ||
2 | import { param } from 'express-validator/check' | ||
3 | import { logger } from '../../helpers/logger' | ||
4 | import { areValidationErrors } from './utils' | ||
5 | import { isPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins' | ||
6 | import { PluginManager } from '../../lib/plugins/plugin-manager' | ||
7 | import { isSafePath } from '../../helpers/custom-validators/misc' | ||
8 | |||
9 | const servePluginStaticDirectoryValidator = [ | ||
10 | param('pluginName').custom(isPluginNameValid).withMessage('Should have a valid plugin name'), | ||
11 | param('pluginVersion').custom(isPluginVersionValid).withMessage('Should have a valid plugin version'), | ||
12 | param('staticEndpoint').custom(isSafePath).withMessage('Should have a valid static endpoint'), | ||
13 | |||
14 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
15 | logger.debug('Checking servePluginStaticDirectory parameters', { parameters: req.params }) | ||
16 | |||
17 | if (areValidationErrors(req, res)) return | ||
18 | |||
19 | const plugin = PluginManager.Instance.getRegisteredPlugin(req.params.pluginName) | ||
20 | |||
21 | if (!plugin || plugin.version !== req.params.pluginVersion) { | ||
22 | return res.sendStatus(404) | ||
23 | } | ||
24 | |||
25 | res.locals.registeredPlugin = plugin | ||
26 | |||
27 | return next() | ||
28 | } | ||
29 | ] | ||
30 | |||
31 | // --------------------------------------------------------------------------- | ||
32 | |||
33 | export { | ||
34 | servePluginStaticDirectoryValidator | ||
35 | } | ||
diff --git a/server/middlewares/validators/themes.ts b/server/middlewares/validators/themes.ts new file mode 100644 index 000000000..642f2df78 --- /dev/null +++ b/server/middlewares/validators/themes.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | import * as express from 'express' | ||
2 | import { param } from 'express-validator/check' | ||
3 | import { logger } from '../../helpers/logger' | ||
4 | import { areValidationErrors } from './utils' | ||
5 | import { isPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins' | ||
6 | import { PluginManager } from '../../lib/plugins/plugin-manager' | ||
7 | import { isSafePath } from '../../helpers/custom-validators/misc' | ||
8 | |||
9 | const serveThemeCSSValidator = [ | ||
10 | param('themeName').custom(isPluginNameValid).withMessage('Should have a valid theme name'), | ||
11 | param('themeVersion').custom(isPluginVersionValid).withMessage('Should have a valid theme version'), | ||
12 | param('staticEndpoint').custom(isSafePath).withMessage('Should have a valid static endpoint'), | ||
13 | |||
14 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
15 | logger.debug('Checking serveThemeCSS parameters', { parameters: req.params }) | ||
16 | |||
17 | if (areValidationErrors(req, res)) return | ||
18 | |||
19 | const theme = PluginManager.Instance.getRegisteredTheme(req.params.themeName) | ||
20 | |||
21 | if (!theme || theme.version !== req.params.themeVersion) { | ||
22 | return res.sendStatus(404) | ||
23 | } | ||
24 | |||
25 | if (theme.css.includes(req.params.staticEndpoint) === false) { | ||
26 | return res.sendStatus(404) | ||
27 | } | ||
28 | |||
29 | res.locals.registeredPlugin = theme | ||
30 | |||
31 | return next() | ||
32 | } | ||
33 | ] | ||
34 | |||
35 | // --------------------------------------------------------------------------- | ||
36 | |||
37 | export { | ||
38 | serveThemeCSSValidator | ||
39 | } | ||
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts new file mode 100644 index 000000000..7ce376d13 --- /dev/null +++ b/server/models/server/plugin.ts | |||
@@ -0,0 +1,79 @@ | |||
1 | import { AllowNull, Column, CreatedAt, DataType, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { throwIfNotValid } from '../utils' | ||
3 | import { | ||
4 | isPluginDescriptionValid, | ||
5 | isPluginNameValid, | ||
6 | isPluginTypeValid, | ||
7 | isPluginVersionValid | ||
8 | } from '../../helpers/custom-validators/plugins' | ||
9 | |||
10 | @Table({ | ||
11 | tableName: 'plugin', | ||
12 | indexes: [ | ||
13 | { | ||
14 | fields: [ 'name' ], | ||
15 | unique: true | ||
16 | } | ||
17 | ] | ||
18 | }) | ||
19 | export class PluginModel extends Model<PluginModel> { | ||
20 | |||
21 | @AllowNull(false) | ||
22 | @Is('PluginName', value => throwIfNotValid(value, isPluginNameValid, 'name')) | ||
23 | @Column | ||
24 | name: string | ||
25 | |||
26 | @AllowNull(false) | ||
27 | @Is('PluginType', value => throwIfNotValid(value, isPluginTypeValid, 'type')) | ||
28 | @Column | ||
29 | type: number | ||
30 | |||
31 | @AllowNull(false) | ||
32 | @Is('PluginVersion', value => throwIfNotValid(value, isPluginVersionValid, 'version')) | ||
33 | @Column | ||
34 | version: string | ||
35 | |||
36 | @AllowNull(false) | ||
37 | @Column | ||
38 | enabled: boolean | ||
39 | |||
40 | @AllowNull(false) | ||
41 | @Column | ||
42 | uninstalled: boolean | ||
43 | |||
44 | @AllowNull(false) | ||
45 | @Is('PluginPeertubeEngine', value => throwIfNotValid(value, isPluginVersionValid, 'peertubeEngine')) | ||
46 | @Column | ||
47 | peertubeEngine: string | ||
48 | |||
49 | @AllowNull(true) | ||
50 | @Is('PluginDescription', value => throwIfNotValid(value, isPluginDescriptionValid, 'description')) | ||
51 | @Column | ||
52 | description: string | ||
53 | |||
54 | @AllowNull(true) | ||
55 | @Column(DataType.JSONB) | ||
56 | settings: any | ||
57 | |||
58 | @AllowNull(true) | ||
59 | @Column(DataType.JSONB) | ||
60 | storage: any | ||
61 | |||
62 | @CreatedAt | ||
63 | createdAt: Date | ||
64 | |||
65 | @UpdatedAt | ||
66 | updatedAt: Date | ||
67 | |||
68 | static listEnabledPluginsAndThemes () { | ||
69 | const query = { | ||
70 | where: { | ||
71 | enabled: true, | ||
72 | uninstalled: false | ||
73 | } | ||
74 | } | ||
75 | |||
76 | return PluginModel.findAll(query) | ||
77 | } | ||
78 | |||
79 | } | ||
diff --git a/server/typings/express.ts b/server/typings/express.ts index 324d78662..aec10b606 100644 --- a/server/typings/express.ts +++ b/server/typings/express.ts | |||
@@ -20,9 +20,11 @@ import { VideoAbuseModel } from '../models/video/video-abuse' | |||
20 | import { VideoBlacklistModel } from '../models/video/video-blacklist' | 20 | import { VideoBlacklistModel } from '../models/video/video-blacklist' |
21 | import { VideoCaptionModel } from '../models/video/video-caption' | 21 | import { VideoCaptionModel } from '../models/video/video-caption' |
22 | import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' | 22 | import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' |
23 | import { RegisteredPlugin } from '../lib/plugins/plugin-manager' | ||
23 | 24 | ||
24 | declare module 'express' { | 25 | declare module 'express' { |
25 | 26 | ||
27 | |||
26 | interface Response { | 28 | interface Response { |
27 | locals: { | 29 | locals: { |
28 | video?: VideoModel | 30 | video?: VideoModel |
@@ -77,6 +79,8 @@ declare module 'express' { | |||
77 | } | 79 | } |
78 | 80 | ||
79 | authenticated?: boolean | 81 | authenticated?: boolean |
82 | |||
83 | registeredPlugin?: RegisteredPlugin | ||
80 | } | 84 | } |
81 | } | 85 | } |
82 | } | 86 | } |
diff --git a/shared/models/plugins/plugin-library.model.ts b/shared/models/plugins/plugin-library.model.ts new file mode 100644 index 000000000..8eb18d720 --- /dev/null +++ b/shared/models/plugins/plugin-library.model.ts | |||
@@ -0,0 +1,6 @@ | |||
1 | import { RegisterOptions } from './register-options.type' | ||
2 | |||
3 | export interface PluginLibrary { | ||
4 | register: (options: RegisterOptions) => void | ||
5 | unregister: () => Promise<any> | ||
6 | } | ||
diff --git a/shared/models/plugins/plugin-package-json.model.ts b/shared/models/plugins/plugin-package-json.model.ts new file mode 100644 index 000000000..4520ee181 --- /dev/null +++ b/shared/models/plugins/plugin-package-json.model.ts | |||
@@ -0,0 +1,15 @@ | |||
1 | export type PluginPackageJson = { | ||
2 | name: string | ||
3 | description: string | ||
4 | engine: { peertube: string }, | ||
5 | |||
6 | homepage: string, | ||
7 | author: string, | ||
8 | bugs: string, | ||
9 | library: string, | ||
10 | |||
11 | staticDirs: { [ name: string ]: string } | ||
12 | css: string[] | ||
13 | |||
14 | clientScripts: { script: string, scopes: string[] }[] | ||
15 | } | ||
diff --git a/shared/models/plugins/plugin.type.ts b/shared/models/plugins/plugin.type.ts new file mode 100644 index 000000000..b6766821a --- /dev/null +++ b/shared/models/plugins/plugin.type.ts | |||
@@ -0,0 +1,4 @@ | |||
1 | export enum PluginType { | ||
2 | PLUGIN = 1, | ||
3 | THEME = 2 | ||
4 | } | ||
diff --git a/shared/models/plugins/register-options.type.ts b/shared/models/plugins/register-options.type.ts new file mode 100644 index 000000000..a074f3931 --- /dev/null +++ b/shared/models/plugins/register-options.type.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | import { RegisterHookOptions } from './register.model' | ||
2 | |||
3 | export type RegisterOptions = { | ||
4 | registerHook: (options: RegisterHookOptions) => void | ||
5 | } | ||
diff --git a/shared/models/plugins/register.model.ts b/shared/models/plugins/register.model.ts new file mode 100644 index 000000000..3817007ae --- /dev/null +++ b/shared/models/plugins/register.model.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export type RegisterHookOptions = { | ||
2 | target: string | ||
3 | handler: Function | ||
4 | priority?: number | ||
5 | } | ||
diff --git a/support/docker/production/config/production.yaml b/support/docker/production/config/production.yaml index ae6bf3982..2ac3c8f44 100644 --- a/support/docker/production/config/production.yaml +++ b/support/docker/production/config/production.yaml | |||
@@ -52,6 +52,7 @@ storage: | |||
52 | torrents: '../data/torrents/' | 52 | torrents: '../data/torrents/' |
53 | captions: '../data/captions/' | 53 | captions: '../data/captions/' |
54 | cache: '../data/cache/' | 54 | cache: '../data/cache/' |
55 | plugins: '../data/plugins/' | ||
55 | 56 | ||
56 | log: | 57 | log: |
57 | level: 'info' # debug/info/warning/error | 58 | level: 'info' # debug/info/warning/error |