Blame Raw
· · · 293 lines (9.7 KB)
0 contributors
1import fs from 'node:fs';
2import path from 'node:path';
3import url from 'node:url';
4
5import express from 'express';
6import { default as git, CheckRepoActions } from 'simple-git';
7import { sync as commandExistsSync } from 'command-exists';
8import { getConfigValue, color } from './util.js';
9
10const enableServerPlugins = !!getConfigValue('enableServerPlugins', false, 'boolean');
11const enableServerPluginsAutoUpdate = !!getConfigValue('enableServerPluginsAutoUpdate', true, 'boolean');
12
13/**
14 * Map of loaded plugins.
15 * @type {Map<string, any>}
16 */
17const loadedPlugins = new Map();
18
19/**
20 * Determine if a file is a CommonJS module.
21 * @param {string} file Path to file
22 * @returns {boolean} True if file is a CommonJS module
23 */
24const isCommonJS = (file) => path.extname(file) === '.js' || path.extname(file) === '.cjs';
25
26/**
27 * Determine if a file is an ECMAScript module.
28 * @param {string} file Path to file
29 * @returns {boolean} True if file is an ECMAScript module
30 */
31const isESModule = (file) => path.extname(file) === '.mjs';
32
33/**
34 * Load and initialize server plugins from a directory if they are enabled.
35 * @param {import('express').Express} app Express app
36 * @param {string} pluginsPath Path to plugins directory
37 * @returns {Promise<Function>} Promise that resolves when all plugins are loaded. Resolves to a "cleanup" function to
38 * be called before the server shuts down.
39 */
40export async function loadPlugins(app, pluginsPath) {
41 try {
42 const exitHooks = [];
43 const emptyFn = () => { };
44
45 // Server plugins are disabled.
46 if (!enableServerPlugins) {
47 return emptyFn;
48 }
49
50 // Plugins directory does not exist.
51 if (!fs.existsSync(pluginsPath)) {
52 return emptyFn;
53 }
54
55 const files = fs.readdirSync(pluginsPath);
56
57 // No plugins to load.
58 if (files.length === 0) {
59 return emptyFn;
60 }
61
62 await updatePlugins(pluginsPath);
63
64 for (const file of files) {
65 const pluginFilePath = path.join(pluginsPath, file);
66
67 if (fs.statSync(pluginFilePath).isDirectory()) {
68 await loadFromDirectory(app, pluginFilePath, exitHooks);
69 continue;
70 }
71
72 // Not a JavaScript file.
73 if (!isCommonJS(file) && !isESModule(file)) {
74 continue;
75 }
76
77 await loadFromFile(app, pluginFilePath, exitHooks);
78 }
79
80 if (loadedPlugins.size > 0) {
81 console.log(`${loadedPlugins.size} server plugin(s) are currently loaded. Make sure you know exactly what they do, and only install plugins from trusted sources!`);
82 }
83
84 // Call all plugin "exit" functions at once and wait for them to finish
85 return () => Promise.all(exitHooks.map(exitFn => exitFn()));
86 } catch (error) {
87 console.error('Plugin loading failed.', error);
88 return () => { };
89 }
90}
91
92async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {
93 const files = fs.readdirSync(pluginDirectoryPath);
94
95 // No plugins to load.
96 if (files.length === 0) {
97 return;
98 }
99
100 // Plugin is an npm package.
101 const packageJsonFilePath = path.join(pluginDirectoryPath, 'package.json');
102 if (fs.existsSync(packageJsonFilePath)) {
103 if (await loadFromPackage(app, packageJsonFilePath, exitHooks)) {
104 return;
105 }
106 }
107
108 // Plugin is a module file.
109 const fileTypes = ['index.js', 'index.cjs', 'index.mjs'];
110
111 for (const fileType of fileTypes) {
112 const filePath = path.join(pluginDirectoryPath, fileType);
113 if (fs.existsSync(filePath)) {
114 if (await loadFromFile(app, filePath, exitHooks)) {
115 return;
116 }
117 }
118 }
119}
120
121/**
122 * Loads and initializes a plugin from an npm package.
123 * @param {import('express').Express} app Express app
124 * @param {string} packageJsonPath Path to package.json file
125 * @param {Array<Function>} exitHooks Array of functions to be run on plugin exit. Will be pushed to if the plugin has
126 * an "exit" function.
127 * @returns {Promise<boolean>} Promise that resolves to true if plugin was loaded successfully
128 */
129async function loadFromPackage(app, packageJsonPath, exitHooks) {
130 try {
131 const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
132 if (packageJson.main) {
133 const pluginFilePath = path.join(path.dirname(packageJsonPath), packageJson.main);
134 return await loadFromFile(app, pluginFilePath, exitHooks);
135 }
136 } catch (error) {
137 console.error(`Failed to load plugin from ${packageJsonPath}: ${error}`);
138 }
139 return false;
140}
141
142/**
143 * Loads and initializes a plugin from a file.
144 * @param {import('express').Express} app Express app
145 * @param {string} pluginFilePath Path to plugin directory
146 * @param {Array.<Function>} exitHooks Array of functions to be run on plugin exit. Will be pushed to if the plugin has
147 * an "exit" function.
148 * @returns {Promise<boolean>} Promise that resolves to true if plugin was loaded successfully
149 */
150async function loadFromFile(app, pluginFilePath, exitHooks) {
151 try {
152 const fileUrl = url.pathToFileURL(pluginFilePath).toString();
153 const plugin = await import(fileUrl);
154 console.log(`Initializing plugin from ${pluginFilePath}`);
155 return await initPlugin(app, plugin, exitHooks);
156 } catch (error) {
157 console.error(`Failed to load plugin from ${pluginFilePath}: ${error}`);
158 return false;
159 }
160}
161
162/**
163 * Check whether a plugin ID is valid (only lowercase alphanumeric, hyphens, and underscores).
164 * @param {string} id The plugin ID to check
165 * @returns {boolean} True if the plugin ID is valid.
166 */
167function isValidPluginID(id) {
168 return /^[a-z0-9_-]+$/.test(id);
169}
170
171/**
172 * Initializes a plugin module.
173 * @param {import('express').Express} app Express app
174 * @param {any} plugin Plugin module
175 * @param {Array.<Function>} exitHooks Array of functions to be run on plugin exit. Will be pushed to if the plugin has
176 * an "exit" function.
177 * @returns {Promise<boolean>} Promise that resolves to true if plugin was initialized successfully
178 */
179async function initPlugin(app, plugin, exitHooks) {
180 const info = plugin.info || plugin.default?.info;
181 if (typeof info !== 'object') {
182 console.error('Failed to load plugin module; plugin info not found');
183 return false;
184 }
185
186 // We don't currently use "name" or "description" but it would be nice to have a UI for listing server plugins, so
187 // require them now just to be safe
188 for (const field of ['id', 'name', 'description']) {
189 if (typeof info[field] !== 'string') {
190 console.error(`Failed to load plugin module; plugin info missing field '${field}'`);
191 return false;
192 }
193 }
194
195 const init = plugin.init || plugin.default?.init;
196 if (typeof init !== 'function') {
197 console.error('Failed to load plugin module; no init function');
198 return false;
199 }
200
201 const { id } = info;
202
203 if (!isValidPluginID(id)) {
204 console.error(`Failed to load plugin module; invalid plugin ID '${id}'`);
205 return false;
206 }
207
208 if (loadedPlugins.has(id)) {
209 console.error(`Failed to load plugin module; plugin ID '${id}' is already in use`);
210 return false;
211 }
212
213 // Allow the plugin to register API routes under /api/plugins/[plugin ID] via a router
214 const router = express.Router();
215
216 await init(router);
217
218 loadedPlugins.set(id, plugin);
219
220 // Add API routes to the app if the plugin registered any
221 if (router.stack.length > 0) {
222 app.use(`/api/plugins/${id}`, router);
223 }
224
225 const exit = plugin.exit || plugin.default?.exit;
226 if (typeof exit === 'function') {
227 exitHooks.push(exit);
228 }
229
230 return true;
231}
232
233/**
234 * Automatically update all git plugins in the ./plugins directory
235 * @param {string} pluginsPath Path to plugins directory
236 */
237async function updatePlugins(pluginsPath) {
238 if (!enableServerPluginsAutoUpdate) {
239 return;
240 }
241
242 const directories = fs.readdirSync(pluginsPath)
243 .filter(file => !file.startsWith('.'))
244 .filter(file => fs.statSync(path.join(pluginsPath, file)).isDirectory());
245
246 if (directories.length === 0) {
247 return;
248 }
249
250 console.log(color.blue('Auto-updating server plugins... Set'), color.yellow('enableServerPluginsAutoUpdate: false'), color.blue('in config.yaml to disable this feature.'));
251
252 if (!commandExistsSync('git')) {
253 console.error(color.red('Git is not installed. Please install Git to enable auto-updating of server plugins.'));
254 return;
255 }
256
257 let pluginsToUpdate = 0;
258
259 for (const directory of directories) {
260 try {
261 const pluginPath = path.join(pluginsPath, directory);
262 const pluginRepo = git(pluginPath);
263
264 const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
265 if (!isRepo) {
266 continue;
267 }
268
269 await pluginRepo.fetch();
270 const commitHash = await pluginRepo.revparse(['HEAD']);
271 const trackingBranch = await pluginRepo.revparse(['--abbrev-ref', '@{u}']);
272 const log = await pluginRepo.log({
273 from: commitHash,
274 to: trackingBranch,
275 });
276
277 if (log.total === 0) {
278 continue;
279 }
280
281 pluginsToUpdate++;
282 await pluginRepo.pull();
283 const latestCommit = await pluginRepo.revparse(['HEAD']);
284 console.log(`Plugin ${color.green(directory)} updated to commit ${color.cyan(latestCommit)}`);
285 } catch (error) {
286 console.error(color.red(`Failed to update plugin ${directory}: ${error.message}`));
287 }
288 }
289
290 if (pluginsToUpdate === 0) {
291 console.log('All plugins are up to date.');
292 }
293}