| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import glob from 'fast-glob' |
| 4 | import { watchLoad } from './watchLoad' |
| 5 | import _ from 'lodash' |
| 6 | import { |
| 7 | API_VERSION, APP_PATH, COMPATIBLE_API_VERSION, IS_WINDOWS, MIME_AUTO, PLUGINS_PUB_URI, EMBEDDED_LANGUAGE, |
| 8 | HTTP_NOT_FOUND, |
| 9 | } from './const' |
| 10 | import * as Const from './const' |
| 11 | import Koa from 'koa' |
| 12 | import { |
| 13 | escapeGlobPath, callable, Callback, CFG, debounceAsync, Dict, onlyTruthy, prefix, |
| 14 | PendingPromise, pendingPromise, Promisable, same, tryJson, wait, waitFor, wantArray, watchDir, objFromKeys, patchKey |
| 15 | } from './misc' |
| 16 | import * as misc from './misc' |
| 17 | import { defineConfig, getConfig, subMultipleConfigs } from './config' |
| 18 | import { DirEntry } from './api.get_file_list' |
| 19 | import { normalizeFilename, VfsNode } from './vfs' |
| 20 | import { serveFile } from './serveFile' |
| 21 | import events from './events' |
| 22 | import { mkdir, readdir, readFile, rm } from 'fs/promises' |
| 23 | import { existsSync, mkdirSync } from 'fs' |
| 24 | import { getConnections } from './connections' |
| 25 | import { dirname, join, resolve } from 'path' |
| 26 | import { watchLoadCustomHtml } from './customHtml' |
| 27 | import { KvStorage, KvStorageOptions } from '@rejetto/kvstorage' |
| 28 | import { onProcessExit } from './first' |
| 29 | import { notifyClient } from './frontEndApis' |
| 30 | import { app } from './index' |
| 31 | import { addBlock } from './block' |
| 32 | import { getLangData } from './lang' |
| 33 | import { i18nFromTranslations } from './i18n' |
| 34 | import { addAccount, ctxBelongsTo, delAccount, getAccount, getUsernames, renameAccount, updateAccount } from './perm' |
| 35 | import { getCurrentUsername } from './auth' |
| 36 | import { CustomizedIcons, watchIconsFolder } from './icons' |
| 37 | import { getServerStatus } from './listen' |
| 38 | |
| 39 | export const PATH = 'plugins' |
| 40 | export const DISABLING_SUFFIX = '-disabled' |
| 41 | export const DELETE_ME_SUFFIX = '-delete_me' + DISABLING_SUFFIX |
| 42 | export const STORAGE_FOLDER = 'storage' |
| 43 | export const pluginsScanned = pendingPromise() |
| 44 | |
| 45 | setTimeout(async () => { // delete leftovers, if any |
| 46 | for (const x of await readdir(PATH)) |
| 47 | if (x.endsWith(DELETE_ME_SUFFIX)) |
| 48 | await rm(join(PATH, x), { recursive: true, force: true }).catch(() => {}) |
| 49 | }, 1000) |
| 50 | |
| 51 | const plugins = new Map<string, Plugin>() // now that we care about the order, a simple object wouldn't do, because numbers are always at the beginning |
| 52 | |
| 53 | export function isPluginRunning(id: string) { |
| 54 | return Boolean(plugins.get(id)?.started) |
| 55 | } |
| 56 | |
| 57 | export function isPluginEnabled(id: string, considerSuspension=false) { |
| 58 | return (!considerSuspension || !suspendPlugins.get()) && enablePlugins.get().includes(id) |
| 59 | } |
| 60 | |
| 61 | export function enablePlugin(id: string, state=true) { |
| 62 | if (state && !getPluginInfo(id)) |
| 63 | throw Error('miss') |
| 64 | enablePlugins.set(arr => { |
| 65 | if (arr.includes(id) === state) |
| 66 | return arr |
| 67 | console.log("Switching plugin", id, state ? "on" : "off") |
| 68 | return arr.includes(id) === state ? arr |
| 69 | : state ? [...arr, id] |
| 70 | : arr.filter((x: string) => x !== id) |
| 71 | }) |
| 72 | } |
| 73 | |
| 74 | export async function stopPlugin(id: string) { |
| 75 | enablePlugin(id, false) |
| 76 | await waitRunning(id, false) |
| 77 | } |
| 78 | |
| 79 | export async function startPlugin(id: string) { |
| 80 | if (getPluginInfo(id)?.isTheme) |
| 81 | await Promise.all(mapPlugins((pl, id) => pl.isTheme && stopPlugin(id))) |
| 82 | enablePlugin(id) |
| 83 | await waitRunning(id) |
| 84 | } |
| 85 | |
| 86 | async function waitRunning(id: string, state=true) { |
| 87 | while (isPluginRunning(id) !== state) { |
| 88 | await wait(500) |
| 89 | const error = getError(id) |
| 90 | if (error) |
| 91 | throw Error(error) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // nullish values are equivalent to defaultValues |
| 96 | export function setPluginConfig(id: string, changes: Dict | null) { |
| 97 | pluginsConfig.set(allConfigs => { |
| 98 | const fields = getPluginConfigFields(id) |
| 99 | const oldConfig = allConfigs[id] |
| 100 | const newConfig = changes && _.pickBy({ ...oldConfig, ...changes }, |
| 101 | (v, k) => v != null && !same(v, fields?.[k]?.defaultValue)) |
| 102 | return { ...allConfigs, [id]: _.isEmpty(newConfig) ? undefined : newConfig } |
| 103 | }) |
| 104 | } |
| 105 | |
| 106 | export function getPluginInfo(id: string) { |
| 107 | const running = plugins.get(id) |
| 108 | return running && { ...running.getData(), ...running } || inactivePlugins[id] |
| 109 | } |
| 110 | |
| 111 | export function findPluginByRepo<T>(repo: string) { |
| 112 | for (const pl of plugins.values()) |
| 113 | if (match(pl.getData())) |
| 114 | return pl |
| 115 | return _.find(inactivePlugins, match) |
| 116 | |
| 117 | function match(rec: any) { |
| 118 | return repo === (rec?.repo?.main ?? rec?.repo) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | export function getPluginConfigFields(id: string) { |
| 123 | return plugins.get(id)?.getData().config |
| 124 | } |
| 125 | |
| 126 | async function initPlugin(pl: any, morePassedToInit?: { id: string } & Dict) { |
| 127 | const undoEvents: any[] = [] |
| 128 | const timeouts: NodeJS.Timeout[] = [] |
| 129 | const controlledEvents = Object.create(events, objFromKeys(['on', 'once', 'multi'], k => ({ |
| 130 | value() { |
| 131 | if (k === 'multi') |
| 132 | arguments[0] = _.mapValues(arguments[0], trap) |
| 133 | else |
| 134 | arguments[1] = trap(arguments[1]) |
| 135 | const ret = (events[k] as any)(...arguments) |
| 136 | undoEvents.push(ret) |
| 137 | return ret |
| 138 | |
| 139 | function trap(cb: unknown) { |
| 140 | return (...args: any[]) => { |
| 141 | try { |
| 142 | if (!_.isFunction(cb)) return |
| 143 | const ret = cb(...args) |
| 144 | return ret instanceof Promise ? ret.catch(printError) : ret |
| 145 | } |
| 146 | catch(e) { |
| 147 | printError(e) |
| 148 | } |
| 149 | |
| 150 | function printError(e: any) { |
| 151 | const {event} = args.at(-1) |
| 152 | console.error(`plugin ${morePassedToInit?.id || '?'} on event ${event}:`, e) |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | } |
| 158 | }))) |
| 159 | const res = await pl.init?.({ |
| 160 | Const, require, |
| 161 | // intercept all subscriptions, so to be able to undo them on unload |
| 162 | events: controlledEvents, |
| 163 | log: console.log, |
| 164 | setError(msg: string) { setError(morePassedToInit?.id || 'server_code', msg) }, |
| 165 | getHfsConfig: getConfig, |
| 166 | setInterval() { // @ts-ignore |
| 167 | const ret = setInterval(...arguments) |
| 168 | timeouts.push(ret) // intervals can be canceled by clearTimeout (source: MDN) |
| 169 | return ret |
| 170 | }, |
| 171 | setTimeout(cb: (...args: any[]) => any, delay=0, ...args: any[]) { // @ts-ignore |
| 172 | let ret: NodeJS.Timeout |
| 173 | if (_.isFunction(cb)) { |
| 174 | const original = cb |
| 175 | cb = function(this: NodeJS.Timeout) { // remove fired one-shot timers so plugin unload only tracks pending work |
| 176 | _.pull(timeouts, ret) |
| 177 | return original.apply(this, args) |
| 178 | } |
| 179 | } |
| 180 | ret = setTimeout(cb, delay, ...args) as any // 'any' to allow build of frontend and admin |
| 181 | timeouts.push(ret) |
| 182 | return ret |
| 183 | }, |
| 184 | async onServer(cb: Callback<object>) { |
| 185 | const res = await getServerStatus() |
| 186 | if (res.http.srv) |
| 187 | cb(res.http.srv) |
| 188 | if (res.https.srv) |
| 189 | cb(res.https.srv) |
| 190 | controlledEvents.on('listening', ({ server }: any) => cb(server)) |
| 191 | }, |
| 192 | misc, _, |
| 193 | customApiCall, notifyClient, addBlock, ctxBelongsTo, getConnections, normalizeFilename, |
| 194 | getCurrentUsername, getAccount, getUsernames, addAccount, delAccount, updateAccount, renameAccount, |
| 195 | ...morePassedToInit |
| 196 | }) |
| 197 | Object.assign(pl, typeof res === 'function' ? { unload: res } : res) |
| 198 | patchKey(pl, 'unload', was => () => { |
| 199 | for (const x of timeouts) clearTimeout(x) |
| 200 | for (const cb of undoEvents) cb() |
| 201 | if (typeof was === 'function') |
| 202 | return was(...arguments) |
| 203 | }) |
| 204 | events.emit('pluginInitialized', pl) |
| 205 | return pl |
| 206 | } |
| 207 | |
| 208 | export const pluginsMiddleware: Koa.Middleware = async (ctx, next) => { |
| 209 | const after: Dict<CallMeAfter> = {} |
| 210 | // run middleware plugins |
| 211 | let lastStatus = ctx.status |
| 212 | let lastBody = ctx.body |
| 213 | const res = await events.emitAsync('request', { ctx }) |
| 214 | if (ctx.isAborted() || res?.isDefaultPrevented()) |
| 215 | return |
| 216 | if (res?.length) |
| 217 | after['/event'] = () => res.forEach(callable) |
| 218 | await Promise.all(mapPlugins(async (pl, id) => { |
| 219 | try { |
| 220 | const res = await pl.middleware?.(ctx) |
| 221 | printChange(id) |
| 222 | if (ctx.isAborted()) |
| 223 | ctx.stop() |
| 224 | // don't just check ctx.isStopped, as the async plugin that called ctx.stop will reach here after sync ones |
| 225 | if (ctx.isStopped && !ctx.pluginBlockedRequest) |
| 226 | console.debug("Plugin blocked request", ctx.pluginBlockedRequest = id) |
| 227 | if (typeof res === 'function') |
| 228 | after[id] = res |
| 229 | } |
| 230 | catch(e){ |
| 231 | printError(id, e) |
| 232 | } |
| 233 | })) |
| 234 | // expose public plugins' files |
| 235 | if (!ctx.isStopped) { |
| 236 | const { path } = ctx |
| 237 | if (path.startsWith(PLUGINS_PUB_URI)) { |
| 238 | const a = path.substring(PLUGINS_PUB_URI.length).split('/') |
| 239 | const name = a.shift()! |
| 240 | if (plugins.has(name)) { // do it only if the plugin is loaded |
| 241 | if (ctx.get('referer')?.endsWith('/')) |
| 242 | ctx.state.considerAsGui = true |
| 243 | await serveFile(ctx, plugins.get(name)!.folder + '/public/' + a.join('/'), MIME_AUTO) |
| 244 | } |
| 245 | return |
| 246 | } |
| 247 | if (ctx.body === undefined && ctx.status === HTTP_NOT_FOUND) // no response was provided by plugins, so we'll do |
| 248 | await next() |
| 249 | } |
| 250 | lastStatus = ctx.status |
| 251 | lastBody = ctx.body |
| 252 | for (const [id, f] of Object.entries(after)) |
| 253 | try { |
| 254 | await f() |
| 255 | printChange(id) |
| 256 | } |
| 257 | catch (e) { printError(id, e) } |
| 258 | |
| 259 | |
| 260 | function printChange(id: string) { |
| 261 | if (id === SERVER_CODE_ID || (lastStatus === ctx.status && lastBody === ctx.body)) return |
| 262 | console.debug("Plugin changed response:", id) |
| 263 | lastStatus = ctx.status |
| 264 | lastBody = ctx.body |
| 265 | } |
| 266 | |
| 267 | function printError(id: string, e: any) { |
| 268 | console.log(`Error middleware plugin ${id}: ${e?.message || e}`) |
| 269 | console.debug(e) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | declare module "koa" { |
| 274 | interface BaseContext { |
| 275 | stop(): void |
| 276 | } |
| 277 | } |
| 278 | events.once('app', () => Object.assign(app.context, { |
| 279 | isStopped: false, |
| 280 | stop() { return this.isStopped = true } |
| 281 | })) |
| 282 | |
| 283 | // return false to ask to exclude this entry from results |
| 284 | interface OnDirEntryParams { entry:DirEntry, ctx:Koa.Context, node:VfsNode } |
| 285 | type OnDirEntry = (params:OnDirEntryParams) => Promisable<unknown | false> |
| 286 | |
| 287 | export class Plugin implements CommonPluginInterface { |
| 288 | started: Date | null = new Date() |
| 289 | icons: CustomizedIcons |
| 290 | log: { ts: Date, msg: string }[] |
| 291 | |
| 292 | constructor(readonly id:string, readonly folder:string, private readonly data:any, private onUnload:()=>unknown){ |
| 293 | if (!data) throw 'invalid data' |
| 294 | |
| 295 | this.log = [] |
| 296 | this.data = data = { ...data } // clone to make object modifiable. Objects coming from import are not. |
| 297 | // some validation |
| 298 | for (const k of ['frontend_css', 'frontend_js']) { |
| 299 | const v = data[k] |
| 300 | if (typeof v === 'string') |
| 301 | data[k] = [v] |
| 302 | else if (v && !Array.isArray(v)) { |
| 303 | delete data[k] |
| 304 | console.warn('Invalid', k) |
| 305 | } |
| 306 | } |
| 307 | plugins.set(id, this) |
| 308 | |
| 309 | const keys = Array.from(plugins.keys()) |
| 310 | const idx = keys.indexOf(id) |
| 311 | // initialize moveDown with existing plugins that want to be after this |
| 312 | const moveDown = onlyTruthy(mapPlugins(((pl, plId, plIdx) => pl.afterPlugin === id && plIdx < idx && plId))) |
| 313 | const {beforePlugin, afterPlugin} = data // then consider what this plugin wants |
| 314 | if (afterPlugin && keys.indexOf(afterPlugin) > idx) |
| 315 | moveDown.push(id) // move down this plugin |
| 316 | if (beforePlugin && keys.indexOf(beforePlugin) < idx) |
| 317 | moveDown.push(beforePlugin) // move down the other plugin |
| 318 | for (const k of moveDown) { |
| 319 | const temp = plugins.get(k) |
| 320 | if (!temp) continue |
| 321 | plugins.delete(k) |
| 322 | plugins.set(k, temp) |
| 323 | } |
| 324 | } |
| 325 | get version(): undefined | number { return this.data?.version } |
| 326 | get description(): undefined | string { return this.data?.description } |
| 327 | get apiRequired(): undefined | number | [number,number] { return this.data?.apiRequired } |
| 328 | get isTheme(): undefined | boolean { return this.data?.isTheme } |
| 329 | get repo(): undefined | Repo { return this.data?.repo } |
| 330 | get depend(): undefined | Depend { return this.data?.depend } |
| 331 | get afterPlugin(): undefined | string { return this.data?.afterPlugin } |
| 332 | get beforePlugin(): undefined | string { return this.data?.beforePlugin } |
| 333 | |
| 334 | get middleware(): undefined | PluginMiddleware { |
| 335 | return this.data?.middleware |
| 336 | } |
| 337 | get frontend_css(): undefined | string[] { |
| 338 | return this.data?.frontend_css |
| 339 | } |
| 340 | get frontend_js(): undefined | string[] { |
| 341 | return this.data?.frontend_js |
| 342 | } |
| 343 | get onDirEntry(): undefined | OnDirEntry { |
| 344 | return this.data?.onDirEntry |
| 345 | } |
| 346 | |
| 347 | getData(): any { |
| 348 | return this.data |
| 349 | } |
| 350 | |
| 351 | async unload(reloading=false) { |
| 352 | if (!this.started) return |
| 353 | this.started = null |
| 354 | const { id } = this |
| 355 | try { await this.data?.unload?.() } |
| 356 | catch(e) { |
| 357 | console.log('Error unloading plugin', id, String(e)) |
| 358 | } |
| 359 | await this.onUnload() |
| 360 | if (!reloading && id !== SERVER_CODE_ID) // we already printed 'reloading' |
| 361 | console.log('Unloaded plugin', id) |
| 362 | if (this.data) |
| 363 | this.data.unload = undefined |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | export const SERVER_CODE_ID = '.' // a name that will surely be not found among plugin folders |
| 368 | const serverCode = defineConfig('server_code', '', async (script, { k }) => { |
| 369 | try { (await serverCode.compiled())?.unload() } |
| 370 | catch {} |
| 371 | const res: any = {} |
| 372 | try { |
| 373 | new Function('exports,require', script)(res, require) // parse |
| 374 | await initPlugin(res) |
| 375 | res.getCustomHtml = () => callable(res.customHtml) || {} |
| 376 | return new Plugin(SERVER_CODE_ID, '', res, _.noop) |
| 377 | } |
| 378 | catch (e: any) { |
| 379 | return console.error(k + ':', e.message || String(e)) |
| 380 | } |
| 381 | }) |
| 382 | |
| 383 | export function mapPlugins<T>(cb:(plugin:Readonly<Plugin>, pluginName:string, idx:number)=> T, includeServerCode=true) { |
| 384 | let i = 0 |
| 385 | return Array.from(plugins).map(([plName,pl]) => { |
| 386 | if (!includeServerCode && plName === SERVER_CODE_ID) return |
| 387 | try { return cb(pl,plName,i++) } |
| 388 | catch(e) { |
| 389 | console.log('Plugin error', plName, String(e)) |
| 390 | } |
| 391 | }).filter(x => x !== undefined) as Exclude<T,undefined>[] |
| 392 | } |
| 393 | |
| 394 | export function firstPlugin<T>(cb:(plugin:Readonly<Plugin>, pluginName:string)=> T, includeServerCode=true) { |
| 395 | for (const [plName, pl] of plugins.entries()) { |
| 396 | if (!includeServerCode && plName === SERVER_CODE_ID) continue |
| 397 | try { |
| 398 | const ret = cb(pl,plName) |
| 399 | if (ret !== undefined) |
| 400 | return ret |
| 401 | } |
| 402 | catch(e) { |
| 403 | console.log('Plugin error', plName, String(e)) |
| 404 | } |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | type PluginMiddleware = (ctx:Koa.Context) => Promisable<void | Stop | CallMeAfter> |
| 409 | type Stop = true |
| 410 | type CallMeAfter = ()=>any |
| 411 | |
| 412 | export type Repo = string | { web?: string, main: string, zip?: string, zipRoot?: string } // string is github, object is custom |
| 413 | type Depend = { repo: string, version?: number }[] |
| 414 | export interface CommonPluginInterface { |
| 415 | id: string |
| 416 | description?: string |
| 417 | version?: number |
| 418 | apiRequired?: number | [number,number] |
| 419 | repo?: Repo |
| 420 | depend?: Depend |
| 421 | isTheme?: boolean | 'light' | 'dark' |
| 422 | preview?: string | string[] |
| 423 | changelog?: unknown |
| 424 | } |
| 425 | export interface InactivePlugin extends CommonPluginInterface { |
| 426 | branch?: string |
| 427 | badApi?: string |
| 428 | error?: string |
| 429 | } |
| 430 | |
| 431 | let inactivePlugins: Record<string, InactivePlugin> = {} |
| 432 | |
| 433 | export function getInactivePlugins() { |
| 434 | return Object.values(inactivePlugins) |
| 435 | } |
| 436 | |
| 437 | const rescanAsap = debounceAsync(rescan, { wait: 1000 }) |
| 438 | if (!existsSync(PATH)) |
| 439 | try { mkdirSync(PATH) } |
| 440 | catch {} |
| 441 | export const pluginsWatcher = watchDir(PATH, rescanAsap) |
| 442 | |
| 443 | export const enablePlugins = defineConfig('enable_plugins', ['antibrute']) |
| 444 | enablePlugins.sub(rescanAsap) |
| 445 | |
| 446 | export const suspendPlugins = defineConfig(CFG.suspend_plugins, false) |
| 447 | |
| 448 | export const pluginsConfig = defineConfig('plugins_config', {} as Record<string,any>) |
| 449 | export const PLUGIN_MAIN_FILE = 'plugin.js' |
| 450 | |
| 451 | const pluginWatchers = new Map<string, ReturnType<typeof watchPlugin>>() |
| 452 | |
| 453 | export async function rescan() { |
| 454 | console.debug('Scanning plugins') |
| 455 | const patterns = [PATH + '/*'] |
| 456 | if (APP_PATH !== process.cwd()) |
| 457 | patterns.unshift(escapeGlobPath(APP_PATH) + '/' + patterns[0]) // first search bundled plugins, because otherwise they won't be loaded because of the folders with same name in .hfs/plugins (used for storage) |
| 458 | const existing = new Set<string>() |
| 459 | for (const { path, dirent } of await glob(patterns, { onlyFiles: false, suppressErrors: true, objectMode: true })) { |
| 460 | if (!dirent.isDirectory() || path.endsWith(DISABLING_SUFFIX)) continue |
| 461 | const id = path.split('/').slice(-1)[0]! |
| 462 | existing.add(id) |
| 463 | if (!pluginWatchers.has(id)) |
| 464 | pluginWatchers.set(id, watchPlugin(id, join(path, PLUGIN_MAIN_FILE))) |
| 465 | } |
| 466 | for (const [id, cancelWatcher] of pluginWatchers.entries()) |
| 467 | if (!existing.has(id)) { |
| 468 | enablePlugin(id, false) |
| 469 | cancelWatcher() |
| 470 | pluginWatchers.delete(id) |
| 471 | } |
| 472 | pluginsScanned.resolve() |
| 473 | } |
| 474 | |
| 475 | function watchPlugin(id: string, path: string) { |
| 476 | console.debug('Plugin watch', id) |
| 477 | const module = resolve(path) |
| 478 | let starting: PendingPromise | undefined |
| 479 | const unsub = subMultipleConfigs(() => { |
| 480 | if (!getPluginInfo(id)) return // not loaded yet |
| 481 | const should = isPluginEnabled(id, true) |
| 482 | if (should === isPluginRunning(id)) return |
| 483 | if (should) |
| 484 | return start() |
| 485 | stop() |
| 486 | }, [enablePlugins, suspendPlugins]) |
| 487 | const { unwatch } = watchLoad(module, async source => { |
| 488 | const notRunning = inactivePlugins[id] |
| 489 | if (!source) |
| 490 | return onUninstalled() |
| 491 | if (isPluginEnabled(id, true)) |
| 492 | return start() |
| 493 | const p = parsePluginSource(id, source) // plugin not running = json parsing |
| 494 | if (same(notRunning, p)) return |
| 495 | inactivePlugins[id] = p |
| 496 | events.emit(notRunning ? 'pluginUpdated' : 'pluginInstalled', p) |
| 497 | }) |
| 498 | return () => { |
| 499 | console.debug('Plugin unwatch', id) |
| 500 | unsub() |
| 501 | unwatch() |
| 502 | return onUninstalled() |
| 503 | } |
| 504 | |
| 505 | async function onUninstalled() { |
| 506 | await stop() |
| 507 | const info = getPluginInfo(id) |
| 508 | if (!info) return // already missing |
| 509 | delete inactivePlugins[id] |
| 510 | events.emit('pluginUninstalled', id, info.repo) |
| 511 | } |
| 512 | |
| 513 | async function markItInactive() { |
| 514 | plugins.delete(id) |
| 515 | inactivePlugins[id] = await parsePlugin() |
| 516 | } |
| 517 | |
| 518 | async function parsePlugin() { |
| 519 | return parsePluginSource(id, await readFile(module, 'utf8')) |
| 520 | } |
| 521 | |
| 522 | async function stop() { |
| 523 | await starting |
| 524 | const p = plugins.get(id) |
| 525 | if (!p) return |
| 526 | await p.unload() |
| 527 | await markItInactive().catch(() => |
| 528 | events.emit('pluginUninstalled', id, p.repo)) // when a running plugin is deleted, avoid error and report |
| 529 | events.emit('pluginStopped', p) |
| 530 | } |
| 531 | |
| 532 | async function start() { |
| 533 | if (starting) return |
| 534 | try { |
| 535 | starting = pendingPromise() |
| 536 | // if dependencies are not ready right now, we give some time. Not super-solid but good enough for now. |
| 537 | const info = await parsePlugin() |
| 538 | if (!await waitFor(() => _.isEmpty(getMissingDependencies(info)), { timeout: 5_000 })) |
| 539 | throw Error("plugin missing dependencies: " + _.map(getMissingDependencies(info), x => x.repo).join(', ')) |
| 540 | if (getPluginInfo(id)) |
| 541 | setError(id, '') |
| 542 | const alreadyRunning = plugins.get(id) |
| 543 | console.log(alreadyRunning ? "Reloading plugin" : "Loading plugin", id) |
| 544 | const pluginData = require(module) |
| 545 | deleteModule(require.resolve(module)) // avoid caching at next import |
| 546 | calculateBadApi(pluginData) |
| 547 | if (pluginData.badApi) |
| 548 | throw Error(pluginData.badApi) |
| 549 | |
| 550 | await alreadyRunning?.unload(true) |
| 551 | console.debug("Starting plugin", id) |
| 552 | const storageDir = resolve(PATH, id, STORAGE_FOLDER) + (IS_WINDOWS ? '\\' : '/') |
| 553 | await mkdir(storageDir, { recursive: true }) |
| 554 | const openDbs: KvStorage[] = [] |
| 555 | const subbedConfigs: Callback[] = [] |
| 556 | const pluginReady = pendingPromise() |
| 557 | const MAX_LOG = 100 |
| 558 | await initPlugin(pluginData, { // following properties are not available in server_code |
| 559 | id, |
| 560 | srcDir: __dirname, |
| 561 | storageDir, |
| 562 | async openDb(filename: string, options?: KvStorageOptions){ |
| 563 | if (!filename) throw Error("missing filename") |
| 564 | const db = new KvStorage(options) |
| 565 | await db.open(join(storageDir, filename)) |
| 566 | openDbs.push(db) |
| 567 | return db |
| 568 | }, |
| 569 | log(...args: any[]) { |
| 570 | console.log(`Plugin "${id}":`, ...args) |
| 571 | pluginReady.then(() => { // log() maybe invoked during init(), while plugin is undefined |
| 572 | if (!plugin) return |
| 573 | const msg = { ts: new Date, msg: args.map(x => x && typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' ') } |
| 574 | plugin.log.push(msg) |
| 575 | if (plugin.log.length > MAX_LOG) |
| 576 | plugin.log.splice(0, 10) // truncate |
| 577 | events.emit('pluginLog:' + id, msg) |
| 578 | events.emit('pluginLog', id, msg) |
| 579 | }) |
| 580 | }, |
| 581 | getConfig(cfgKey?: string) { |
| 582 | const cur = pluginsConfig.get()?.[id] |
| 583 | return cfgKey ? cur?.[cfgKey] ?? pluginData.config?.[cfgKey]?.defaultValue |
| 584 | : _.defaults(cur, _.mapValues(pluginData.config, x => x.defaultValue)) |
| 585 | }, |
| 586 | setConfig: (cfgKey: string, value: any) => |
| 587 | setPluginConfig(id, { [cfgKey]: value }), |
| 588 | subscribeConfig(cfgKey: string | string[], cb: Callback<any>) { |
| 589 | const get = () => Array.isArray(cfgKey) ? objFromKeys(cfgKey, k => this.getConfig(k)) |
| 590 | : this.getConfig(cfgKey) |
| 591 | let last = get() |
| 592 | cb(last) |
| 593 | const ret = pluginsConfig.sub(() => { |
| 594 | const now = get() |
| 595 | if (same(now, last)) return |
| 596 | try { cb(last = now) } |
| 597 | catch(e){ this.log(String(e)) } |
| 598 | }) |
| 599 | subbedConfigs.push(ret) |
| 600 | return ret |
| 601 | }, |
| 602 | async i18n(ctx: any) { |
| 603 | return i18nFromTranslations(await getLangData(ctx), EMBEDDED_LANGUAGE) |
| 604 | }, |
| 605 | }) |
| 606 | const folder = dirname(module) |
| 607 | const { sections, unwatch } = watchLoadCustomHtml(folder) |
| 608 | pluginData.getCustomHtml = () => |
| 609 | Object.assign(Object.fromEntries(sections), callable(pluginData.customHtml) || {}) |
| 610 | |
| 611 | const unwatchIcons = watchIconsFolder(folder, v => plugin.icons = v) |
| 612 | const plugin = new Plugin(id, folder, pluginData, async () => { |
| 613 | unwatchIcons() |
| 614 | unwatch() |
| 615 | for (const x of subbedConfigs) x() |
| 616 | await Promise.allSettled(openDbs.map(x => x.close())) |
| 617 | openDbs.length = 0 |
| 618 | }) |
| 619 | pluginReady.resolve() |
| 620 | if (alreadyRunning) |
| 621 | events.emit('pluginUpdated', Object.assign(_.pick(plugin, 'started'), getPluginInfo(id))) |
| 622 | else { |
| 623 | const wasInstalled = inactivePlugins[id] |
| 624 | if (wasInstalled) |
| 625 | delete inactivePlugins[id] |
| 626 | events.emit(wasInstalled ? 'pluginStarted' : 'pluginInstalled', plugin) |
| 627 | } |
| 628 | events.emit('pluginStarted:'+id) |
| 629 | } catch (e: any) { |
| 630 | await markItInactive() |
| 631 | const parsed = e.stack?.split('\n\n') // this form is used by syntax-errors inside the plugin, which is useful to show |
| 632 | const where = parsed?.length > 1 ? `\n${parsed[0]}` : '' |
| 633 | e = prefix('', e.message, where) || String(e) |
| 634 | setError(id, e) |
| 635 | } |
| 636 | finally { |
| 637 | starting?.resolve() |
| 638 | starting = undefined |
| 639 | } |
| 640 | |
| 641 | } |
| 642 | } |
| 643 | |
| 644 | function customApiCall(method: string, ...params: any[]) { |
| 645 | return mapPlugins(pl => pl.getData().customApi?.[method]?.(...params)) |
| 646 | } |
| 647 | |
| 648 | function getError(id: string) { |
| 649 | return getPluginInfo(id)?.error as undefined | string |
| 650 | } |
| 651 | |
| 652 | // returns true if there's an error, and it has changed |
| 653 | function setError(id: string, error: string) { |
| 654 | const info = getPluginInfo(id) |
| 655 | if (!info) return |
| 656 | if (info.error === error) return |
| 657 | info.error = error |
| 658 | events.emit('pluginUpdated', info) |
| 659 | if (!error) return |
| 660 | console.warn(`Plugin error: ${id}:`, error) |
| 661 | return true |
| 662 | } |
| 663 | |
| 664 | function deleteModule(id: string) { |
| 665 | const { cache } = require |
| 666 | // build reversed map of dependencies |
| 667 | const requiredBy: Record<string,string[]> = { '.':['.'] } // don't touch main entry |
| 668 | for (const k in cache) |
| 669 | if (k !== id) |
| 670 | for (const child of wantArray(cache[k]?.children)) |
| 671 | (requiredBy[child.id] ||= []).push(k) |
| 672 | const deleted: string[] = [] |
| 673 | ;(function deleteCache(id: string) { |
| 674 | const mod = cache[id] |
| 675 | if (!mod) return |
| 676 | delete cache[id] |
| 677 | deleted.push(id) |
| 678 | for (const child of mod.children) |
| 679 | if (! _.difference(requiredBy[child.id], deleted).length) |
| 680 | deleteCache(child.id) |
| 681 | })(id) |
| 682 | } |
| 683 | |
| 684 | onProcessExit(() => |
| 685 | Promise.allSettled(mapPlugins(pl => pl.unload()))) |
| 686 | |
| 687 | export function parsePluginSource(id: string, source: string) { |
| 688 | const pl: InactivePlugin = { id } |
| 689 | pl.description = tryJson(/exports.description\s*=\s*(".*")/.exec(source)?.[1]) |
| 690 | pl.repo = tryJson(/exports.repo\s*=\s*([^\s;]+)/.exec(source)?.[1]) |
| 691 | pl.version = Number(/exports.version\s*=\s*(\d*\.?\d+)/.exec(source)?.[1]) ?? undefined |
| 692 | pl.apiRequired = tryJson(/exports.apiRequired\s*=\s*([ \d.,[\]]+)/.exec(source)?.[1]) ?? undefined |
| 693 | pl.isTheme = tryJson(/exports.isTheme\s*=\s*(true|false|"light"|"dark")/.exec(source)?.[1]) ?? (id.endsWith('-theme') || undefined) |
| 694 | pl.preview = tryJson(/exports.preview\s*=\s*("(?:[^"\\]|\\.)*"|\[[\s\S]*?\])/.exec(source)?.[1]) ?? undefined |
| 695 | pl.depend = tryJson(/exports.depend\s*=\s*(\[[\s\S]*?])/m.exec(source)?.[1])?.filter((x: any) => |
| 696 | typeof x.repo === 'string' && x.version === undefined || typeof x.version === 'number' |
| 697 | || console.warn("Plugin dependency discarded", x) ) |
| 698 | pl.changelog = tryJson(/exports.changelog\s*=\s*(\[[\s\S]*?])/m.exec(source)?.[1]) |
| 699 | if (Array.isArray(pl.apiRequired) && (pl.apiRequired.length !== 2 || !pl.apiRequired.every(_.isFinite))) // validate [from,to] form |
| 700 | pl.apiRequired = undefined |
| 701 | calculateBadApi(pl) |
| 702 | return pl |
| 703 | } |
| 704 | |
| 705 | function calculateBadApi(data: InactivePlugin) { |
| 706 | const r = data.apiRequired |
| 707 | const [min=0, max=Infinity] = Array.isArray(r) ? r : [r] // normalize data type |
| 708 | data.badApi = !r ? "missing mandatory property apiRequired" |
| 709 | : min > API_VERSION ? "may not work correctly as it is designed for a newer version of HFS - check for updates" |
| 710 | : min < COMPATIBLE_API_VERSION || max < API_VERSION ? "may not work correctly as it is designed for an older version of HFS - check for updates" |
| 711 | : undefined |
| 712 | } |
| 713 | |
| 714 | export function getMissingDependencies(plugin: CommonPluginInterface) { |
| 715 | return onlyTruthy((plugin?.depend || []).map((dep: any) => { |
| 716 | const res = findPluginByRepo(dep.repo) |
| 717 | const error = !res ? 'missing' |
| 718 | : (res.version || 0) < dep.version ? 'version' |
| 719 | : !isPluginEnabled(res.id) ? 'disabled' |
| 720 | : !isPluginRunning(res.id) ? 'stopped' |
| 721 | : '' |
| 722 | return error && { repo: dep.repo, error, id: res?.id } |
| 723 | })) |
| 724 | } |