aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--config/default.yaml1
-rw-r--r--config/production.yaml.example1
-rw-r--r--server.ts6
-rw-r--r--server/controllers/index.ts2
-rw-r--r--server/controllers/plugins.ts48
-rw-r--r--server/controllers/themes.ts28
-rw-r--r--server/helpers/custom-validators/misc.ts9
-rw-r--r--server/helpers/custom-validators/plugins.ts82
-rw-r--r--server/initializers/checker-before-init.ts2
-rw-r--r--server/initializers/config.ts3
-rw-r--r--server/initializers/constants.ts11
-rw-r--r--server/lib/plugins/plugin-manager.ts169
-rw-r--r--server/lib/schedulers/remove-old-history-scheduler.ts1
-rw-r--r--server/middlewares/validators/plugins.ts35
-rw-r--r--server/middlewares/validators/themes.ts39
-rw-r--r--server/models/server/plugin.ts79
-rw-r--r--server/typings/express.ts4
-rw-r--r--shared/models/plugins/plugin-library.model.ts6
-rw-r--r--shared/models/plugins/plugin-package-json.model.ts15
-rw-r--r--shared/models/plugins/plugin.type.ts4
-rw-r--r--shared/models/plugins/register-options.type.ts5
-rw-r--r--shared/models/plugins/register.model.ts5
-rw-r--r--support/docker/production/config/production.yaml1
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
84log: 85log:
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
85log: 86log:
86 level: 'info' # debug/info/warning/error 87 level: 'info' # debug/info/warning/error
diff --git a/server.ts b/server.ts
index 9f0b123e0..2f5f39db2 100644
--- a/server.ts
+++ b/server.ts
@@ -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...)
174app.use('/services', servicesRouter) 176app.use('/services', servicesRouter)
175 177
178// Plugins & themes
179app.use('/plugins', pluginsRouter)
180app.use('/themes', themesRouter)
181
176app.use('/', activityPubRouter) 182app.use('/', activityPubRouter)
177app.use('/', feedsRouter) 183app.use('/', feedsRouter)
178app.use('/', webfingerRouter) 184app.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'
7export * from './webfinger' 7export * from './webfinger'
8export * from './tracker' 8export * from './tracker'
9export * from './bots' 9export * from './bots'
10export * from './plugins'
11export * 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 @@
1import * as express from 'express'
2import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants'
3import { join } from 'path'
4import { RegisteredPlugin } from '../lib/plugins/plugin-manager'
5import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins'
6
7const pluginsRouter = express.Router()
8
9pluginsRouter.get('/global.css',
10 express.static(PLUGIN_GLOBAL_CSS_PATH, { fallthrough: false })
11)
12
13pluginsRouter.get('/:pluginName/:pluginVersion/statics/:staticEndpoint',
14 servePluginStaticDirectoryValidator,
15 servePluginStaticDirectory
16)
17
18pluginsRouter.get('/:pluginName/:pluginVersion/client-scripts/:staticEndpoint',
19 servePluginStaticDirectoryValidator,
20 servePluginClientScripts
21)
22
23// ---------------------------------------------------------------------------
24
25export {
26 pluginsRouter
27}
28
29// ---------------------------------------------------------------------------
30
31function 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
43function 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 @@
1import * as express from 'express'
2import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants'
3import { join } from 'path'
4import { RegisteredPlugin } from '../lib/plugins/plugin-manager'
5import { servePluginStaticDirectoryValidator } from '../middlewares/validators/plugins'
6import { serveThemeCSSValidator } from '../middlewares/validators/themes'
7
8const themesRouter = express.Router()
9
10themesRouter.get('/:themeName/:themeVersion/css/:staticEndpoint',
11 serveThemeCSSValidator,
12 serveThemeCSSDirectory
13)
14
15// ---------------------------------------------------------------------------
16
17export {
18 themesRouter
19}
20
21// ---------------------------------------------------------------------------
22
23function 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 @@
1import 'multer' 1import 'multer'
2import * as validator from 'validator' 2import * as validator from 'validator'
3import { sep } from 'path'
3 4
4function exists (value: any) { 5function exists (value: any) {
5 return value !== undefined && value !== null 6 return value !== undefined && value !== null
6} 7}
7 8
9function isSafePath (p: string) {
10 return exists(p) &&
11 (p + '').split(sep).every(part => {
12 return [ '', '.', '..' ].includes(part) === false
13 })
14}
15
8function isArray (value: any) { 16function 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 @@
1import { exists, isArray, isSafePath } from './misc'
2import * as validator from 'validator'
3import { PluginType } from '../../../shared/models/plugins/plugin.type'
4import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
5import { PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model'
6import { isUrlValid } from './activitypub/misc'
7
8const PLUGINS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.PLUGINS
9
10function isPluginTypeValid (value: any) {
11 return exists(value) && validator.isInt('' + value) && PluginType[value] !== undefined
12}
13
14function isPluginNameValid (value: string) {
15 return exists(value) &&
16 validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.NAME) &&
17 validator.matches(value, /^[a-z\-]+$/)
18}
19
20function isPluginDescriptionValid (value: string) {
21 return exists(value) && validator.isLength(value, PLUGINS_CONSTRAINTS_FIELDS.DESCRIPTION)
22}
23
24function 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
32function isPluginEngineValid (engine: any) {
33 return exists(engine) && exists(engine.peertube)
34}
35
36function 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
46function isClientScriptsValid (clientScripts: any[]) {
47 return isArray(clientScripts) &&
48 clientScripts.every(c => {
49 return isSafePath(c.script) && isArray(c.scopes)
50 })
51}
52
53function isCSSPathsValid (css: any[]) {
54 return isArray(css) && css.every(c => isSafePath(c))
55}
56
57function 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
70function isLibraryCodeValid (library: any) {
71 return typeof library.register === 'function'
72 && typeof library.unregister === 'function'
73}
74
75export {
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
585const PLUGIN_GLOBAL_CSS_FILE_NAME = 'plugins-global.css'
586const 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
582if (isTestInstance() === true) { 591if (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 @@
1import { PluginModel } from '../../models/server/plugin'
2import { logger } from '../../helpers/logger'
3import { RegisterHookOptions } from '../../../shared/models/plugins/register.model'
4import { join } from 'path'
5import { CONFIG } from '../../initializers/config'
6import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
7import { PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model'
8import { PluginLibrary } from '../../../shared/models/plugins/plugin-library.model'
9import { createReadStream, createWriteStream } from 'fs'
10import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
11import { PluginType } from '../../../shared/models/plugins/plugin.type'
12
13export 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
31export interface HookInformationValue {
32 pluginName: string
33 handler: Function
34 priority: number
35}
36
37export 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'
3import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants' 3import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
4import { UserVideoHistoryModel } from '../../models/account/user-video-history' 4import { UserVideoHistoryModel } from '../../models/account/user-video-history'
5import { CONFIG } from '../../initializers/config' 5import { CONFIG } from '../../initializers/config'
6import { isTestInstance } from '../../helpers/core-utils'
7 6
8export class RemoveOldHistoryScheduler extends AbstractScheduler { 7export 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 @@
1import * as express from 'express'
2import { param } from 'express-validator/check'
3import { logger } from '../../helpers/logger'
4import { areValidationErrors } from './utils'
5import { isPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins'
6import { PluginManager } from '../../lib/plugins/plugin-manager'
7import { isSafePath } from '../../helpers/custom-validators/misc'
8
9const 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
33export {
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 @@
1import * as express from 'express'
2import { param } from 'express-validator/check'
3import { logger } from '../../helpers/logger'
4import { areValidationErrors } from './utils'
5import { isPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins'
6import { PluginManager } from '../../lib/plugins/plugin-manager'
7import { isSafePath } from '../../helpers/custom-validators/misc'
8
9const 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
37export {
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 @@
1import { AllowNull, Column, CreatedAt, DataType, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { throwIfNotValid } from '../utils'
3import {
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})
19export 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'
20import { VideoBlacklistModel } from '../models/video/video-blacklist' 20import { VideoBlacklistModel } from '../models/video/video-blacklist'
21import { VideoCaptionModel } from '../models/video/video-caption' 21import { VideoCaptionModel } from '../models/video/video-caption'
22import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 22import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
23import { RegisteredPlugin } from '../lib/plugins/plugin-manager'
23 24
24declare module 'express' { 25declare 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 @@
1import { RegisterOptions } from './register-options.type'
2
3export 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 @@
1export 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 @@
1export 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 @@
1import { RegisterHookOptions } from './register.model'
2
3export 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 @@
1export 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
56log: 57log:
57 level: 'info' # debug/info/warning/error 58 level: 'info' # debug/info/warning/error