diff options
Diffstat (limited to 'server/lib/plugins')
-rw-r--r-- | server/lib/plugins/hooks.ts | 35 | ||||
-rw-r--r-- | server/lib/plugins/plugin-helpers-builder.ts | 262 | ||||
-rw-r--r-- | server/lib/plugins/plugin-index.ts | 85 | ||||
-rw-r--r-- | server/lib/plugins/plugin-manager.ts | 665 | ||||
-rw-r--r-- | server/lib/plugins/register-helpers.ts | 340 | ||||
-rw-r--r-- | server/lib/plugins/theme-utils.ts | 24 | ||||
-rw-r--r-- | server/lib/plugins/video-constant-manager-factory.ts | 139 | ||||
-rw-r--r-- | server/lib/plugins/yarn.ts | 73 |
8 files changed, 0 insertions, 1623 deletions
diff --git a/server/lib/plugins/hooks.ts b/server/lib/plugins/hooks.ts deleted file mode 100644 index 694527c12..000000000 --- a/server/lib/plugins/hooks.ts +++ /dev/null | |||
@@ -1,35 +0,0 @@ | |||
1 | import Bluebird from 'bluebird' | ||
2 | import { ServerActionHookName, ServerFilterHookName } from '../../../shared/models' | ||
3 | import { logger } from '../../helpers/logger' | ||
4 | import { PluginManager } from './plugin-manager' | ||
5 | |||
6 | type PromiseFunction <U, T> = (params: U) => Promise<T> | Bluebird<T> | ||
7 | type RawFunction <U, T> = (params: U) => T | ||
8 | |||
9 | // Helpers to run hooks | ||
10 | const Hooks = { | ||
11 | wrapObject: <T, U extends ServerFilterHookName>(result: T, hookName: U, context?: any) => { | ||
12 | return PluginManager.Instance.runHook(hookName, result, context) | ||
13 | }, | ||
14 | |||
15 | wrapPromiseFun: async <U, T, V extends ServerFilterHookName>(fun: PromiseFunction<U, T>, params: U, hookName: V) => { | ||
16 | const result = await fun(params) | ||
17 | |||
18 | return PluginManager.Instance.runHook(hookName, result, params) | ||
19 | }, | ||
20 | |||
21 | wrapFun: async <U, T, V extends ServerFilterHookName>(fun: RawFunction<U, T>, params: U, hookName: V) => { | ||
22 | const result = fun(params) | ||
23 | |||
24 | return PluginManager.Instance.runHook(hookName, result, params) | ||
25 | }, | ||
26 | |||
27 | runAction: <T, U extends ServerActionHookName>(hookName: U, params?: T) => { | ||
28 | PluginManager.Instance.runHook(hookName, undefined, params) | ||
29 | .catch(err => logger.error('Fatal hook error.', { err })) | ||
30 | } | ||
31 | } | ||
32 | |||
33 | export { | ||
34 | Hooks | ||
35 | } | ||
diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts deleted file mode 100644 index b4e3eece4..000000000 --- a/server/lib/plugins/plugin-helpers-builder.ts +++ /dev/null | |||
@@ -1,262 +0,0 @@ | |||
1 | import express from 'express' | ||
2 | import { Server } from 'http' | ||
3 | import { join } from 'path' | ||
4 | import { buildLogger } from '@server/helpers/logger' | ||
5 | import { CONFIG } from '@server/initializers/config' | ||
6 | import { WEBSERVER } from '@server/initializers/constants' | ||
7 | import { sequelizeTypescript } from '@server/initializers/database' | ||
8 | import { AccountModel } from '@server/models/account/account' | ||
9 | import { AccountBlocklistModel } from '@server/models/account/account-blocklist' | ||
10 | import { getServerActor } from '@server/models/application/application' | ||
11 | import { ServerModel } from '@server/models/server/server' | ||
12 | import { ServerBlocklistModel } from '@server/models/server/server-blocklist' | ||
13 | import { UserModel } from '@server/models/user/user' | ||
14 | import { VideoModel } from '@server/models/video/video' | ||
15 | import { VideoBlacklistModel } from '@server/models/video/video-blacklist' | ||
16 | import { MPlugin, MVideo, UserNotificationModelForApi } from '@server/types/models' | ||
17 | import { PeerTubeHelpers } from '@server/types/plugins' | ||
18 | import { ffprobePromise } from '@shared/ffmpeg' | ||
19 | import { VideoBlacklistCreate, VideoStorage } from '@shared/models' | ||
20 | import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist' | ||
21 | import { PeerTubeSocket } from '../peertube-socket' | ||
22 | import { ServerConfigManager } from '../server-config-manager' | ||
23 | import { blacklistVideo, unblacklistVideo } from '../video-blacklist' | ||
24 | import { VideoPathManager } from '../video-path-manager' | ||
25 | |||
26 | function buildPluginHelpers (httpServer: Server, pluginModel: MPlugin, npmName: string): PeerTubeHelpers { | ||
27 | const logger = buildPluginLogger(npmName) | ||
28 | |||
29 | const database = buildDatabaseHelpers() | ||
30 | const videos = buildVideosHelpers() | ||
31 | |||
32 | const config = buildConfigHelpers() | ||
33 | |||
34 | const server = buildServerHelpers(httpServer) | ||
35 | |||
36 | const moderation = buildModerationHelpers() | ||
37 | |||
38 | const plugin = buildPluginRelatedHelpers(pluginModel, npmName) | ||
39 | |||
40 | const socket = buildSocketHelpers() | ||
41 | |||
42 | const user = buildUserHelpers() | ||
43 | |||
44 | return { | ||
45 | logger, | ||
46 | database, | ||
47 | videos, | ||
48 | config, | ||
49 | moderation, | ||
50 | plugin, | ||
51 | server, | ||
52 | socket, | ||
53 | user | ||
54 | } | ||
55 | } | ||
56 | |||
57 | export { | ||
58 | buildPluginHelpers | ||
59 | } | ||
60 | |||
61 | // --------------------------------------------------------------------------- | ||
62 | |||
63 | function buildPluginLogger (npmName: string) { | ||
64 | return buildLogger(npmName) | ||
65 | } | ||
66 | |||
67 | function buildDatabaseHelpers () { | ||
68 | return { | ||
69 | query: sequelizeTypescript.query.bind(sequelizeTypescript) | ||
70 | } | ||
71 | } | ||
72 | |||
73 | function buildServerHelpers (httpServer: Server) { | ||
74 | return { | ||
75 | getHTTPServer: () => httpServer, | ||
76 | |||
77 | getServerActor: () => getServerActor() | ||
78 | } | ||
79 | } | ||
80 | |||
81 | function buildVideosHelpers () { | ||
82 | return { | ||
83 | loadByUrl: (url: string) => { | ||
84 | return VideoModel.loadByUrl(url) | ||
85 | }, | ||
86 | |||
87 | loadByIdOrUUID: (id: number | string) => { | ||
88 | return VideoModel.load(id) | ||
89 | }, | ||
90 | |||
91 | removeVideo: (id: number) => { | ||
92 | return sequelizeTypescript.transaction(async t => { | ||
93 | const video = await VideoModel.loadFull(id, t) | ||
94 | |||
95 | await video.destroy({ transaction: t }) | ||
96 | }) | ||
97 | }, | ||
98 | |||
99 | ffprobe: (path: string) => { | ||
100 | return ffprobePromise(path) | ||
101 | }, | ||
102 | |||
103 | getFiles: async (id: number | string) => { | ||
104 | const video = await VideoModel.loadFull(id) | ||
105 | if (!video) return undefined | ||
106 | |||
107 | const webVideoFiles = (video.VideoFiles || []).map(f => ({ | ||
108 | path: f.storage === VideoStorage.FILE_SYSTEM | ||
109 | ? VideoPathManager.Instance.getFSVideoFileOutputPath(video, f) | ||
110 | : null, | ||
111 | url: f.getFileUrl(video), | ||
112 | |||
113 | resolution: f.resolution, | ||
114 | size: f.size, | ||
115 | fps: f.fps | ||
116 | })) | ||
117 | |||
118 | const hls = video.getHLSPlaylist() | ||
119 | |||
120 | const hlsVideoFiles = hls | ||
121 | ? (video.getHLSPlaylist().VideoFiles || []).map(f => { | ||
122 | return { | ||
123 | path: f.storage === VideoStorage.FILE_SYSTEM | ||
124 | ? VideoPathManager.Instance.getFSVideoFileOutputPath(hls, f) | ||
125 | : null, | ||
126 | url: f.getFileUrl(video), | ||
127 | resolution: f.resolution, | ||
128 | size: f.size, | ||
129 | fps: f.fps | ||
130 | } | ||
131 | }) | ||
132 | : [] | ||
133 | |||
134 | const thumbnails = video.Thumbnails.map(t => ({ | ||
135 | type: t.type, | ||
136 | url: t.getOriginFileUrl(video), | ||
137 | path: t.getPath() | ||
138 | })) | ||
139 | |||
140 | return { | ||
141 | webtorrent: { // TODO: remove in v7 | ||
142 | videoFiles: webVideoFiles | ||
143 | }, | ||
144 | |||
145 | webVideo: { | ||
146 | videoFiles: webVideoFiles | ||
147 | }, | ||
148 | |||
149 | hls: { | ||
150 | videoFiles: hlsVideoFiles | ||
151 | }, | ||
152 | |||
153 | thumbnails | ||
154 | } | ||
155 | } | ||
156 | } | ||
157 | } | ||
158 | |||
159 | function buildModerationHelpers () { | ||
160 | return { | ||
161 | blockServer: async (options: { byAccountId: number, hostToBlock: string }) => { | ||
162 | const serverToBlock = await ServerModel.loadOrCreateByHost(options.hostToBlock) | ||
163 | |||
164 | await addServerInBlocklist(options.byAccountId, serverToBlock.id) | ||
165 | }, | ||
166 | |||
167 | unblockServer: async (options: { byAccountId: number, hostToUnblock: string }) => { | ||
168 | const serverBlock = await ServerBlocklistModel.loadByAccountAndHost(options.byAccountId, options.hostToUnblock) | ||
169 | if (!serverBlock) return | ||
170 | |||
171 | await removeServerFromBlocklist(serverBlock) | ||
172 | }, | ||
173 | |||
174 | blockAccount: async (options: { byAccountId: number, handleToBlock: string }) => { | ||
175 | const accountToBlock = await AccountModel.loadByNameWithHost(options.handleToBlock) | ||
176 | if (!accountToBlock) return | ||
177 | |||
178 | await addAccountInBlocklist(options.byAccountId, accountToBlock.id) | ||
179 | }, | ||
180 | |||
181 | unblockAccount: async (options: { byAccountId: number, handleToUnblock: string }) => { | ||
182 | const targetAccount = await AccountModel.loadByNameWithHost(options.handleToUnblock) | ||
183 | if (!targetAccount) return | ||
184 | |||
185 | const accountBlock = await AccountBlocklistModel.loadByAccountAndTarget(options.byAccountId, targetAccount.id) | ||
186 | if (!accountBlock) return | ||
187 | |||
188 | await removeAccountFromBlocklist(accountBlock) | ||
189 | }, | ||
190 | |||
191 | blacklistVideo: async (options: { videoIdOrUUID: number | string, createOptions: VideoBlacklistCreate }) => { | ||
192 | const video = await VideoModel.loadFull(options.videoIdOrUUID) | ||
193 | if (!video) return | ||
194 | |||
195 | await blacklistVideo(video, options.createOptions) | ||
196 | }, | ||
197 | |||
198 | unblacklistVideo: async (options: { videoIdOrUUID: number | string }) => { | ||
199 | const video = await VideoModel.loadFull(options.videoIdOrUUID) | ||
200 | if (!video) return | ||
201 | |||
202 | const videoBlacklist = await VideoBlacklistModel.loadByVideoId(video.id) | ||
203 | if (!videoBlacklist) return | ||
204 | |||
205 | await unblacklistVideo(videoBlacklist, video) | ||
206 | } | ||
207 | } | ||
208 | } | ||
209 | |||
210 | function buildConfigHelpers () { | ||
211 | return { | ||
212 | getWebserverUrl () { | ||
213 | return WEBSERVER.URL | ||
214 | }, | ||
215 | |||
216 | getServerListeningConfig () { | ||
217 | return { hostname: CONFIG.LISTEN.HOSTNAME, port: CONFIG.LISTEN.PORT } | ||
218 | }, | ||
219 | |||
220 | getServerConfig () { | ||
221 | return ServerConfigManager.Instance.getServerConfig() | ||
222 | } | ||
223 | } | ||
224 | } | ||
225 | |||
226 | function buildPluginRelatedHelpers (plugin: MPlugin, npmName: string) { | ||
227 | return { | ||
228 | getBaseStaticRoute: () => `/plugins/${plugin.name}/${plugin.version}/static/`, | ||
229 | |||
230 | getBaseRouterRoute: () => `/plugins/${plugin.name}/${plugin.version}/router/`, | ||
231 | |||
232 | getBaseWebSocketRoute: () => `/plugins/${plugin.name}/${plugin.version}/ws/`, | ||
233 | |||
234 | getDataDirectoryPath: () => join(CONFIG.STORAGE.PLUGINS_DIR, 'data', npmName) | ||
235 | } | ||
236 | } | ||
237 | |||
238 | function buildSocketHelpers () { | ||
239 | return { | ||
240 | sendNotification: (userId: number, notification: UserNotificationModelForApi) => { | ||
241 | PeerTubeSocket.Instance.sendNotification(userId, notification) | ||
242 | }, | ||
243 | sendVideoLiveNewState: (video: MVideo) => { | ||
244 | PeerTubeSocket.Instance.sendVideoLiveNewState(video) | ||
245 | } | ||
246 | } | ||
247 | } | ||
248 | |||
249 | function buildUserHelpers () { | ||
250 | return { | ||
251 | loadById: (id: number) => { | ||
252 | return UserModel.loadByIdFull(id) | ||
253 | }, | ||
254 | |||
255 | getAuthUser: (res: express.Response) => { | ||
256 | const user = res.locals.oauth?.token?.User || res.locals.videoFileToken?.user | ||
257 | if (!user) return undefined | ||
258 | |||
259 | return UserModel.loadByIdFull(user.id) | ||
260 | } | ||
261 | } | ||
262 | } | ||
diff --git a/server/lib/plugins/plugin-index.ts b/server/lib/plugins/plugin-index.ts deleted file mode 100644 index 119cee8e0..000000000 --- a/server/lib/plugins/plugin-index.ts +++ /dev/null | |||
@@ -1,85 +0,0 @@ | |||
1 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { doJSONRequest } from '@server/helpers/requests' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { PEERTUBE_VERSION } from '@server/initializers/constants' | ||
6 | import { PluginModel } from '@server/models/server/plugin' | ||
7 | import { | ||
8 | PeerTubePluginIndex, | ||
9 | PeertubePluginIndexList, | ||
10 | PeertubePluginLatestVersionRequest, | ||
11 | PeertubePluginLatestVersionResponse, | ||
12 | ResultList | ||
13 | } from '@shared/models' | ||
14 | import { PluginManager } from './plugin-manager' | ||
15 | |||
16 | async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) { | ||
17 | const { start = 0, count = 20, search, sort = 'npmName', pluginType } = options | ||
18 | |||
19 | const searchParams: PeertubePluginIndexList & Record<string, string | number> = { | ||
20 | start, | ||
21 | count, | ||
22 | sort, | ||
23 | pluginType, | ||
24 | search, | ||
25 | currentPeerTubeEngine: options.currentPeerTubeEngine || PEERTUBE_VERSION | ||
26 | } | ||
27 | |||
28 | const uri = CONFIG.PLUGINS.INDEX.URL + '/api/v1/plugins' | ||
29 | |||
30 | try { | ||
31 | const { body } = await doJSONRequest<any>(uri, { searchParams }) | ||
32 | |||
33 | logger.debug('Got result from PeerTube index.', { body }) | ||
34 | |||
35 | addInstanceInformation(body) | ||
36 | |||
37 | return body as ResultList<PeerTubePluginIndex> | ||
38 | } catch (err) { | ||
39 | logger.error('Cannot list available plugins from index %s.', uri, { err }) | ||
40 | return undefined | ||
41 | } | ||
42 | } | ||
43 | |||
44 | function addInstanceInformation (result: ResultList<PeerTubePluginIndex>) { | ||
45 | for (const d of result.data) { | ||
46 | d.installed = PluginManager.Instance.isRegistered(d.npmName) | ||
47 | d.name = PluginModel.normalizePluginName(d.npmName) | ||
48 | } | ||
49 | |||
50 | return result | ||
51 | } | ||
52 | |||
53 | async function getLatestPluginsVersion (npmNames: string[]): Promise<PeertubePluginLatestVersionResponse> { | ||
54 | const bodyRequest: PeertubePluginLatestVersionRequest = { | ||
55 | npmNames, | ||
56 | currentPeerTubeEngine: PEERTUBE_VERSION | ||
57 | } | ||
58 | |||
59 | const uri = sanitizeUrl(CONFIG.PLUGINS.INDEX.URL) + '/api/v1/plugins/latest-version' | ||
60 | |||
61 | const options = { | ||
62 | json: bodyRequest, | ||
63 | method: 'POST' as 'POST' | ||
64 | } | ||
65 | const { body } = await doJSONRequest<PeertubePluginLatestVersionResponse>(uri, options) | ||
66 | |||
67 | return body | ||
68 | } | ||
69 | |||
70 | async function getLatestPluginVersion (npmName: string) { | ||
71 | const results = await getLatestPluginsVersion([ npmName ]) | ||
72 | |||
73 | if (Array.isArray(results) === false || results.length !== 1) { | ||
74 | logger.warn('Cannot get latest supported plugin version of %s.', npmName) | ||
75 | return undefined | ||
76 | } | ||
77 | |||
78 | return results[0].latestVersion | ||
79 | } | ||
80 | |||
81 | export { | ||
82 | listAvailablePluginsFromIndex, | ||
83 | getLatestPluginVersion, | ||
84 | getLatestPluginsVersion | ||
85 | } | ||
diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts deleted file mode 100644 index 88c5b60d7..000000000 --- a/server/lib/plugins/plugin-manager.ts +++ /dev/null | |||
@@ -1,665 +0,0 @@ | |||
1 | import express from 'express' | ||
2 | import { createReadStream, createWriteStream } from 'fs' | ||
3 | import { ensureDir, outputFile, readJSON } from 'fs-extra' | ||
4 | import { Server } from 'http' | ||
5 | import { basename, join } from 'path' | ||
6 | import { decachePlugin } from '@server/helpers/decache' | ||
7 | import { ApplicationModel } from '@server/models/application/application' | ||
8 | import { MOAuthTokenUser, MUser } from '@server/types/models' | ||
9 | import { getCompleteLocale } from '@shared/core-utils' | ||
10 | import { | ||
11 | ClientScriptJSON, | ||
12 | PluginPackageJSON, | ||
13 | PluginTranslation, | ||
14 | PluginTranslationPathsJSON, | ||
15 | RegisterServerHookOptions | ||
16 | } from '@shared/models' | ||
17 | import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks' | ||
18 | import { PluginType } from '../../../shared/models/plugins/plugin.type' | ||
19 | import { ServerHook, ServerHookName } from '../../../shared/models/plugins/server/server-hook.model' | ||
20 | import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins' | ||
21 | import { logger } from '../../helpers/logger' | ||
22 | import { CONFIG } from '../../initializers/config' | ||
23 | import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants' | ||
24 | import { PluginModel } from '../../models/server/plugin' | ||
25 | import { PluginLibrary, RegisterServerAuthExternalOptions, RegisterServerAuthPassOptions, RegisterServerOptions } from '../../types/plugins' | ||
26 | import { ClientHtml } from '../client-html' | ||
27 | import { RegisterHelpers } from './register-helpers' | ||
28 | import { installNpmPlugin, installNpmPluginFromDisk, rebuildNativePlugins, removeNpmPlugin } from './yarn' | ||
29 | |||
30 | export interface RegisteredPlugin { | ||
31 | npmName: string | ||
32 | name: string | ||
33 | version: string | ||
34 | description: string | ||
35 | peertubeEngine: string | ||
36 | |||
37 | type: PluginType | ||
38 | |||
39 | path: string | ||
40 | |||
41 | staticDirs: { [name: string]: string } | ||
42 | clientScripts: { [name: string]: ClientScriptJSON } | ||
43 | |||
44 | css: string[] | ||
45 | |||
46 | // Only if this is a plugin | ||
47 | registerHelpers?: RegisterHelpers | ||
48 | unregister?: Function | ||
49 | } | ||
50 | |||
51 | export interface HookInformationValue { | ||
52 | npmName: string | ||
53 | pluginName: string | ||
54 | handler: Function | ||
55 | priority: number | ||
56 | } | ||
57 | |||
58 | type PluginLocalesTranslations = { | ||
59 | [locale: string]: PluginTranslation | ||
60 | } | ||
61 | |||
62 | export class PluginManager implements ServerHook { | ||
63 | |||
64 | private static instance: PluginManager | ||
65 | |||
66 | private registeredPlugins: { [name: string]: RegisteredPlugin } = {} | ||
67 | |||
68 | private hooks: { [name: string]: HookInformationValue[] } = {} | ||
69 | private translations: PluginLocalesTranslations = {} | ||
70 | |||
71 | private server: Server | ||
72 | |||
73 | private constructor () { | ||
74 | } | ||
75 | |||
76 | init (server: Server) { | ||
77 | this.server = server | ||
78 | } | ||
79 | |||
80 | registerWebSocketRouter () { | ||
81 | this.server.on('upgrade', (request, socket, head) => { | ||
82 | // Check if it's a plugin websocket connection | ||
83 | // No need to destroy the stream when we abort the request | ||
84 | // Other handlers in PeerTube will catch this upgrade event too (socket.io, tracker etc) | ||
85 | |||
86 | const url = request.url | ||
87 | |||
88 | const matched = url.match(`/plugins/([^/]+)/([^/]+/)?ws(/.*)`) | ||
89 | if (!matched) return | ||
90 | |||
91 | const npmName = PluginModel.buildNpmName(matched[1], PluginType.PLUGIN) | ||
92 | const subRoute = matched[3] | ||
93 | |||
94 | const result = this.getRegisteredPluginOrTheme(npmName) | ||
95 | if (!result) return | ||
96 | |||
97 | const routes = result.registerHelpers.getWebSocketRoutes() | ||
98 | |||
99 | const wss = routes.find(r => r.route.startsWith(subRoute)) | ||
100 | if (!wss) return | ||
101 | |||
102 | try { | ||
103 | wss.handler(request, socket, head) | ||
104 | } catch (err) { | ||
105 | logger.error('Exception in plugin handler ' + npmName, { err }) | ||
106 | } | ||
107 | }) | ||
108 | } | ||
109 | |||
110 | // ###################### Getters ###################### | ||
111 | |||
112 | isRegistered (npmName: string) { | ||
113 | return !!this.getRegisteredPluginOrTheme(npmName) | ||
114 | } | ||
115 | |||
116 | getRegisteredPluginOrTheme (npmName: string) { | ||
117 | return this.registeredPlugins[npmName] | ||
118 | } | ||
119 | |||
120 | getRegisteredPluginByShortName (name: string) { | ||
121 | const npmName = PluginModel.buildNpmName(name, PluginType.PLUGIN) | ||
122 | const registered = this.getRegisteredPluginOrTheme(npmName) | ||
123 | |||
124 | if (!registered || registered.type !== PluginType.PLUGIN) return undefined | ||
125 | |||
126 | return registered | ||
127 | } | ||
128 | |||
129 | getRegisteredThemeByShortName (name: string) { | ||
130 | const npmName = PluginModel.buildNpmName(name, PluginType.THEME) | ||
131 | const registered = this.getRegisteredPluginOrTheme(npmName) | ||
132 | |||
133 | if (!registered || registered.type !== PluginType.THEME) return undefined | ||
134 | |||
135 | return registered | ||
136 | } | ||
137 | |||
138 | getRegisteredPlugins () { | ||
139 | return this.getRegisteredPluginsOrThemes(PluginType.PLUGIN) | ||
140 | } | ||
141 | |||
142 | getRegisteredThemes () { | ||
143 | return this.getRegisteredPluginsOrThemes(PluginType.THEME) | ||
144 | } | ||
145 | |||
146 | getIdAndPassAuths () { | ||
147 | return this.getRegisteredPlugins() | ||
148 | .map(p => ({ | ||
149 | npmName: p.npmName, | ||
150 | name: p.name, | ||
151 | version: p.version, | ||
152 | idAndPassAuths: p.registerHelpers.getIdAndPassAuths() | ||
153 | })) | ||
154 | .filter(v => v.idAndPassAuths.length !== 0) | ||
155 | } | ||
156 | |||
157 | getExternalAuths () { | ||
158 | return this.getRegisteredPlugins() | ||
159 | .map(p => ({ | ||
160 | npmName: p.npmName, | ||
161 | name: p.name, | ||
162 | version: p.version, | ||
163 | externalAuths: p.registerHelpers.getExternalAuths() | ||
164 | })) | ||
165 | .filter(v => v.externalAuths.length !== 0) | ||
166 | } | ||
167 | |||
168 | getRegisteredSettings (npmName: string) { | ||
169 | const result = this.getRegisteredPluginOrTheme(npmName) | ||
170 | if (!result || result.type !== PluginType.PLUGIN) return [] | ||
171 | |||
172 | return result.registerHelpers.getSettings() | ||
173 | } | ||
174 | |||
175 | getRouter (npmName: string) { | ||
176 | const result = this.getRegisteredPluginOrTheme(npmName) | ||
177 | if (!result || result.type !== PluginType.PLUGIN) return null | ||
178 | |||
179 | return result.registerHelpers.getRouter() | ||
180 | } | ||
181 | |||
182 | getTranslations (locale: string) { | ||
183 | return this.translations[locale] || {} | ||
184 | } | ||
185 | |||
186 | async isTokenValid (token: MOAuthTokenUser, type: 'access' | 'refresh') { | ||
187 | const auth = this.getAuth(token.User.pluginAuth, token.authName) | ||
188 | if (!auth) return true | ||
189 | |||
190 | if (auth.hookTokenValidity) { | ||
191 | try { | ||
192 | const { valid } = await auth.hookTokenValidity({ token, type }) | ||
193 | |||
194 | if (valid === false) { | ||
195 | logger.info('Rejecting %s token validity from auth %s of plugin %s', type, token.authName, token.User.pluginAuth) | ||
196 | } | ||
197 | |||
198 | return valid | ||
199 | } catch (err) { | ||
200 | logger.warn('Cannot run check token validity from auth %s of plugin %s.', token.authName, token.User.pluginAuth, { err }) | ||
201 | return true | ||
202 | } | ||
203 | } | ||
204 | |||
205 | return true | ||
206 | } | ||
207 | |||
208 | // ###################### External events ###################### | ||
209 | |||
210 | async onLogout (npmName: string, authName: string, user: MUser, req: express.Request) { | ||
211 | const auth = this.getAuth(npmName, authName) | ||
212 | |||
213 | if (auth?.onLogout) { | ||
214 | logger.info('Running onLogout function from auth %s of plugin %s', authName, npmName) | ||
215 | |||
216 | try { | ||
217 | // Force await, in case or onLogout returns a promise | ||
218 | const result = await auth.onLogout(user, req) | ||
219 | |||
220 | return typeof result === 'string' | ||
221 | ? result | ||
222 | : undefined | ||
223 | } catch (err) { | ||
224 | logger.warn('Cannot run onLogout function from auth %s of plugin %s.', authName, npmName, { err }) | ||
225 | } | ||
226 | } | ||
227 | |||
228 | return undefined | ||
229 | } | ||
230 | |||
231 | async onSettingsChanged (name: string, settings: any) { | ||
232 | const registered = this.getRegisteredPluginByShortName(name) | ||
233 | if (!registered) { | ||
234 | logger.error('Cannot find plugin %s to call on settings changed.', name) | ||
235 | } | ||
236 | |||
237 | for (const cb of registered.registerHelpers.getOnSettingsChangedCallbacks()) { | ||
238 | try { | ||
239 | await cb(settings) | ||
240 | } catch (err) { | ||
241 | logger.error('Cannot run on settings changed callback for %s.', registered.npmName, { err }) | ||
242 | } | ||
243 | } | ||
244 | } | ||
245 | |||
246 | // ###################### Hooks ###################### | ||
247 | |||
248 | async runHook<T> (hookName: ServerHookName, result?: T, params?: any): Promise<T> { | ||
249 | if (!this.hooks[hookName]) return Promise.resolve(result) | ||
250 | |||
251 | const hookType = getHookType(hookName) | ||
252 | |||
253 | for (const hook of this.hooks[hookName]) { | ||
254 | logger.debug('Running hook %s of plugin %s.', hookName, hook.npmName) | ||
255 | |||
256 | result = await internalRunHook({ | ||
257 | handler: hook.handler, | ||
258 | hookType, | ||
259 | result, | ||
260 | params, | ||
261 | onError: err => { logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err }) } | ||
262 | }) | ||
263 | } | ||
264 | |||
265 | return result | ||
266 | } | ||
267 | |||
268 | // ###################### Registration ###################### | ||
269 | |||
270 | async registerPluginsAndThemes () { | ||
271 | await this.resetCSSGlobalFile() | ||
272 | |||
273 | const plugins = await PluginModel.listEnabledPluginsAndThemes() | ||
274 | |||
275 | for (const plugin of plugins) { | ||
276 | try { | ||
277 | await this.registerPluginOrTheme(plugin) | ||
278 | } catch (err) { | ||
279 | // Try to unregister the plugin | ||
280 | try { | ||
281 | await this.unregister(PluginModel.buildNpmName(plugin.name, plugin.type)) | ||
282 | } catch { | ||
283 | // we don't care if we cannot unregister it | ||
284 | } | ||
285 | |||
286 | logger.error('Cannot register plugin %s, skipping.', plugin.name, { err }) | ||
287 | } | ||
288 | } | ||
289 | |||
290 | this.sortHooksByPriority() | ||
291 | } | ||
292 | |||
293 | // Don't need the plugin type since themes cannot register server code | ||
294 | async unregister (npmName: string) { | ||
295 | logger.info('Unregister plugin %s.', npmName) | ||
296 | |||
297 | const plugin = this.getRegisteredPluginOrTheme(npmName) | ||
298 | |||
299 | if (!plugin) { | ||
300 | throw new Error(`Unknown plugin ${npmName} to unregister`) | ||
301 | } | ||
302 | |||
303 | delete this.registeredPlugins[plugin.npmName] | ||
304 | |||
305 | this.deleteTranslations(plugin.npmName) | ||
306 | |||
307 | if (plugin.type === PluginType.PLUGIN) { | ||
308 | await plugin.unregister() | ||
309 | |||
310 | // Remove hooks of this plugin | ||
311 | for (const key of Object.keys(this.hooks)) { | ||
312 | this.hooks[key] = this.hooks[key].filter(h => h.npmName !== npmName) | ||
313 | } | ||
314 | |||
315 | const store = plugin.registerHelpers | ||
316 | store.reinitVideoConstants(plugin.npmName) | ||
317 | store.reinitTranscodingProfilesAndEncoders(plugin.npmName) | ||
318 | |||
319 | logger.info('Regenerating registered plugin CSS to global file.') | ||
320 | await this.regeneratePluginGlobalCSS() | ||
321 | } | ||
322 | |||
323 | ClientHtml.invalidCache() | ||
324 | } | ||
325 | |||
326 | // ###################### Installation ###################### | ||
327 | |||
328 | async install (options: { | ||
329 | toInstall: string | ||
330 | version?: string | ||
331 | fromDisk?: boolean // default false | ||
332 | register?: boolean // default true | ||
333 | }) { | ||
334 | const { toInstall, version, fromDisk = false, register = true } = options | ||
335 | |||
336 | let plugin: PluginModel | ||
337 | let npmName: string | ||
338 | |||
339 | logger.info('Installing plugin %s.', toInstall) | ||
340 | |||
341 | try { | ||
342 | fromDisk | ||
343 | ? await installNpmPluginFromDisk(toInstall) | ||
344 | : await installNpmPlugin(toInstall, version) | ||
345 | |||
346 | npmName = fromDisk ? basename(toInstall) : toInstall | ||
347 | const pluginType = PluginModel.getTypeFromNpmName(npmName) | ||
348 | const pluginName = PluginModel.normalizePluginName(npmName) | ||
349 | |||
350 | const packageJSON = await this.getPackageJSON(pluginName, pluginType) | ||
351 | |||
352 | this.sanitizeAndCheckPackageJSONOrThrow(packageJSON, pluginType); | ||
353 | |||
354 | [ plugin ] = await PluginModel.upsert({ | ||
355 | name: pluginName, | ||
356 | description: packageJSON.description, | ||
357 | homepage: packageJSON.homepage, | ||
358 | type: pluginType, | ||
359 | version: packageJSON.version, | ||
360 | enabled: true, | ||
361 | uninstalled: false, | ||
362 | peertubeEngine: packageJSON.engine.peertube | ||
363 | }, { returning: true }) | ||
364 | |||
365 | logger.info('Successful installation of plugin %s.', toInstall) | ||
366 | |||
367 | if (register) { | ||
368 | await this.registerPluginOrTheme(plugin) | ||
369 | } | ||
370 | } catch (rootErr) { | ||
371 | logger.error('Cannot install plugin %s, removing it...', toInstall, { err: rootErr }) | ||
372 | |||
373 | if (npmName) { | ||
374 | try { | ||
375 | await this.uninstall({ npmName }) | ||
376 | } catch (err) { | ||
377 | logger.error('Cannot uninstall plugin %s after failed installation.', toInstall, { err }) | ||
378 | |||
379 | try { | ||
380 | await removeNpmPlugin(npmName) | ||
381 | } catch (err) { | ||
382 | logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err }) | ||
383 | } | ||
384 | } | ||
385 | } | ||
386 | |||
387 | throw rootErr | ||
388 | } | ||
389 | |||
390 | return plugin | ||
391 | } | ||
392 | |||
393 | async update (toUpdate: string, fromDisk = false) { | ||
394 | const npmName = fromDisk ? basename(toUpdate) : toUpdate | ||
395 | |||
396 | logger.info('Updating plugin %s.', npmName) | ||
397 | |||
398 | // Use the latest version from DB, to not upgrade to a version that does not support our PeerTube version | ||
399 | let version: string | ||
400 | if (!fromDisk) { | ||
401 | const plugin = await PluginModel.loadByNpmName(toUpdate) | ||
402 | version = plugin.latestVersion | ||
403 | } | ||
404 | |||
405 | // Unregister old hooks | ||
406 | await this.unregister(npmName) | ||
407 | |||
408 | return this.install({ toInstall: toUpdate, version, fromDisk }) | ||
409 | } | ||
410 | |||
411 | async uninstall (options: { | ||
412 | npmName: string | ||
413 | unregister?: boolean // default true | ||
414 | }) { | ||
415 | const { npmName, unregister = true } = options | ||
416 | |||
417 | logger.info('Uninstalling plugin %s.', npmName) | ||
418 | |||
419 | if (unregister) { | ||
420 | try { | ||
421 | await this.unregister(npmName) | ||
422 | } catch (err) { | ||
423 | logger.warn('Cannot unregister plugin %s.', npmName, { err }) | ||
424 | } | ||
425 | } | ||
426 | |||
427 | const plugin = await PluginModel.loadByNpmName(npmName) | ||
428 | if (!plugin || plugin.uninstalled === true) { | ||
429 | logger.error('Cannot uninstall plugin %s: it does not exist or is already uninstalled.', npmName) | ||
430 | return | ||
431 | } | ||
432 | |||
433 | plugin.enabled = false | ||
434 | plugin.uninstalled = true | ||
435 | |||
436 | await plugin.save() | ||
437 | |||
438 | await removeNpmPlugin(npmName) | ||
439 | |||
440 | logger.info('Plugin %s uninstalled.', npmName) | ||
441 | } | ||
442 | |||
443 | async rebuildNativePluginsIfNeeded () { | ||
444 | if (!await ApplicationModel.nodeABIChanged()) return | ||
445 | |||
446 | return rebuildNativePlugins() | ||
447 | } | ||
448 | |||
449 | // ###################### Private register ###################### | ||
450 | |||
451 | private async registerPluginOrTheme (plugin: PluginModel) { | ||
452 | const npmName = PluginModel.buildNpmName(plugin.name, plugin.type) | ||
453 | |||
454 | logger.info('Registering plugin or theme %s.', npmName) | ||
455 | |||
456 | const packageJSON = await this.getPackageJSON(plugin.name, plugin.type) | ||
457 | const pluginPath = this.getPluginPath(plugin.name, plugin.type) | ||
458 | |||
459 | this.sanitizeAndCheckPackageJSONOrThrow(packageJSON, plugin.type) | ||
460 | |||
461 | let library: PluginLibrary | ||
462 | let registerHelpers: RegisterHelpers | ||
463 | if (plugin.type === PluginType.PLUGIN) { | ||
464 | const result = await this.registerPlugin(plugin, pluginPath, packageJSON) | ||
465 | library = result.library | ||
466 | registerHelpers = result.registerStore | ||
467 | } | ||
468 | |||
469 | const clientScripts: { [id: string]: ClientScriptJSON } = {} | ||
470 | for (const c of packageJSON.clientScripts) { | ||
471 | clientScripts[c.script] = c | ||
472 | } | ||
473 | |||
474 | this.registeredPlugins[npmName] = { | ||
475 | npmName, | ||
476 | name: plugin.name, | ||
477 | type: plugin.type, | ||
478 | version: plugin.version, | ||
479 | description: plugin.description, | ||
480 | peertubeEngine: plugin.peertubeEngine, | ||
481 | path: pluginPath, | ||
482 | staticDirs: packageJSON.staticDirs, | ||
483 | clientScripts, | ||
484 | css: packageJSON.css, | ||
485 | registerHelpers: registerHelpers || undefined, | ||
486 | unregister: library ? library.unregister : undefined | ||
487 | } | ||
488 | |||
489 | await this.addTranslations(plugin, npmName, packageJSON.translations) | ||
490 | |||
491 | ClientHtml.invalidCache() | ||
492 | } | ||
493 | |||
494 | private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJSON) { | ||
495 | const npmName = PluginModel.buildNpmName(plugin.name, plugin.type) | ||
496 | |||
497 | // Delete cache if needed | ||
498 | const modulePath = join(pluginPath, packageJSON.library) | ||
499 | decachePlugin(modulePath) | ||
500 | const library: PluginLibrary = require(modulePath) | ||
501 | |||
502 | if (!isLibraryCodeValid(library)) { | ||
503 | throw new Error('Library code is not valid (miss register or unregister function)') | ||
504 | } | ||
505 | |||
506 | const { registerOptions, registerStore } = this.getRegisterHelpers(npmName, plugin) | ||
507 | |||
508 | await ensureDir(registerOptions.peertubeHelpers.plugin.getDataDirectoryPath()) | ||
509 | |||
510 | await library.register(registerOptions) | ||
511 | |||
512 | logger.info('Add plugin %s CSS to global file.', npmName) | ||
513 | |||
514 | await this.addCSSToGlobalFile(pluginPath, packageJSON.css) | ||
515 | |||
516 | return { library, registerStore } | ||
517 | } | ||
518 | |||
519 | // ###################### Translations ###################### | ||
520 | |||
521 | private async addTranslations (plugin: PluginModel, npmName: string, translationPaths: PluginTranslationPathsJSON) { | ||
522 | for (const locale of Object.keys(translationPaths)) { | ||
523 | const path = translationPaths[locale] | ||
524 | const json = await readJSON(join(this.getPluginPath(plugin.name, plugin.type), path)) | ||
525 | |||
526 | const completeLocale = getCompleteLocale(locale) | ||
527 | |||
528 | if (!this.translations[completeLocale]) this.translations[completeLocale] = {} | ||
529 | this.translations[completeLocale][npmName] = json | ||
530 | |||
531 | logger.info('Added locale %s of plugin %s.', completeLocale, npmName) | ||
532 | } | ||
533 | } | ||
534 | |||
535 | private deleteTranslations (npmName: string) { | ||
536 | for (const locale of Object.keys(this.translations)) { | ||
537 | delete this.translations[locale][npmName] | ||
538 | |||
539 | logger.info('Deleted locale %s of plugin %s.', locale, npmName) | ||
540 | } | ||
541 | } | ||
542 | |||
543 | // ###################### CSS ###################### | ||
544 | |||
545 | private resetCSSGlobalFile () { | ||
546 | return outputFile(PLUGIN_GLOBAL_CSS_PATH, '') | ||
547 | } | ||
548 | |||
549 | private async addCSSToGlobalFile (pluginPath: string, cssRelativePaths: string[]) { | ||
550 | for (const cssPath of cssRelativePaths) { | ||
551 | await this.concatFiles(join(pluginPath, cssPath), PLUGIN_GLOBAL_CSS_PATH) | ||
552 | } | ||
553 | } | ||
554 | |||
555 | private concatFiles (input: string, output: string) { | ||
556 | return new Promise<void>((res, rej) => { | ||
557 | const inputStream = createReadStream(input) | ||
558 | const outputStream = createWriteStream(output, { flags: 'a' }) | ||
559 | |||
560 | inputStream.pipe(outputStream) | ||
561 | |||
562 | inputStream.on('end', () => res()) | ||
563 | inputStream.on('error', err => rej(err)) | ||
564 | }) | ||
565 | } | ||
566 | |||
567 | private async regeneratePluginGlobalCSS () { | ||
568 | await this.resetCSSGlobalFile() | ||
569 | |||
570 | for (const plugin of this.getRegisteredPlugins()) { | ||
571 | await this.addCSSToGlobalFile(plugin.path, plugin.css) | ||
572 | } | ||
573 | } | ||
574 | |||
575 | // ###################### Utils ###################### | ||
576 | |||
577 | private sortHooksByPriority () { | ||
578 | for (const hookName of Object.keys(this.hooks)) { | ||
579 | this.hooks[hookName].sort((a, b) => { | ||
580 | return b.priority - a.priority | ||
581 | }) | ||
582 | } | ||
583 | } | ||
584 | |||
585 | private getPackageJSON (pluginName: string, pluginType: PluginType) { | ||
586 | const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json') | ||
587 | |||
588 | return readJSON(pluginPath) as Promise<PluginPackageJSON> | ||
589 | } | ||
590 | |||
591 | private getPluginPath (pluginName: string, pluginType: PluginType) { | ||
592 | const npmName = PluginModel.buildNpmName(pluginName, pluginType) | ||
593 | |||
594 | return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', npmName) | ||
595 | } | ||
596 | |||
597 | private getAuth (npmName: string, authName: string) { | ||
598 | const plugin = this.getRegisteredPluginOrTheme(npmName) | ||
599 | if (!plugin || plugin.type !== PluginType.PLUGIN) return null | ||
600 | |||
601 | let auths: (RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions)[] = plugin.registerHelpers.getIdAndPassAuths() | ||
602 | auths = auths.concat(plugin.registerHelpers.getExternalAuths()) | ||
603 | |||
604 | return auths.find(a => a.authName === authName) | ||
605 | } | ||
606 | |||
607 | // ###################### Private getters ###################### | ||
608 | |||
609 | private getRegisteredPluginsOrThemes (type: PluginType) { | ||
610 | const plugins: RegisteredPlugin[] = [] | ||
611 | |||
612 | for (const npmName of Object.keys(this.registeredPlugins)) { | ||
613 | const plugin = this.registeredPlugins[npmName] | ||
614 | if (plugin.type !== type) continue | ||
615 | |||
616 | plugins.push(plugin) | ||
617 | } | ||
618 | |||
619 | return plugins | ||
620 | } | ||
621 | |||
622 | // ###################### Generate register helpers ###################### | ||
623 | |||
624 | private getRegisterHelpers ( | ||
625 | npmName: string, | ||
626 | plugin: PluginModel | ||
627 | ): { registerStore: RegisterHelpers, registerOptions: RegisterServerOptions } { | ||
628 | const onHookAdded = (options: RegisterServerHookOptions) => { | ||
629 | if (!this.hooks[options.target]) this.hooks[options.target] = [] | ||
630 | |||
631 | this.hooks[options.target].push({ | ||
632 | npmName, | ||
633 | pluginName: plugin.name, | ||
634 | handler: options.handler, | ||
635 | priority: options.priority || 0 | ||
636 | }) | ||
637 | } | ||
638 | |||
639 | const registerHelpers = new RegisterHelpers(npmName, plugin, this.server, onHookAdded.bind(this)) | ||
640 | |||
641 | return { | ||
642 | registerStore: registerHelpers, | ||
643 | registerOptions: registerHelpers.buildRegisterHelpers() | ||
644 | } | ||
645 | } | ||
646 | |||
647 | private sanitizeAndCheckPackageJSONOrThrow (packageJSON: PluginPackageJSON, pluginType: PluginType) { | ||
648 | if (!packageJSON.staticDirs) packageJSON.staticDirs = {} | ||
649 | if (!packageJSON.css) packageJSON.css = [] | ||
650 | if (!packageJSON.clientScripts) packageJSON.clientScripts = [] | ||
651 | if (!packageJSON.translations) packageJSON.translations = {} | ||
652 | |||
653 | const { result: packageJSONValid, badFields } = isPackageJSONValid(packageJSON, pluginType) | ||
654 | if (!packageJSONValid) { | ||
655 | const formattedFields = badFields.map(f => `"${f}"`) | ||
656 | .join(', ') | ||
657 | |||
658 | throw new Error(`PackageJSON is invalid (invalid fields: ${formattedFields}).`) | ||
659 | } | ||
660 | } | ||
661 | |||
662 | static get Instance () { | ||
663 | return this.instance || (this.instance = new this()) | ||
664 | } | ||
665 | } | ||
diff --git a/server/lib/plugins/register-helpers.ts b/server/lib/plugins/register-helpers.ts deleted file mode 100644 index 1aaef3606..000000000 --- a/server/lib/plugins/register-helpers.ts +++ /dev/null | |||
@@ -1,340 +0,0 @@ | |||
1 | import express from 'express' | ||
2 | import { Server } from 'http' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { onExternalUserAuthenticated } from '@server/lib/auth/external-auth' | ||
5 | import { VideoConstantManagerFactory } from '@server/lib/plugins/video-constant-manager-factory' | ||
6 | import { PluginModel } from '@server/models/server/plugin' | ||
7 | import { | ||
8 | RegisterServerAuthExternalOptions, | ||
9 | RegisterServerAuthExternalResult, | ||
10 | RegisterServerAuthPassOptions, | ||
11 | RegisterServerExternalAuthenticatedResult, | ||
12 | RegisterServerOptions, | ||
13 | RegisterServerWebSocketRouteOptions | ||
14 | } from '@server/types/plugins' | ||
15 | import { | ||
16 | EncoderOptionsBuilder, | ||
17 | PluginSettingsManager, | ||
18 | PluginStorageManager, | ||
19 | RegisterServerHookOptions, | ||
20 | RegisterServerSettingOptions, | ||
21 | serverHookObject, | ||
22 | SettingsChangeCallback, | ||
23 | VideoPlaylistPrivacy, | ||
24 | VideoPrivacy | ||
25 | } from '@shared/models' | ||
26 | import { VideoTranscodingProfilesManager } from '../transcoding/default-transcoding-profiles' | ||
27 | import { buildPluginHelpers } from './plugin-helpers-builder' | ||
28 | |||
29 | export class RegisterHelpers { | ||
30 | private readonly transcodingProfiles: { | ||
31 | [ npmName: string ]: { | ||
32 | type: 'vod' | 'live' | ||
33 | encoder: string | ||
34 | profile: string | ||
35 | }[] | ||
36 | } = {} | ||
37 | |||
38 | private readonly transcodingEncoders: { | ||
39 | [ npmName: string ]: { | ||
40 | type: 'vod' | 'live' | ||
41 | streamType: 'audio' | 'video' | ||
42 | encoder: string | ||
43 | priority: number | ||
44 | }[] | ||
45 | } = {} | ||
46 | |||
47 | private readonly settings: RegisterServerSettingOptions[] = [] | ||
48 | |||
49 | private idAndPassAuths: RegisterServerAuthPassOptions[] = [] | ||
50 | private externalAuths: RegisterServerAuthExternalOptions[] = [] | ||
51 | |||
52 | private readonly onSettingsChangeCallbacks: SettingsChangeCallback[] = [] | ||
53 | |||
54 | private readonly webSocketRoutes: RegisterServerWebSocketRouteOptions[] = [] | ||
55 | |||
56 | private readonly router: express.Router | ||
57 | private readonly videoConstantManagerFactory: VideoConstantManagerFactory | ||
58 | |||
59 | constructor ( | ||
60 | private readonly npmName: string, | ||
61 | private readonly plugin: PluginModel, | ||
62 | private readonly server: Server, | ||
63 | private readonly onHookAdded: (options: RegisterServerHookOptions) => void | ||
64 | ) { | ||
65 | this.router = express.Router() | ||
66 | this.videoConstantManagerFactory = new VideoConstantManagerFactory(this.npmName) | ||
67 | } | ||
68 | |||
69 | buildRegisterHelpers (): RegisterServerOptions { | ||
70 | const registerHook = this.buildRegisterHook() | ||
71 | const registerSetting = this.buildRegisterSetting() | ||
72 | |||
73 | const getRouter = this.buildGetRouter() | ||
74 | const registerWebSocketRoute = this.buildRegisterWebSocketRoute() | ||
75 | |||
76 | const settingsManager = this.buildSettingsManager() | ||
77 | const storageManager = this.buildStorageManager() | ||
78 | |||
79 | const videoLanguageManager = this.videoConstantManagerFactory.createVideoConstantManager<string>('language') | ||
80 | |||
81 | const videoLicenceManager = this.videoConstantManagerFactory.createVideoConstantManager<number>('licence') | ||
82 | const videoCategoryManager = this.videoConstantManagerFactory.createVideoConstantManager<number>('category') | ||
83 | |||
84 | const videoPrivacyManager = this.videoConstantManagerFactory.createVideoConstantManager<VideoPrivacy>('privacy') | ||
85 | const playlistPrivacyManager = this.videoConstantManagerFactory.createVideoConstantManager<VideoPlaylistPrivacy>('playlistPrivacy') | ||
86 | |||
87 | const transcodingManager = this.buildTranscodingManager() | ||
88 | |||
89 | const registerIdAndPassAuth = this.buildRegisterIdAndPassAuth() | ||
90 | const registerExternalAuth = this.buildRegisterExternalAuth() | ||
91 | const unregisterIdAndPassAuth = this.buildUnregisterIdAndPassAuth() | ||
92 | const unregisterExternalAuth = this.buildUnregisterExternalAuth() | ||
93 | |||
94 | const peertubeHelpers = buildPluginHelpers(this.server, this.plugin, this.npmName) | ||
95 | |||
96 | return { | ||
97 | registerHook, | ||
98 | registerSetting, | ||
99 | |||
100 | getRouter, | ||
101 | registerWebSocketRoute, | ||
102 | |||
103 | settingsManager, | ||
104 | storageManager, | ||
105 | |||
106 | videoLanguageManager: { | ||
107 | ...videoLanguageManager, | ||
108 | /** @deprecated use `addConstant` instead **/ | ||
109 | addLanguage: videoLanguageManager.addConstant, | ||
110 | /** @deprecated use `deleteConstant` instead **/ | ||
111 | deleteLanguage: videoLanguageManager.deleteConstant | ||
112 | }, | ||
113 | videoCategoryManager: { | ||
114 | ...videoCategoryManager, | ||
115 | /** @deprecated use `addConstant` instead **/ | ||
116 | addCategory: videoCategoryManager.addConstant, | ||
117 | /** @deprecated use `deleteConstant` instead **/ | ||
118 | deleteCategory: videoCategoryManager.deleteConstant | ||
119 | }, | ||
120 | videoLicenceManager: { | ||
121 | ...videoLicenceManager, | ||
122 | /** @deprecated use `addConstant` instead **/ | ||
123 | addLicence: videoLicenceManager.addConstant, | ||
124 | /** @deprecated use `deleteConstant` instead **/ | ||
125 | deleteLicence: videoLicenceManager.deleteConstant | ||
126 | }, | ||
127 | |||
128 | videoPrivacyManager: { | ||
129 | ...videoPrivacyManager, | ||
130 | /** @deprecated use `deleteConstant` instead **/ | ||
131 | deletePrivacy: videoPrivacyManager.deleteConstant | ||
132 | }, | ||
133 | playlistPrivacyManager: { | ||
134 | ...playlistPrivacyManager, | ||
135 | /** @deprecated use `deleteConstant` instead **/ | ||
136 | deletePlaylistPrivacy: playlistPrivacyManager.deleteConstant | ||
137 | }, | ||
138 | |||
139 | transcodingManager, | ||
140 | |||
141 | registerIdAndPassAuth, | ||
142 | registerExternalAuth, | ||
143 | unregisterIdAndPassAuth, | ||
144 | unregisterExternalAuth, | ||
145 | |||
146 | peertubeHelpers | ||
147 | } | ||
148 | } | ||
149 | |||
150 | reinitVideoConstants (npmName: string) { | ||
151 | this.videoConstantManagerFactory.resetVideoConstants(npmName) | ||
152 | } | ||
153 | |||
154 | reinitTranscodingProfilesAndEncoders (npmName: string) { | ||
155 | const profiles = this.transcodingProfiles[npmName] | ||
156 | if (Array.isArray(profiles)) { | ||
157 | for (const profile of profiles) { | ||
158 | VideoTranscodingProfilesManager.Instance.removeProfile(profile) | ||
159 | } | ||
160 | } | ||
161 | |||
162 | const encoders = this.transcodingEncoders[npmName] | ||
163 | if (Array.isArray(encoders)) { | ||
164 | for (const o of encoders) { | ||
165 | VideoTranscodingProfilesManager.Instance.removeEncoderPriority(o.type, o.streamType, o.encoder, o.priority) | ||
166 | } | ||
167 | } | ||
168 | } | ||
169 | |||
170 | getSettings () { | ||
171 | return this.settings | ||
172 | } | ||
173 | |||
174 | getRouter () { | ||
175 | return this.router | ||
176 | } | ||
177 | |||
178 | getIdAndPassAuths () { | ||
179 | return this.idAndPassAuths | ||
180 | } | ||
181 | |||
182 | getExternalAuths () { | ||
183 | return this.externalAuths | ||
184 | } | ||
185 | |||
186 | getOnSettingsChangedCallbacks () { | ||
187 | return this.onSettingsChangeCallbacks | ||
188 | } | ||
189 | |||
190 | getWebSocketRoutes () { | ||
191 | return this.webSocketRoutes | ||
192 | } | ||
193 | |||
194 | private buildGetRouter () { | ||
195 | return () => this.router | ||
196 | } | ||
197 | |||
198 | private buildRegisterWebSocketRoute () { | ||
199 | return (options: RegisterServerWebSocketRouteOptions) => { | ||
200 | this.webSocketRoutes.push(options) | ||
201 | } | ||
202 | } | ||
203 | |||
204 | private buildRegisterSetting () { | ||
205 | return (options: RegisterServerSettingOptions) => { | ||
206 | this.settings.push(options) | ||
207 | } | ||
208 | } | ||
209 | |||
210 | private buildRegisterHook () { | ||
211 | return (options: RegisterServerHookOptions) => { | ||
212 | if (serverHookObject[options.target] !== true) { | ||
213 | logger.warn('Unknown hook %s of plugin %s. Skipping.', options.target, this.npmName) | ||
214 | return | ||
215 | } | ||
216 | |||
217 | return this.onHookAdded(options) | ||
218 | } | ||
219 | } | ||
220 | |||
221 | private buildRegisterIdAndPassAuth () { | ||
222 | return (options: RegisterServerAuthPassOptions) => { | ||
223 | if (!options.authName || typeof options.getWeight !== 'function' || typeof options.login !== 'function') { | ||
224 | logger.error('Cannot register auth plugin %s: authName, getWeight or login are not valid.', this.npmName, { options }) | ||
225 | return | ||
226 | } | ||
227 | |||
228 | this.idAndPassAuths.push(options) | ||
229 | } | ||
230 | } | ||
231 | |||
232 | private buildRegisterExternalAuth () { | ||
233 | const self = this | ||
234 | |||
235 | return (options: RegisterServerAuthExternalOptions) => { | ||
236 | if (!options.authName || typeof options.authDisplayName !== 'function' || typeof options.onAuthRequest !== 'function') { | ||
237 | logger.error('Cannot register auth plugin %s: authName, authDisplayName or onAuthRequest are not valid.', this.npmName, { options }) | ||
238 | return | ||
239 | } | ||
240 | |||
241 | this.externalAuths.push(options) | ||
242 | |||
243 | return { | ||
244 | userAuthenticated (result: RegisterServerExternalAuthenticatedResult): void { | ||
245 | onExternalUserAuthenticated({ | ||
246 | npmName: self.npmName, | ||
247 | authName: options.authName, | ||
248 | authResult: result | ||
249 | }).catch(err => { | ||
250 | logger.error('Cannot execute onExternalUserAuthenticated.', { npmName: self.npmName, authName: options.authName, err }) | ||
251 | }) | ||
252 | } | ||
253 | } as RegisterServerAuthExternalResult | ||
254 | } | ||
255 | } | ||
256 | |||
257 | private buildUnregisterExternalAuth () { | ||
258 | return (authName: string) => { | ||
259 | this.externalAuths = this.externalAuths.filter(a => a.authName !== authName) | ||
260 | } | ||
261 | } | ||
262 | |||
263 | private buildUnregisterIdAndPassAuth () { | ||
264 | return (authName: string) => { | ||
265 | this.idAndPassAuths = this.idAndPassAuths.filter(a => a.authName !== authName) | ||
266 | } | ||
267 | } | ||
268 | |||
269 | private buildSettingsManager (): PluginSettingsManager { | ||
270 | return { | ||
271 | getSetting: (name: string) => PluginModel.getSetting(this.plugin.name, this.plugin.type, name, this.settings), | ||
272 | |||
273 | getSettings: (names: string[]) => PluginModel.getSettings(this.plugin.name, this.plugin.type, names, this.settings), | ||
274 | |||
275 | setSetting: (name: string, value: string) => PluginModel.setSetting(this.plugin.name, this.plugin.type, name, value), | ||
276 | |||
277 | onSettingsChange: (cb: SettingsChangeCallback) => this.onSettingsChangeCallbacks.push(cb) | ||
278 | } | ||
279 | } | ||
280 | |||
281 | private buildStorageManager (): PluginStorageManager { | ||
282 | return { | ||
283 | getData: (key: string) => PluginModel.getData(this.plugin.name, this.plugin.type, key), | ||
284 | |||
285 | storeData: (key: string, data: any) => PluginModel.storeData(this.plugin.name, this.plugin.type, key, data) | ||
286 | } | ||
287 | } | ||
288 | |||
289 | private buildTranscodingManager () { | ||
290 | const self = this | ||
291 | |||
292 | function addProfile (type: 'live' | 'vod', encoder: string, profile: string, builder: EncoderOptionsBuilder) { | ||
293 | if (profile === 'default') { | ||
294 | logger.error('A plugin cannot add a default live transcoding profile') | ||
295 | return false | ||
296 | } | ||
297 | |||
298 | VideoTranscodingProfilesManager.Instance.addProfile({ | ||
299 | type, | ||
300 | encoder, | ||
301 | profile, | ||
302 | builder | ||
303 | }) | ||
304 | |||
305 | if (!self.transcodingProfiles[self.npmName]) self.transcodingProfiles[self.npmName] = [] | ||
306 | self.transcodingProfiles[self.npmName].push({ type, encoder, profile }) | ||
307 | |||
308 | return true | ||
309 | } | ||
310 | |||
311 | function addEncoderPriority (type: 'live' | 'vod', streamType: 'audio' | 'video', encoder: string, priority: number) { | ||
312 | VideoTranscodingProfilesManager.Instance.addEncoderPriority(type, streamType, encoder, priority) | ||
313 | |||
314 | if (!self.transcodingEncoders[self.npmName]) self.transcodingEncoders[self.npmName] = [] | ||
315 | self.transcodingEncoders[self.npmName].push({ type, streamType, encoder, priority }) | ||
316 | } | ||
317 | |||
318 | return { | ||
319 | addLiveProfile (encoder: string, profile: string, builder: EncoderOptionsBuilder) { | ||
320 | return addProfile('live', encoder, profile, builder) | ||
321 | }, | ||
322 | |||
323 | addVODProfile (encoder: string, profile: string, builder: EncoderOptionsBuilder) { | ||
324 | return addProfile('vod', encoder, profile, builder) | ||
325 | }, | ||
326 | |||
327 | addLiveEncoderPriority (streamType: 'audio' | 'video', encoder: string, priority: number) { | ||
328 | return addEncoderPriority('live', streamType, encoder, priority) | ||
329 | }, | ||
330 | |||
331 | addVODEncoderPriority (streamType: 'audio' | 'video', encoder: string, priority: number) { | ||
332 | return addEncoderPriority('vod', streamType, encoder, priority) | ||
333 | }, | ||
334 | |||
335 | removeAllProfilesAndEncoderPriorities () { | ||
336 | return self.reinitTranscodingProfilesAndEncoders(self.npmName) | ||
337 | } | ||
338 | } | ||
339 | } | ||
340 | } | ||
diff --git a/server/lib/plugins/theme-utils.ts b/server/lib/plugins/theme-utils.ts deleted file mode 100644 index 76c671f1c..000000000 --- a/server/lib/plugins/theme-utils.ts +++ /dev/null | |||
@@ -1,24 +0,0 @@ | |||
1 | import { DEFAULT_THEME_NAME, DEFAULT_USER_THEME_NAME } from '../../initializers/constants' | ||
2 | import { PluginManager } from './plugin-manager' | ||
3 | import { CONFIG } from '../../initializers/config' | ||
4 | |||
5 | function getThemeOrDefault (name: string, defaultTheme: string) { | ||
6 | if (isThemeRegistered(name)) return name | ||
7 | |||
8 | // Fallback to admin default theme | ||
9 | if (name !== CONFIG.THEME.DEFAULT) return getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) | ||
10 | |||
11 | return defaultTheme | ||
12 | } | ||
13 | |||
14 | function isThemeRegistered (name: string) { | ||
15 | if (name === DEFAULT_THEME_NAME || name === DEFAULT_USER_THEME_NAME) return true | ||
16 | |||
17 | return !!PluginManager.Instance.getRegisteredThemes() | ||
18 | .find(r => r.name === name) | ||
19 | } | ||
20 | |||
21 | export { | ||
22 | getThemeOrDefault, | ||
23 | isThemeRegistered | ||
24 | } | ||
diff --git a/server/lib/plugins/video-constant-manager-factory.ts b/server/lib/plugins/video-constant-manager-factory.ts deleted file mode 100644 index 5f7edfbe2..000000000 --- a/server/lib/plugins/video-constant-manager-factory.ts +++ /dev/null | |||
@@ -1,139 +0,0 @@ | |||
1 | import { logger } from '@server/helpers/logger' | ||
2 | import { | ||
3 | VIDEO_CATEGORIES, | ||
4 | VIDEO_LANGUAGES, | ||
5 | VIDEO_LICENCES, | ||
6 | VIDEO_PLAYLIST_PRIVACIES, | ||
7 | VIDEO_PRIVACIES | ||
8 | } from '@server/initializers/constants' | ||
9 | import { ConstantManager } from '@shared/models/plugins/server/plugin-constant-manager.model' | ||
10 | |||
11 | type AlterableVideoConstant = 'language' | 'licence' | 'category' | 'privacy' | 'playlistPrivacy' | ||
12 | type VideoConstant = Record<number | string, string> | ||
13 | |||
14 | type UpdatedVideoConstant = { | ||
15 | [name in AlterableVideoConstant]: { | ||
16 | [ npmName: string]: { | ||
17 | added: VideoConstant[] | ||
18 | deleted: VideoConstant[] | ||
19 | } | ||
20 | } | ||
21 | } | ||
22 | |||
23 | const constantsHash: { [key in AlterableVideoConstant]: VideoConstant } = { | ||
24 | language: VIDEO_LANGUAGES, | ||
25 | licence: VIDEO_LICENCES, | ||
26 | category: VIDEO_CATEGORIES, | ||
27 | privacy: VIDEO_PRIVACIES, | ||
28 | playlistPrivacy: VIDEO_PLAYLIST_PRIVACIES | ||
29 | } | ||
30 | |||
31 | export class VideoConstantManagerFactory { | ||
32 | private readonly updatedVideoConstants: UpdatedVideoConstant = { | ||
33 | playlistPrivacy: { }, | ||
34 | privacy: { }, | ||
35 | language: { }, | ||
36 | licence: { }, | ||
37 | category: { } | ||
38 | } | ||
39 | |||
40 | constructor ( | ||
41 | private readonly npmName: string | ||
42 | ) {} | ||
43 | |||
44 | public resetVideoConstants (npmName: string) { | ||
45 | const types: AlterableVideoConstant[] = [ 'language', 'licence', 'category', 'privacy', 'playlistPrivacy' ] | ||
46 | for (const type of types) { | ||
47 | this.resetConstants({ npmName, type }) | ||
48 | } | ||
49 | } | ||
50 | |||
51 | private resetConstants (parameters: { npmName: string, type: AlterableVideoConstant }) { | ||
52 | const { npmName, type } = parameters | ||
53 | const updatedConstants = this.updatedVideoConstants[type][npmName] | ||
54 | |||
55 | if (!updatedConstants) return | ||
56 | |||
57 | for (const added of updatedConstants.added) { | ||
58 | delete constantsHash[type][added.key] | ||
59 | } | ||
60 | |||
61 | for (const deleted of updatedConstants.deleted) { | ||
62 | constantsHash[type][deleted.key] = deleted.label | ||
63 | } | ||
64 | |||
65 | delete this.updatedVideoConstants[type][npmName] | ||
66 | } | ||
67 | |||
68 | public createVideoConstantManager<K extends number | string>(type: AlterableVideoConstant): ConstantManager<K> { | ||
69 | const { npmName } = this | ||
70 | return { | ||
71 | addConstant: (key: K, label: string) => this.addConstant({ npmName, type, key, label }), | ||
72 | deleteConstant: (key: K) => this.deleteConstant({ npmName, type, key }), | ||
73 | getConstantValue: (key: K) => constantsHash[type][key], | ||
74 | getConstants: () => constantsHash[type] as Record<K, string>, | ||
75 | resetConstants: () => this.resetConstants({ npmName, type }) | ||
76 | } | ||
77 | } | ||
78 | |||
79 | private addConstant<T extends string | number> (parameters: { | ||
80 | npmName: string | ||
81 | type: AlterableVideoConstant | ||
82 | key: T | ||
83 | label: string | ||
84 | }) { | ||
85 | const { npmName, type, key, label } = parameters | ||
86 | const obj = constantsHash[type] | ||
87 | |||
88 | if (obj[key]) { | ||
89 | logger.warn('Cannot add %s %s by plugin %s: key already exists.', type, npmName, key) | ||
90 | return false | ||
91 | } | ||
92 | |||
93 | if (!this.updatedVideoConstants[type][npmName]) { | ||
94 | this.updatedVideoConstants[type][npmName] = { | ||
95 | added: [], | ||
96 | deleted: [] | ||
97 | } | ||
98 | } | ||
99 | |||
100 | this.updatedVideoConstants[type][npmName].added.push({ key, label } as VideoConstant) | ||
101 | obj[key] = label | ||
102 | |||
103 | return true | ||
104 | } | ||
105 | |||
106 | private deleteConstant<T extends string | number> (parameters: { | ||
107 | npmName: string | ||
108 | type: AlterableVideoConstant | ||
109 | key: T | ||
110 | }) { | ||
111 | const { npmName, type, key } = parameters | ||
112 | const obj = constantsHash[type] | ||
113 | |||
114 | if (!obj[key]) { | ||
115 | logger.warn('Cannot delete %s by plugin %s: key %s does not exist.', type, npmName, key) | ||
116 | return false | ||
117 | } | ||
118 | |||
119 | if (!this.updatedVideoConstants[type][npmName]) { | ||
120 | this.updatedVideoConstants[type][npmName] = { | ||
121 | added: [], | ||
122 | deleted: [] | ||
123 | } | ||
124 | } | ||
125 | |||
126 | const updatedConstants = this.updatedVideoConstants[type][npmName] | ||
127 | |||
128 | const alreadyAdded = updatedConstants.added.find(a => a.key === key) | ||
129 | if (alreadyAdded) { | ||
130 | updatedConstants.added.filter(a => a.key !== key) | ||
131 | } else if (obj[key]) { | ||
132 | updatedConstants.deleted.push({ key, label: obj[key] } as VideoConstant) | ||
133 | } | ||
134 | |||
135 | delete obj[key] | ||
136 | |||
137 | return true | ||
138 | } | ||
139 | } | ||
diff --git a/server/lib/plugins/yarn.ts b/server/lib/plugins/yarn.ts deleted file mode 100644 index 9cf6ec9e9..000000000 --- a/server/lib/plugins/yarn.ts +++ /dev/null | |||
@@ -1,73 +0,0 @@ | |||
1 | import { outputJSON, pathExists } from 'fs-extra' | ||
2 | import { join } from 'path' | ||
3 | import { execShell } from '../../helpers/core-utils' | ||
4 | import { isNpmPluginNameValid, isPluginStableOrUnstableVersionValid } from '../../helpers/custom-validators/plugins' | ||
5 | import { logger } from '../../helpers/logger' | ||
6 | import { CONFIG } from '../../initializers/config' | ||
7 | import { getLatestPluginVersion } from './plugin-index' | ||
8 | |||
9 | async function installNpmPlugin (npmName: string, versionArg?: string) { | ||
10 | // Security check | ||
11 | checkNpmPluginNameOrThrow(npmName) | ||
12 | if (versionArg) checkPluginVersionOrThrow(versionArg) | ||
13 | |||
14 | const version = versionArg || await getLatestPluginVersion(npmName) | ||
15 | |||
16 | let toInstall = npmName | ||
17 | if (version) toInstall += `@${version}` | ||
18 | |||
19 | const { stdout } = await execYarn('add ' + toInstall) | ||
20 | |||
21 | logger.debug('Added a yarn package.', { yarnStdout: stdout }) | ||
22 | } | ||
23 | |||
24 | async function installNpmPluginFromDisk (path: string) { | ||
25 | await execYarn('add file:' + path) | ||
26 | } | ||
27 | |||
28 | async function removeNpmPlugin (name: string) { | ||
29 | checkNpmPluginNameOrThrow(name) | ||
30 | |||
31 | await execYarn('remove ' + name) | ||
32 | } | ||
33 | |||
34 | async function rebuildNativePlugins () { | ||
35 | await execYarn('install --pure-lockfile') | ||
36 | } | ||
37 | |||
38 | // ############################################################################ | ||
39 | |||
40 | export { | ||
41 | installNpmPlugin, | ||
42 | installNpmPluginFromDisk, | ||
43 | rebuildNativePlugins, | ||
44 | removeNpmPlugin | ||
45 | } | ||
46 | |||
47 | // ############################################################################ | ||
48 | |||
49 | async function execYarn (command: string) { | ||
50 | try { | ||
51 | const pluginDirectory = CONFIG.STORAGE.PLUGINS_DIR | ||
52 | const pluginPackageJSON = join(pluginDirectory, 'package.json') | ||
53 | |||
54 | // Create empty package.json file if needed | ||
55 | if (!await pathExists(pluginPackageJSON)) { | ||
56 | await outputJSON(pluginPackageJSON, {}) | ||
57 | } | ||
58 | |||
59 | return execShell(`yarn ${command}`, { cwd: pluginDirectory }) | ||
60 | } catch (result) { | ||
61 | logger.error('Cannot exec yarn.', { command, err: result.err, stderr: result.stderr }) | ||
62 | |||
63 | throw result.err | ||
64 | } | ||
65 | } | ||
66 | |||
67 | function checkNpmPluginNameOrThrow (name: string) { | ||
68 | if (!isNpmPluginNameValid(name)) throw new Error('Invalid NPM plugin name to install') | ||
69 | } | ||
70 | |||
71 | function checkPluginVersionOrThrow (name: string) { | ||
72 | if (!isPluginStableOrUnstableVersionValid(name)) throw new Error('Invalid NPM plugin version to install') | ||
73 | } | ||