|
import fs from 'node:fs'; |
|
import path from 'node:path'; |
|
import url from 'node:url'; |
|
|
|
import express from 'express'; |
|
import { default as git, CheckRepoActions } from 'simple-git'; |
|
import { sync as commandExistsSync } from 'command-exists'; |
|
import { getConfigValue, color } from './util.js'; |
|
|
|
const enableServerPlugins = !!getConfigValue('enableServerPlugins', false, 'boolean'); |
|
const enableServerPluginsAutoUpdate = !!getConfigValue('enableServerPluginsAutoUpdate', true, 'boolean'); |
|
|
|
|
|
|
|
|
|
|
|
const loadedPlugins = new Map(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
const isCommonJS = (file) => path.extname(file) === '.js' || path.extname(file) === '.cjs'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
const isESModule = (file) => path.extname(file) === '.mjs'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function loadPlugins(app, pluginsPath) { |
|
try { |
|
const exitHooks = []; |
|
const emptyFn = () => { }; |
|
|
|
|
|
if (!enableServerPlugins) { |
|
return emptyFn; |
|
} |
|
|
|
|
|
if (!fs.existsSync(pluginsPath)) { |
|
return emptyFn; |
|
} |
|
|
|
const files = fs.readdirSync(pluginsPath); |
|
|
|
|
|
if (files.length === 0) { |
|
return emptyFn; |
|
} |
|
|
|
await updatePlugins(pluginsPath); |
|
|
|
for (const file of files) { |
|
const pluginFilePath = path.join(pluginsPath, file); |
|
|
|
if (fs.statSync(pluginFilePath).isDirectory()) { |
|
await loadFromDirectory(app, pluginFilePath, exitHooks); |
|
continue; |
|
} |
|
|
|
|
|
if (!isCommonJS(file) && !isESModule(file)) { |
|
continue; |
|
} |
|
|
|
await loadFromFile(app, pluginFilePath, exitHooks); |
|
} |
|
|
|
if (loadedPlugins.size > 0) { |
|
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!`); |
|
} |
|
|
|
|
|
return () => Promise.all(exitHooks.map(exitFn => exitFn())); |
|
} catch (error) { |
|
console.error('Plugin loading failed.', error); |
|
return () => { }; |
|
} |
|
} |
|
|
|
async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) { |
|
const files = fs.readdirSync(pluginDirectoryPath); |
|
|
|
|
|
if (files.length === 0) { |
|
return; |
|
} |
|
|
|
|
|
const packageJsonFilePath = path.join(pluginDirectoryPath, 'package.json'); |
|
if (fs.existsSync(packageJsonFilePath)) { |
|
if (await loadFromPackage(app, packageJsonFilePath, exitHooks)) { |
|
return; |
|
} |
|
} |
|
|
|
|
|
const fileTypes = ['index.js', 'index.cjs', 'index.mjs']; |
|
|
|
for (const fileType of fileTypes) { |
|
const filePath = path.join(pluginDirectoryPath, fileType); |
|
if (fs.existsSync(filePath)) { |
|
if (await loadFromFile(app, filePath, exitHooks)) { |
|
return; |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function loadFromPackage(app, packageJsonPath, exitHooks) { |
|
try { |
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); |
|
if (packageJson.main) { |
|
const pluginFilePath = path.join(path.dirname(packageJsonPath), packageJson.main); |
|
return await loadFromFile(app, pluginFilePath, exitHooks); |
|
} |
|
} catch (error) { |
|
console.error(`Failed to load plugin from ${packageJsonPath}: ${error}`); |
|
} |
|
return false; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function loadFromFile(app, pluginFilePath, exitHooks) { |
|
try { |
|
const fileUrl = url.pathToFileURL(pluginFilePath).toString(); |
|
const plugin = await import(fileUrl); |
|
console.log(`Initializing plugin from ${pluginFilePath}`); |
|
return await initPlugin(app, plugin, exitHooks); |
|
} catch (error) { |
|
console.error(`Failed to load plugin from ${pluginFilePath}: ${error}`); |
|
return false; |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function isValidPluginID(id) { |
|
return /^[a-z0-9_-]+$/.test(id); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function initPlugin(app, plugin, exitHooks) { |
|
const info = plugin.info || plugin.default?.info; |
|
if (typeof info !== 'object') { |
|
console.error('Failed to load plugin module; plugin info not found'); |
|
return false; |
|
} |
|
|
|
|
|
|
|
for (const field of ['id', 'name', 'description']) { |
|
if (typeof info[field] !== 'string') { |
|
console.error(`Failed to load plugin module; plugin info missing field '${field}'`); |
|
return false; |
|
} |
|
} |
|
|
|
const init = plugin.init || plugin.default?.init; |
|
if (typeof init !== 'function') { |
|
console.error('Failed to load plugin module; no init function'); |
|
return false; |
|
} |
|
|
|
const { id } = info; |
|
|
|
if (!isValidPluginID(id)) { |
|
console.error(`Failed to load plugin module; invalid plugin ID '${id}'`); |
|
return false; |
|
} |
|
|
|
if (loadedPlugins.has(id)) { |
|
console.error(`Failed to load plugin module; plugin ID '${id}' is already in use`); |
|
return false; |
|
} |
|
|
|
|
|
const router = express.Router(); |
|
|
|
await init(router); |
|
|
|
loadedPlugins.set(id, plugin); |
|
|
|
|
|
if (router.stack.length > 0) { |
|
app.use(`/api/plugins/${id}`, router); |
|
} |
|
|
|
const exit = plugin.exit || plugin.default?.exit; |
|
if (typeof exit === 'function') { |
|
exitHooks.push(exit); |
|
} |
|
|
|
return true; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function updatePlugins(pluginsPath) { |
|
if (!enableServerPluginsAutoUpdate) { |
|
return; |
|
} |
|
|
|
const directories = fs.readdirSync(pluginsPath) |
|
.filter(file => !file.startsWith('.')) |
|
.filter(file => fs.statSync(path.join(pluginsPath, file)).isDirectory()); |
|
|
|
if (directories.length === 0) { |
|
return; |
|
} |
|
|
|
console.log(color.blue('Auto-updating server plugins... Set'), color.yellow('enableServerPluginsAutoUpdate: false'), color.blue('in config.yaml to disable this feature.')); |
|
|
|
if (!commandExistsSync('git')) { |
|
console.error(color.red('Git is not installed. Please install Git to enable auto-updating of server plugins.')); |
|
return; |
|
} |
|
|
|
let pluginsToUpdate = 0; |
|
|
|
for (const directory of directories) { |
|
try { |
|
const pluginPath = path.join(pluginsPath, directory); |
|
const pluginRepo = git(pluginPath); |
|
|
|
const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); |
|
if (!isRepo) { |
|
continue; |
|
} |
|
|
|
await pluginRepo.fetch(); |
|
const commitHash = await pluginRepo.revparse(['HEAD']); |
|
const trackingBranch = await pluginRepo.revparse(['--abbrev-ref', '@{u}']); |
|
const log = await pluginRepo.log({ |
|
from: commitHash, |
|
to: trackingBranch, |
|
}); |
|
|
|
if (log.total === 0) { |
|
continue; |
|
} |
|
|
|
pluginsToUpdate++; |
|
await pluginRepo.pull(); |
|
const latestCommit = await pluginRepo.revparse(['HEAD']); |
|
console.log(`Plugin ${color.green(directory)} updated to commit ${color.cyan(latestCommit)}`); |
|
} catch (error) { |
|
console.error(color.red(`Failed to update plugin ${directory}: ${error.message}`)); |
|
} |
|
} |
|
|
|
if (pluginsToUpdate === 0) { |
|
console.log('All plugins are up to date.'); |
|
} |
|
} |
|
|