From 62bd7e73f42672e6371395a113f33fed6a3530f4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 19 Dec 2024 17:48:18 -0600 Subject: [PATCH 01/14] Example of potential plugin implementation --- .gitignore | 1 + client/pages/item/_id/index.vue | 39 +++++ client/pages/login.vue | 3 +- client/store/index.js | 19 ++- index.js | 4 +- prod.js | 9 +- server/Auth.js | 2 + server/PluginAbstract.js | 20 +++ server/Server.js | 7 +- server/controllers/PluginController.js | 24 +++ server/managers/PluginManager.js | 145 ++++++++++++++++++ server/routers/ApiRouter.js | 6 + test/server/managers/PluginManager.test.js | 5 + test/server/managers/plugins/Example/index.js | 18 +++ .../managers/plugins/Example/manifest.json | 52 +++++++ 15 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 server/PluginAbstract.js create mode 100644 server/controllers/PluginController.js create mode 100644 server/managers/PluginManager.js create mode 100644 test/server/managers/PluginManager.test.js create mode 100644 test/server/managers/plugins/Example/index.js create mode 100644 test/server/managers/plugins/Example/manifest.json diff --git a/.gitignore b/.gitignore index 1eda8e1fa8..d375bae085 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /podcasts/ /media/ /metadata/ +/plugins/ /client/.nuxt/ /client/dist/ /dist/ diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 139794e555..c540fec7f8 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -364,6 +364,9 @@ export default { showCollectionsButton() { return this.isBook && this.userCanUpdate }, + pluginExtensions() { + return this.$store.getters['getPluginExtensions']('item.detail.actions') + }, contextMenuItems() { const items = [] @@ -429,6 +432,18 @@ export default { }) } + if (this.pluginExtensions.length) { + this.pluginExtensions.forEach((plugin) => { + const pluginSlug = plugin.slug + plugin.extensions.forEach((pext) => { + items.push({ + text: pext.label, + action: `plugin-${pluginSlug}-action-${pext.name}` + }) + }) + }) + } + return items } }, @@ -763,7 +778,31 @@ export default { } else if (action === 'share') { this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('globals/setShareModal', this.mediaItemShare) + } else if (action.startsWith('plugin-')) { + const actionStrSplit = action.replace('plugin-', '').split('-action-') + const pluginSlug = actionStrSplit[0] + const pluginAction = actionStrSplit[1] + console.log('Plugin action for', pluginSlug, 'with action', pluginAction) + this.onPluginAction(pluginSlug, pluginAction) } + }, + onPluginAction(pluginSlug, pluginAction) { + this.$axios + .$post(`/api/plugins/action`, { + pluginSlug, + pluginAction, + target: 'item.detail.actions', + data: { + entityId: this.libraryItemId, + entityType: 'libraryItem' + } + }) + .then((data) => { + console.log('Plugin action response', data) + }) + .catch((error) => { + console.error('Plugin action failed', error) + }) } }, mounted() { diff --git a/client/pages/login.vue b/client/pages/login.vue index a853def452..df7ca109eb 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -166,10 +166,11 @@ export default { location.reload() }, - setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) { + setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, pluginExtensions }) { this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setSource', Source) this.$store.commit('libraries/setEReaderDevices', ereaderDevices) + this.$store.commit('setPluginExtensions', pluginExtensions) this.$setServerLanguageCode(serverSettings.language) if (serverSettings.chromecastEnabled) { diff --git a/client/store/index.js b/client/store/index.js index 2f2201b66c..cdac75c254 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -28,7 +28,8 @@ export const state = () => ({ openModal: null, innerModalOpen: false, lastBookshelfScrollData: {}, - routerBasePath: '/' + routerBasePath: '/', + pluginExtensions: [] }) export const getters = { @@ -61,6 +62,19 @@ export const getters = { getHomeBookshelfView: (state) => { if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD return state.serverSettings.homeBookshelfView + }, + getPluginExtensions: (state) => (target) => { + return state.pluginExtensions + .map((pext) => { + const extensionsMatchingTarget = pext.extensions.filter((ext) => ext.target === target) + if (!extensionsMatchingTarget.length) return null + return { + name: pext.name, + slug: pext.slug, + extensions: extensionsMatchingTarget + } + }) + .filter(Boolean) } } @@ -239,5 +253,8 @@ export const mutations = { }, setInnerModalOpen(state, val) { state.innerModalOpen = val + }, + setPluginExtensions(state, val) { + state.pluginExtensions = val } } diff --git a/index.js b/index.js index 9a0be347cc..d51ce4b2cf 100644 --- a/index.js +++ b/index.js @@ -13,6 +13,7 @@ if (isDev) { if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath + if (devEnv.PluginsPath) process.env.PLUGINS_PATH = devEnv.PluginsPath process.env.SOURCE = 'local' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' } @@ -21,10 +22,11 @@ const PORT = process.env.PORT || 80 const HOST = process.env.HOST const CONFIG_PATH = process.env.CONFIG_PATH || '/config' const METADATA_PATH = process.env.METADATA_PATH || '/metadata' +const PLUGINS_PATH = process.env.PLUGINS_PATH || '/plugins' const SOURCE = process.env.SOURCE || 'docker' const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '' console.log('Config', CONFIG_PATH, METADATA_PATH) -const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) +const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, PLUGINS_PATH, ROUTER_BASE_PATH) Server.start() diff --git a/prod.js b/prod.js index 70633d5b23..4d09a4f5b8 100644 --- a/prod.js +++ b/prod.js @@ -1,6 +1,7 @@ const optionDefinitions = [ { name: 'config', alias: 'c', type: String }, { name: 'metadata', alias: 'm', type: String }, + { name: 'plugins', alias: 'l', type: String }, { name: 'port', alias: 'p', type: String }, { name: 'host', alias: 'h', type: String }, { name: 'source', alias: 's', type: String } @@ -16,18 +17,20 @@ const server = require('./server/Server') global.appRoot = __dirname -var inputConfig = options.config ? Path.resolve(options.config) : null -var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null +const inputConfig = options.config ? Path.resolve(options.config) : null +const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null +const inputPlugins = options.plugins ? Path.resolve(options.plugins) : null const PORT = options.port || process.env.PORT || 3333 const HOST = options.host || process.env.HOST const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config') const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata') +const PLUGINS_PATH = inputPlugins || process.env.PLUGINS_PATH || Path.resolve('plugins') const SOURCE = options.source || process.env.SOURCE || 'debian' const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '' console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH) -const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) +const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, PLUGINS_PATH, ROUTER_BASE_PATH) Server.start() diff --git a/server/Auth.js b/server/Auth.js index 74b767f5b1..79237cfa11 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -10,6 +10,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt const OpenIDClient = require('openid-client') const Database = require('./Database') const Logger = require('./Logger') +const PluginManager = require('./managers/PluginManager') /** * @class Class for handling all the authentication related functionality. @@ -938,6 +939,7 @@ class Auth { userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), serverSettings: Database.serverSettings.toJSONForBrowser(), ereaderDevices: Database.emailSettings.getEReaderDevices(user), + pluginExtensions: PluginManager.pluginExtensions, Source: global.Source } } diff --git a/server/PluginAbstract.js b/server/PluginAbstract.js new file mode 100644 index 0000000000..d5d56effde --- /dev/null +++ b/server/PluginAbstract.js @@ -0,0 +1,20 @@ +class PluginAbstract { + constructor() { + if (this.constructor === PluginAbstract) { + throw new Error('Cannot instantiate abstract class') + } + } + + init() { + throw new Error('Method "init()" not implemented') + } + + onAction() { + throw new Error('Method "onAction()" not implemented') + } + + onDestroy() { + throw new Error('Method "onDestroy()" not implemented') + } +} +module.exports = PluginAbstract diff --git a/server/Server.js b/server/Server.js index 95e3d68356..837eab2090 100644 --- a/server/Server.js +++ b/server/Server.js @@ -36,6 +36,7 @@ const ApiCacheManager = require('./managers/ApiCacheManager') const BinaryManager = require('./managers/BinaryManager') const ShareManager = require('./managers/ShareManager') const LibraryScanner = require('./scanner/LibraryScanner') +const PluginManager = require('./managers/PluginManager') //Import the main Passport and Express-Session library const passport = require('passport') @@ -43,7 +44,7 @@ const expressSession = require('express-session') const MemoryStore = require('./libs/memorystore') class Server { - constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) { + constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, PLUGINS_PATH, ROUTER_BASE_PATH) { this.Port = PORT this.Host = HOST global.Source = SOURCE @@ -51,6 +52,7 @@ class Server { global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH)) global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH)) global.RouterBasePath = ROUTER_BASE_PATH + global.PluginsPath = fileUtils.filePathToPOSIX(Path.normalize(PLUGINS_PATH)) global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1' @@ -151,6 +153,9 @@ class Server { LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask) }) } + + // Initialize plugins + PluginManager.init() } /** diff --git a/server/controllers/PluginController.js b/server/controllers/PluginController.js new file mode 100644 index 0000000000..aed2f14083 --- /dev/null +++ b/server/controllers/PluginController.js @@ -0,0 +1,24 @@ +const { Request, Response } = require('express') +const PluginManager = require('../managers/PluginManager') +const Logger = require('../Logger') + +class PluginController { + constructor() {} + + /** + * POST: /api/plugins/action + * + * @param {Request} req + * @param {Response} res + */ + handleAction(req, res) { + const pluginSlug = req.body.pluginSlug + const actionName = req.body.pluginAction + const target = req.body.target + const data = req.body.data + Logger.info(`[PluginController] Handle plugin action ${pluginSlug} ${actionName} ${target}`, data) + PluginManager.onAction(pluginSlug, actionName, target, data) + res.sendStatus(200) + } +} +module.exports = new PluginController() diff --git a/server/managers/PluginManager.js b/server/managers/PluginManager.js new file mode 100644 index 0000000000..d72362993e --- /dev/null +++ b/server/managers/PluginManager.js @@ -0,0 +1,145 @@ +const Path = require('path') +const Logger = require('../Logger') +const PluginAbstract = require('../PluginAbstract') +const fs = require('fs').promises + +class PluginManager { + constructor() { + this.plugins = [] + } + + get pluginExtensions() { + return this.plugins + .filter((plugin) => plugin.manifest.extensions?.length) + .map((plugin) => { + return { + name: plugin.manifest.name, + slug: plugin.manifest.slug, + extensions: plugin.manifest.extensions + } + }) + } + + get pluginContext() { + return { + Logger + } + } + + /** + * + * @param {string} pluginPath + * @returns {Promise<{manifest: Object, contents: PluginAbstract}>} + */ + async loadPlugin(pluginPath) { + const pluginFiles = await fs.readdir(pluginPath, { withFileTypes: true }).then((files) => files.filter((file) => !file.isDirectory())) + + if (!pluginFiles.length) { + Logger.error(`No files found in plugin ${pluginPath}`) + return null + } + const manifestFile = pluginFiles.find((file) => file.name === 'manifest.json') + if (!manifestFile) { + Logger.error(`No manifest found for plugin ${pluginPath}`) + return null + } + const indexFile = pluginFiles.find((file) => file.name === 'index.js') + if (!indexFile) { + Logger.error(`No index file found for plugin ${pluginPath}`) + return null + } + + let manifestJson = null + try { + manifestJson = await fs.readFile(Path.join(pluginPath, manifestFile.name), 'utf8').then((data) => JSON.parse(data)) + } catch (error) { + Logger.error(`Error parsing manifest file for plugin ${pluginPath}`, error) + return null + } + + // TODO: Validate manifest json + + let pluginContents = null + try { + pluginContents = require(Path.join(pluginPath, indexFile.name)) + } catch (error) { + Logger.error(`Error loading plugin ${pluginPath}`, error) + return null + } + + return { + manifest: manifestJson, + contents: pluginContents + } + } + + async loadPlugins() { + const pluginDirs = await fs.readdir(global.PluginsPath, { withFileTypes: true, recursive: true }).then((files) => files.filter((file) => file.isDirectory())) + console.log('pluginDirs', pluginDirs) + + for (const pluginDir of pluginDirs) { + Logger.info(`[PluginManager] Loading plugin ${pluginDir.name}`) + const plugin = await this.loadPlugin(Path.join(global.PluginsPath, pluginDir.name)) + if (plugin) { + Logger.info(`[PluginManager] Loaded plugin ${plugin.manifest.name}`) + this.plugins.push(plugin) + } + } + } + + async init() { + await this.loadPlugins() + + for (const plugin of this.plugins) { + if (plugin.contents.init) { + Logger.info(`[PluginManager] Initializing plugin ${plugin.manifest.name}`) + plugin.contents.init(this.pluginContext) + } + } + } + + onAction(pluginSlug, actionName, target, data) { + const plugin = this.plugins.find((plugin) => plugin.manifest.slug === pluginSlug) + if (!plugin) { + Logger.error(`[PluginManager] Plugin ${pluginSlug} not found`) + return + } + + const pluginExtension = plugin.manifest.extensions.find((extension) => extension.name === actionName) + if (!pluginExtension) { + Logger.error(`[PluginManager] Extension ${actionName} not found for plugin ${plugin.manifest.name}`) + return + } + + if (plugin.contents.onAction) { + Logger.info(`[PluginManager] Calling onAction for plugin ${plugin.manifest.name}`) + plugin.contents.onAction(this.pluginContext, actionName, target, data) + } + } + + pluginExists(name) { + return this.plugins.some((plugin) => plugin.name === name) + } + + registerPlugin(plugin) { + if (!plugin.name) { + throw new Error('The plugin name and package are required') + } + + if (this.pluginExists(plugin.name)) { + throw new Error(`Cannot add existing plugin ${plugin.name}`) + } + + try { + // Try to load the plugin + const pluginPath = Path.join(global.PluginsPath, plugin.name) + const packageContents = require(pluginPath) + console.log('packageContents', packageContents) + packageContents.init() + this.plugins.push(packageContents) + } catch (error) { + console.log(`Cannot load plugin ${plugin.name}`, error) + } + } +} +module.exports = new PluginManager() diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 235d25cd5f..5d4187b852 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -33,6 +33,7 @@ const RSSFeedController = require('../controllers/RSSFeedController') const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController') const MiscController = require('../controllers/MiscController') const ShareController = require('../controllers/ShareController') +const PluginController = require('../controllers/PluginController') const { getTitleIgnorePrefix } = require('../utils/index') @@ -320,6 +321,11 @@ class ApiRouter { this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this)) this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this)) + // + // Plugin routes + // + this.router.post('/plugins/action', PluginController.handleAction.bind(this)) + // // Misc Routes // diff --git a/test/server/managers/PluginManager.test.js b/test/server/managers/PluginManager.test.js new file mode 100644 index 0000000000..ee0a0c3364 --- /dev/null +++ b/test/server/managers/PluginManager.test.js @@ -0,0 +1,5 @@ +describe('PluginManager', () => { + it('should register a plugin', () => { + // Test implementation + }) +}) diff --git a/test/server/managers/plugins/Example/index.js b/test/server/managers/plugins/Example/index.js new file mode 100644 index 0000000000..0e938fe5ba --- /dev/null +++ b/test/server/managers/plugins/Example/index.js @@ -0,0 +1,18 @@ +const PluginAbstract = require('../../../../../server/PluginAbstract') + +class ExamplePlugin extends PluginAbstract { + constructor() { + super() + + this.name = 'Example' + } + + init(context) { + context.Logger.info('[ExamplePlugin] Example plugin loaded successfully') + } + + async onAction(context, actionName, target, data) { + context.Logger.info('[ExamplePlugin] Example plugin onAction', actionName, target, data) + } +} +module.exports = new ExamplePlugin() diff --git a/test/server/managers/plugins/Example/manifest.json b/test/server/managers/plugins/Example/manifest.json new file mode 100644 index 0000000000..96a31e4d59 --- /dev/null +++ b/test/server/managers/plugins/Example/manifest.json @@ -0,0 +1,52 @@ +{ + "name": "Example Plugin", + "slug": "example", + "version": "1.0.0", + "description": "This is an example plugin", + "extensions": [ + { + "target": "item.detail.actions", + "name": "itemActionExample", + "label": "Item Example Action", + "labelKey": "ItemExampleAction" + } + ], + "config": { + "title": "Example Plugin Configuration", + "titleKey": "ExamplePluginConfiguration", + "description": "This is an example plugin", + "descriptionKey": "ExamplePluginConfigurationDescription", + "formFields": [ + { + "name": "apiKey", + "label": "API Key", + "labelKey": "LabelApiKey", + "type": "text", + "required": false + }, + { + "name": "requestAddress", + "label": "Request Address", + "labelKey": "LabelRequestAddress", + "type": "text", + "required": true + }, + { + "name": "enable", + "label": "Enable", + "labelKey": "LabelEnable", + "type": "checkbox" + } + ] + }, + "localization": { + "en-us": { + "ItemExampleAction": "Item Example Action", + "LabelApiKey": "API Key", + "LabelEnable": "Enable", + "ExamplePluginConfiguration": "Example Plugin Configuration", + "ExamplePluginConfigurationDescription": "This is an example plugin", + "LabelRequestAddress": "Request Address" + } + } +} From ad89fb2eac0d80f53f92b91f49d1457b1fd1a3e6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 20 Dec 2024 17:21:00 -0600 Subject: [PATCH 02/14] Update example plugin and add plugins frontend page with save config endpoint --- client/components/app/ConfigSideNav.vue | 5 + client/pages/config/plugins/_slug.vue | 123 ++++++++++++++++++ client/pages/config/plugins/index.vue | 44 +++++++ client/pages/login.vue | 4 +- client/store/index.js | 10 +- server/Auth.js | 2 +- server/controllers/PluginController.js | 14 ++ server/managers/PluginManager.js | 58 ++++++--- server/routers/ApiRouter.js | 1 + test/server/managers/plugins/Example/index.js | 48 ++++++- .../managers/plugins/Example/manifest.json | 3 - 11 files changed, 276 insertions(+), 36 deletions(-) create mode 100644 client/pages/config/plugins/_slug.vue create mode 100644 client/pages/config/plugins/index.vue diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index b42a560ea3..9700cc1b5f 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -109,6 +109,11 @@ export default { id: 'config-authentication', title: this.$strings.HeaderAuthentication, path: '/config/authentication' + }, + { + id: 'config-plugins', + title: 'Plugins', + path: '/config/plugins' } ] diff --git a/client/pages/config/plugins/_slug.vue b/client/pages/config/plugins/_slug.vue new file mode 100644 index 0000000000..619b660813 --- /dev/null +++ b/client/pages/config/plugins/_slug.vue @@ -0,0 +1,123 @@ + + + diff --git a/client/pages/config/plugins/index.vue b/client/pages/config/plugins/index.vue new file mode 100644 index 0000000000..d924474470 --- /dev/null +++ b/client/pages/config/plugins/index.vue @@ -0,0 +1,44 @@ + + + diff --git a/client/pages/login.vue b/client/pages/login.vue index df7ca109eb..3a2f165a2a 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -166,11 +166,11 @@ export default { location.reload() }, - setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, pluginExtensions }) { + setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, plugins }) { this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setSource', Source) this.$store.commit('libraries/setEReaderDevices', ereaderDevices) - this.$store.commit('setPluginExtensions', pluginExtensions) + this.$store.commit('setPlugins', plugins) this.$setServerLanguageCode(serverSettings.language) if (serverSettings.chromecastEnabled) { diff --git a/client/store/index.js b/client/store/index.js index cdac75c254..dfbbc01c74 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -29,7 +29,7 @@ export const state = () => ({ innerModalOpen: false, lastBookshelfScrollData: {}, routerBasePath: '/', - pluginExtensions: [] + plugins: [] }) export const getters = { @@ -64,9 +64,9 @@ export const getters = { return state.serverSettings.homeBookshelfView }, getPluginExtensions: (state) => (target) => { - return state.pluginExtensions + return state.plugins .map((pext) => { - const extensionsMatchingTarget = pext.extensions.filter((ext) => ext.target === target) + const extensionsMatchingTarget = pext.extensions?.filter((ext) => ext.target === target) || [] if (!extensionsMatchingTarget.length) return null return { name: pext.name, @@ -254,7 +254,7 @@ export const mutations = { setInnerModalOpen(state, val) { state.innerModalOpen = val }, - setPluginExtensions(state, val) { - state.pluginExtensions = val + setPlugins(state, val) { + state.plugins = val } } diff --git a/server/Auth.js b/server/Auth.js index 79237cfa11..f3d2c38d6c 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -939,7 +939,7 @@ class Auth { userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), serverSettings: Database.serverSettings.toJSONForBrowser(), ereaderDevices: Database.emailSettings.getEReaderDevices(user), - pluginExtensions: PluginManager.pluginExtensions, + plugins: PluginManager.pluginData, Source: global.Source } } diff --git a/server/controllers/PluginController.js b/server/controllers/PluginController.js index aed2f14083..c69ecc0ca7 100644 --- a/server/controllers/PluginController.js +++ b/server/controllers/PluginController.js @@ -20,5 +20,19 @@ class PluginController { PluginManager.onAction(pluginSlug, actionName, target, data) res.sendStatus(200) } + + /** + * POST: /api/plugins/config + * + * @param {*} req + * @param {*} res + */ + handleConfigSave(req, res) { + const pluginSlug = req.body.pluginSlug + const config = req.body.config + Logger.info(`[PluginController] Saving config for plugin ${pluginSlug}`, config) + PluginManager.onConfigSave(pluginSlug, config) + res.sendStatus(200) + } } module.exports = new PluginController() diff --git a/server/managers/PluginManager.js b/server/managers/PluginManager.js index d72362993e..93fd15c4fd 100644 --- a/server/managers/PluginManager.js +++ b/server/managers/PluginManager.js @@ -1,28 +1,31 @@ const Path = require('path') const Logger = require('../Logger') +const Database = require('../Database') const PluginAbstract = require('../PluginAbstract') const fs = require('fs').promises +/** + * @typedef PluginContext + * @property {import('../../server/Logger')} Logger + * @property {import('../../server/Database')} Database + */ + class PluginManager { constructor() { this.plugins = [] } - get pluginExtensions() { - return this.plugins - .filter((plugin) => plugin.manifest.extensions?.length) - .map((plugin) => { - return { - name: plugin.manifest.name, - slug: plugin.manifest.slug, - extensions: plugin.manifest.extensions - } - }) + get pluginData() { + return this.plugins.map((plugin) => plugin.manifest) } + /** + * @returns {PluginContext} + */ get pluginContext() { return { - Logger + Logger, + Database } } @@ -59,23 +62,27 @@ class PluginManager { // TODO: Validate manifest json - let pluginContents = null + let pluginInstance = null try { - pluginContents = require(Path.join(pluginPath, indexFile.name)) + pluginInstance = require(Path.join(pluginPath, indexFile.name)) } catch (error) { Logger.error(`Error loading plugin ${pluginPath}`, error) return null } + if (typeof pluginInstance.init !== 'function') { + Logger.error(`Plugin ${pluginPath} does not have an init function`) + return null + } + return { manifest: manifestJson, - contents: pluginContents + instance: pluginInstance } } async loadPlugins() { const pluginDirs = await fs.readdir(global.PluginsPath, { withFileTypes: true, recursive: true }).then((files) => files.filter((file) => file.isDirectory())) - console.log('pluginDirs', pluginDirs) for (const pluginDir of pluginDirs) { Logger.info(`[PluginManager] Loading plugin ${pluginDir.name}`) @@ -91,9 +98,9 @@ class PluginManager { await this.loadPlugins() for (const plugin of this.plugins) { - if (plugin.contents.init) { + if (plugin.instance.init) { Logger.info(`[PluginManager] Initializing plugin ${plugin.manifest.name}`) - plugin.contents.init(this.pluginContext) + plugin.instance.init(this.pluginContext) } } } @@ -111,9 +118,22 @@ class PluginManager { return } - if (plugin.contents.onAction) { + if (plugin.instance.onAction) { Logger.info(`[PluginManager] Calling onAction for plugin ${plugin.manifest.name}`) - plugin.contents.onAction(this.pluginContext, actionName, target, data) + plugin.instance.onAction(this.pluginContext, actionName, target, data) + } + } + + onConfigSave(pluginSlug, config) { + const plugin = this.plugins.find((plugin) => plugin.manifest.slug === pluginSlug) + if (!plugin) { + Logger.error(`[PluginManager] Plugin ${pluginSlug} not found`) + return + } + + if (plugin.instance.onConfigSave) { + Logger.info(`[PluginManager] Calling onConfigSave for plugin ${plugin.manifest.name}`) + plugin.instance.onConfigSave(this.pluginContext, config) } } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 5d4187b852..beb3db1f3f 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -325,6 +325,7 @@ class ApiRouter { // Plugin routes // this.router.post('/plugins/action', PluginController.handleAction.bind(this)) + this.router.post('/plugins/config', PluginController.handleConfigSave.bind(this)) // // Misc Routes diff --git a/test/server/managers/plugins/Example/index.js b/test/server/managers/plugins/Example/index.js index 0e938fe5ba..6f3e560321 100644 --- a/test/server/managers/plugins/Example/index.js +++ b/test/server/managers/plugins/Example/index.js @@ -1,18 +1,54 @@ -const PluginAbstract = require('../../../../../server/PluginAbstract') - -class ExamplePlugin extends PluginAbstract { +class ExamplePlugin { constructor() { - super() - this.name = 'Example' } - init(context) { + /** + * + * @param {import('../../server/managers/PluginManager').PluginContext} context + */ + async init(context) { context.Logger.info('[ExamplePlugin] Example plugin loaded successfully') + + context.Database.mediaProgressModel.addHook('afterSave', (instance, options) => { + context.Logger.debug(`[ExamplePlugin] mediaProgressModel afterSave hook for mediaProgress ${instance.id}`) + this.handleMediaProgressUpdate(context, instance) + }) } + /** + * @param {import('../../server/managers/PluginManager').PluginContext} context + * @param {import('../../server/models/MediaProgress')} mediaProgress + */ + async handleMediaProgressUpdate(context, mediaProgress) { + const mediaItem = await mediaProgress.getMediaItem() + if (!mediaItem) { + context.Logger.error(`[ExamplePlugin] Media item not found for mediaProgress ${mediaProgress.id}`) + } else { + const mediaProgressDuration = mediaProgress.duration + const progressPercent = mediaProgressDuration > 0 ? (mediaProgress.currentTime / mediaProgressDuration) * 100 : 0 + context.Logger.info(`[ExamplePlugin] Media progress update for "${mediaItem.title}" ${Math.round(progressPercent)}%`) + } + } + + /** + * + * @param {import('../../server/managers/PluginManager').PluginContext} context + * @param {string} actionName + * @param {string} target + * @param {*} data + */ async onAction(context, actionName, target, data) { context.Logger.info('[ExamplePlugin] Example plugin onAction', actionName, target, data) } + + /** + * + * @param {import('../../server/managers/PluginManager').PluginContext} context + * @param {*} config + */ + async onConfigSave(context, config) { + context.Logger.info('[ExamplePlugin] Example plugin onConfigSave', config) + } } module.exports = new ExamplePlugin() diff --git a/test/server/managers/plugins/Example/manifest.json b/test/server/managers/plugins/Example/manifest.json index 96a31e4d59..33b25d2432 100644 --- a/test/server/managers/plugins/Example/manifest.json +++ b/test/server/managers/plugins/Example/manifest.json @@ -12,8 +12,6 @@ } ], "config": { - "title": "Example Plugin Configuration", - "titleKey": "ExamplePluginConfiguration", "description": "This is an example plugin", "descriptionKey": "ExamplePluginConfigurationDescription", "formFields": [ @@ -44,7 +42,6 @@ "ItemExampleAction": "Item Example Action", "LabelApiKey": "API Key", "LabelEnable": "Enable", - "ExamplePluginConfiguration": "Example Plugin Configuration", "ExamplePluginConfigurationDescription": "This is an example plugin", "LabelRequestAddress": "Request Address" } From 23b480b11a47be26192ee18e1a3768361b9b57df Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 21 Dec 2024 10:20:09 -0600 Subject: [PATCH 03/14] Remove separate plugins dir and use metadata dir for plugins folder --- index.js | 4 +--- prod.js | 5 +---- server/Server.js | 3 +-- server/managers/PluginManager.js | 17 ++++++++++++----- test/server/managers/plugins/readme.md | 0 5 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 test/server/managers/plugins/readme.md diff --git a/index.js b/index.js index d51ce4b2cf..9a0be347cc 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,6 @@ if (isDev) { if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath - if (devEnv.PluginsPath) process.env.PLUGINS_PATH = devEnv.PluginsPath process.env.SOURCE = 'local' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' } @@ -22,11 +21,10 @@ const PORT = process.env.PORT || 80 const HOST = process.env.HOST const CONFIG_PATH = process.env.CONFIG_PATH || '/config' const METADATA_PATH = process.env.METADATA_PATH || '/metadata' -const PLUGINS_PATH = process.env.PLUGINS_PATH || '/plugins' const SOURCE = process.env.SOURCE || 'docker' const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '' console.log('Config', CONFIG_PATH, METADATA_PATH) -const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, PLUGINS_PATH, ROUTER_BASE_PATH) +const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) Server.start() diff --git a/prod.js b/prod.js index 4d09a4f5b8..6520d317b2 100644 --- a/prod.js +++ b/prod.js @@ -1,7 +1,6 @@ const optionDefinitions = [ { name: 'config', alias: 'c', type: String }, { name: 'metadata', alias: 'm', type: String }, - { name: 'plugins', alias: 'l', type: String }, { name: 'port', alias: 'p', type: String }, { name: 'host', alias: 'h', type: String }, { name: 'source', alias: 's', type: String } @@ -19,18 +18,16 @@ global.appRoot = __dirname const inputConfig = options.config ? Path.resolve(options.config) : null const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null -const inputPlugins = options.plugins ? Path.resolve(options.plugins) : null const PORT = options.port || process.env.PORT || 3333 const HOST = options.host || process.env.HOST const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config') const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata') -const PLUGINS_PATH = inputPlugins || process.env.PLUGINS_PATH || Path.resolve('plugins') const SOURCE = options.source || process.env.SOURCE || 'debian' const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '' console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH) -const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, PLUGINS_PATH, ROUTER_BASE_PATH) +const Server = new server(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) Server.start() diff --git a/server/Server.js b/server/Server.js index 837eab2090..a707958a72 100644 --- a/server/Server.js +++ b/server/Server.js @@ -44,7 +44,7 @@ const expressSession = require('express-session') const MemoryStore = require('./libs/memorystore') class Server { - constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, PLUGINS_PATH, ROUTER_BASE_PATH) { + constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) { this.Port = PORT this.Host = HOST global.Source = SOURCE @@ -52,7 +52,6 @@ class Server { global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH)) global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH)) global.RouterBasePath = ROUTER_BASE_PATH - global.PluginsPath = fileUtils.filePathToPOSIX(Path.normalize(PLUGINS_PATH)) global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1' diff --git a/server/managers/PluginManager.js b/server/managers/PluginManager.js index 93fd15c4fd..1999eb8388 100644 --- a/server/managers/PluginManager.js +++ b/server/managers/PluginManager.js @@ -3,6 +3,7 @@ const Logger = require('../Logger') const Database = require('../Database') const PluginAbstract = require('../PluginAbstract') const fs = require('fs').promises +const fsExtra = require('../libs/fsExtra') /** * @typedef PluginContext @@ -15,6 +16,10 @@ class PluginManager { this.plugins = [] } + get pluginMetadataPath() { + return Path.posix.join(global.MetadataPath, 'plugins') + } + get pluginData() { return this.plugins.map((plugin) => plugin.manifest) } @@ -35,7 +40,7 @@ class PluginManager { * @returns {Promise<{manifest: Object, contents: PluginAbstract}>} */ async loadPlugin(pluginPath) { - const pluginFiles = await fs.readdir(pluginPath, { withFileTypes: true }).then((files) => files.filter((file) => !file.isDirectory())) + const pluginFiles = await fsExtra.readdir(pluginPath, { withFileTypes: true }).then((files) => files.filter((file) => !file.isDirectory())) if (!pluginFiles.length) { Logger.error(`No files found in plugin ${pluginPath}`) @@ -54,7 +59,7 @@ class PluginManager { let manifestJson = null try { - manifestJson = await fs.readFile(Path.join(pluginPath, manifestFile.name), 'utf8').then((data) => JSON.parse(data)) + manifestJson = await fsExtra.readFile(Path.join(pluginPath, manifestFile.name), 'utf8').then((data) => JSON.parse(data)) } catch (error) { Logger.error(`Error parsing manifest file for plugin ${pluginPath}`, error) return null @@ -82,11 +87,13 @@ class PluginManager { } async loadPlugins() { - const pluginDirs = await fs.readdir(global.PluginsPath, { withFileTypes: true, recursive: true }).then((files) => files.filter((file) => file.isDirectory())) + await fsExtra.ensureDir(this.pluginMetadataPath) + + const pluginDirs = await fsExtra.readdir(this.pluginMetadataPath, { withFileTypes: true, recursive: true }).then((files) => files.filter((file) => file.isDirectory())) for (const pluginDir of pluginDirs) { Logger.info(`[PluginManager] Loading plugin ${pluginDir.name}`) - const plugin = await this.loadPlugin(Path.join(global.PluginsPath, pluginDir.name)) + const plugin = await this.loadPlugin(Path.join(this.pluginMetadataPath, pluginDir.name)) if (plugin) { Logger.info(`[PluginManager] Loaded plugin ${plugin.manifest.name}`) this.plugins.push(plugin) @@ -152,7 +159,7 @@ class PluginManager { try { // Try to load the plugin - const pluginPath = Path.join(global.PluginsPath, plugin.name) + const pluginPath = Path.join(this.pluginMetadataPath, plugin.name) const packageContents = require(pluginPath) console.log('packageContents', packageContents) packageContents.init() diff --git a/test/server/managers/plugins/readme.md b/test/server/managers/plugins/readme.md new file mode 100644 index 0000000000..e69de29bb2 From 5a96d8aeb3c965d5d77e0c5d8dece632d5ecb874 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 21 Dec 2024 12:43:20 -0600 Subject: [PATCH 04/14] Add Plugin model with migration --- server/Database.js | 1 + .../migrations/v2.18.0-add-plugins-table.js | 67 +++++++++++++++++++ server/models/Plugin.js | 48 +++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 server/migrations/v2.18.0-add-plugins-table.js create mode 100644 server/models/Plugin.js diff --git a/server/Database.js b/server/Database.js index afb09dae92..0f7017060f 100644 --- a/server/Database.js +++ b/server/Database.js @@ -305,6 +305,7 @@ class Database { require('./models/Setting').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/MediaItemShare').init(this.sequelize) + require('./models/Plugin').init(this.sequelize) return this.sequelize.sync({ force, alter: false }) } diff --git a/server/migrations/v2.18.0-add-plugins-table.js b/server/migrations/v2.18.0-add-plugins-table.js new file mode 100644 index 0000000000..8c63cbdf8e --- /dev/null +++ b/server/migrations/v2.18.0-add-plugins-table.js @@ -0,0 +1,67 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.18.0' +const migrationName = `${migrationVersion}-add-plugins-table` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration creates the plugins table if it does not exist. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + if (!(await queryInterface.tableExists('plugins'))) { + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + await queryInterface.createTable('plugins', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + version: DataTypes.STRING, + config: DataTypes.JSON, + extraData: DataTypes.JSON, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }) + logger.info(`${loggerPrefix} Table 'plugins' created`) + } else { + logger.info(`${loggerPrefix} Table 'plugins' already exists`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script drops the plugins table if it exists. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('plugins')) { + await queryInterface.dropTable('plugins') + logger.info(`${loggerPrefix} Table 'plugins' dropped`) + } else { + logger.info(`${loggerPrefix} Table 'plugins' does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/Plugin.js b/server/models/Plugin.js new file mode 100644 index 0000000000..cae34ccbf3 --- /dev/null +++ b/server/models/Plugin.js @@ -0,0 +1,48 @@ +const { DataTypes, Model } = require('sequelize') + +class Plugin extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.name + /** @type {string} */ + this.version + /** @type {Object} */ + this.config + /** @type {Object} */ + this.extraData + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + version: DataTypes.STRING, + config: DataTypes.JSON, + extraData: DataTypes.JSON + }, + { + sequelize, + modelName: 'plugin' + } + ) + } +} + +module.exports = Plugin From cfe3deff3b9f17c2e5a704e6e9857078351d9ee8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 21 Dec 2024 13:26:42 -0600 Subject: [PATCH 05/14] Add isMissing to Plugin model, add manifest version and name validation, create/update plugins table --- server/Database.js | 5 ++ server/managers/PluginManager.js | 83 +++++++++++++++++-- .../migrations/v2.18.0-add-plugins-table.js | 1 + server/models/Plugin.js | 6 ++ server/utils/index.js | 18 ++++ 5 files changed, 106 insertions(+), 7 deletions(-) diff --git a/server/Database.js b/server/Database.js index 0f7017060f..5abcb78e71 100644 --- a/server/Database.js +++ b/server/Database.js @@ -152,6 +152,11 @@ class Database { return this.models.device } + /** @type {typeof import('./models/Plugin')} */ + get pluginModel() { + return this.models.plugin + } + /** * Check if db file exists * @returns {boolean} diff --git a/server/managers/PluginManager.js b/server/managers/PluginManager.js index 1999eb8388..9b3ba8f321 100644 --- a/server/managers/PluginManager.js +++ b/server/managers/PluginManager.js @@ -2,8 +2,8 @@ const Path = require('path') const Logger = require('../Logger') const Database = require('../Database') const PluginAbstract = require('../PluginAbstract') -const fs = require('fs').promises const fsExtra = require('../libs/fsExtra') +const { isUUID, parseSemverStrict } = require('../utils') /** * @typedef PluginContext @@ -35,11 +35,14 @@ class PluginManager { } /** + * Validate and load a plugin from a directory + * TODO: Validatation * + * @param {string} dirname * @param {string} pluginPath * @returns {Promise<{manifest: Object, contents: PluginAbstract}>} */ - async loadPlugin(pluginPath) { + async loadPlugin(dirname, pluginPath) { const pluginFiles = await fsExtra.readdir(pluginPath, { withFileTypes: true }).then((files) => files.filter((file) => !file.isDirectory())) if (!pluginFiles.length) { @@ -66,6 +69,19 @@ class PluginManager { } // TODO: Validate manifest json + if (!isUUID(manifestJson.id)) { + Logger.error(`Invalid plugin ID in manifest for plugin ${pluginPath}`) + return null + } + if (!parseSemverStrict(manifestJson.version)) { + Logger.error(`Invalid plugin version in manifest for plugin ${pluginPath}`) + return null + } + // TODO: Enforcing plugin name to be the same as the directory name? Ensures plugins are identifiable in the file system. May have issues with unicode characters. + if (dirname !== manifestJson.name) { + Logger.error(`Plugin directory name "${dirname}" does not match manifest name "${manifestJson.name}"`) + return null + } let pluginInstance = null try { @@ -86,21 +102,74 @@ class PluginManager { } } - async loadPlugins() { + /** + * Get all plugins from the /metadata/plugins directory + */ + async getPluginsFromFileSystem() { await fsExtra.ensureDir(this.pluginMetadataPath) + // Get all directories in the plugins directory const pluginDirs = await fsExtra.readdir(this.pluginMetadataPath, { withFileTypes: true, recursive: true }).then((files) => files.filter((file) => file.isDirectory())) + const pluginsFound = [] for (const pluginDir of pluginDirs) { - Logger.info(`[PluginManager] Loading plugin ${pluginDir.name}`) - const plugin = await this.loadPlugin(Path.join(this.pluginMetadataPath, pluginDir.name)) + Logger.debug(`[PluginManager] Checking if directory "${pluginDir.name}" is a plugin`) + const plugin = await this.loadPlugin(pluginDir.name, Path.join(this.pluginMetadataPath, pluginDir.name)) if (plugin) { - Logger.info(`[PluginManager] Loaded plugin ${plugin.manifest.name}`) - this.plugins.push(plugin) + Logger.debug(`[PluginManager] Found plugin "${plugin.manifest.name}"`) + pluginsFound.push(plugin) + } + } + return pluginsFound + } + + /** + * Load plugins from the /metadata/plugins directory and update the database + */ + async loadPlugins() { + const pluginsFound = await this.getPluginsFromFileSystem() + + const existingPlugins = await Database.pluginModel.findAll() + + // Add new plugins or update existing plugins + for (const plugin of pluginsFound) { + const existingPlugin = existingPlugins.find((p) => p.id === plugin.manifest.id) + if (existingPlugin) { + // TODO: Should automatically update? + if (existingPlugin.version !== plugin.manifest.version) { + Logger.info(`[PluginManager] Updating plugin "${plugin.manifest.name}" version from "${existingPlugin.version}" to version "${plugin.manifest.version}"`) + await existingPlugin.update({ version: plugin.manifest.version, isMissing: false }) + } else if (existingPlugin.isMissing) { + Logger.info(`[PluginManager] Plugin "${plugin.manifest.name}" was missing but is now found`) + await existingPlugin.update({ isMissing: false }) + } else { + Logger.debug(`[PluginManager] Plugin "${plugin.manifest.name}" already exists in the database with version "${plugin.manifest.version}"`) + } + } else { + await Database.pluginModel.create({ + id: plugin.manifest.id, + name: plugin.manifest.name, + version: plugin.manifest.version + }) + Logger.info(`[PluginManager] Added plugin "${plugin.manifest.name}" to the database`) } } + + // Mark missing plugins + for (const plugin of existingPlugins) { + const foundPlugin = pluginsFound.find((p) => p.manifest.id === plugin.id) + if (!foundPlugin && !plugin.isMissing) { + Logger.info(`[PluginManager] Plugin "${plugin.name}" not found or invalid - marking as missing`) + await plugin.update({ isMissing: true }) + } + } + + this.plugins = pluginsFound } + /** + * Load and initialize all plugins + */ async init() { await this.loadPlugins() diff --git a/server/migrations/v2.18.0-add-plugins-table.js b/server/migrations/v2.18.0-add-plugins-table.js index 8c63cbdf8e..97d8872978 100644 --- a/server/migrations/v2.18.0-add-plugins-table.js +++ b/server/migrations/v2.18.0-add-plugins-table.js @@ -31,6 +31,7 @@ async function up({ context: { queryInterface, logger } }) { }, name: DataTypes.STRING, version: DataTypes.STRING, + isMissing: DataTypes.BOOLEAN, config: DataTypes.JSON, extraData: DataTypes.JSON, createdAt: DataTypes.DATE, diff --git a/server/models/Plugin.js b/server/models/Plugin.js index cae34ccbf3..1c12bac456 100644 --- a/server/models/Plugin.js +++ b/server/models/Plugin.js @@ -10,6 +10,8 @@ class Plugin extends Model { this.name /** @type {string} */ this.version + /** @type {boolean} */ + this.isMissing /** @type {Object} */ this.config /** @type {Object} */ @@ -34,6 +36,10 @@ class Plugin extends Model { }, name: DataTypes.STRING, version: DataTypes.STRING, + isMissing: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, config: DataTypes.JSON, extraData: DataTypes.JSON }, diff --git a/server/utils/index.js b/server/utils/index.js index fa7ae92ed2..32a3600c0a 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -243,3 +243,21 @@ module.exports.isValidASIN = (str) => { if (!str || typeof str !== 'string') return false return /^[A-Z0-9]{10}$/.test(str) } + +/** + * Parse semver string that must be in format "major.minor.patch" all numbers + * + * @param {string} version + * @returns {{major: number, minor: number, patch: number} | null} + */ +module.exports.parseSemverStrict = (version) => { + if (typeof version !== 'string') { + return null + } + const [major, minor, patch] = version.split('.').map(Number) + + if (isNaN(major) || isNaN(minor) || isNaN(patch)) { + return null + } + return { major, minor, patch } +} From fc17a7486507ae93956ca728204862caa2892b5c Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 21 Dec 2024 14:54:43 -0600 Subject: [PATCH 06/14] Update plugin to use uuid for id, update example plugin with taskmanager and socketauthority test --- .../config/plugins/{_slug.vue => _id.vue} | 5 +- client/pages/config/plugins/index.vue | 2 +- client/pages/item/_id/index.vue | 14 +- server/Auth.js | 5 +- server/PluginAbstract.js | 20 --- server/Server.js | 4 +- server/controllers/PluginController.js | 16 +-- server/managers/PluginManager.js | 46 +++++-- server/routers/ApiRouter.js | 4 +- test/server/managers/plugins/Example/index.js | 126 +++++++++++------- .../managers/plugins/Example/manifest.json | 3 +- 11 files changed, 139 insertions(+), 106 deletions(-) rename client/pages/config/plugins/{_slug.vue => _id.vue} (95%) delete mode 100644 server/PluginAbstract.js diff --git a/client/pages/config/plugins/_slug.vue b/client/pages/config/plugins/_id.vue similarity index 95% rename from client/pages/config/plugins/_slug.vue rename to client/pages/config/plugins/_id.vue index 619b660813..3f7d84b7f5 100644 --- a/client/pages/config/plugins/_slug.vue +++ b/client/pages/config/plugins/_id.vue @@ -42,7 +42,7 @@ export default { if (!store.getters['user/getIsAdminOrUp']) { redirect('/') } - const plugin = store.state.plugins.find((plugin) => plugin.slug === params.slug) + const plugin = store.state.plugins.find((plugin) => plugin.id === params.id) if (!plugin) { redirect('/config/plugins') } @@ -95,14 +95,13 @@ export default { console.log('Form data', formData) const payload = { - pluginSlug: this.plugin.slug, config: formData } this.processing = true this.$axios - .$post(`/api/plugins/config`, payload) + .$post(`/api/plugins/${this.plugin.id}/config`, payload) .then(() => { console.log('Plugin configuration saved') }) diff --git a/client/pages/config/plugins/index.vue b/client/pages/config/plugins/index.vue index d924474470..925248f937 100644 --- a/client/pages/config/plugins/index.vue +++ b/client/pages/config/plugins/index.vue @@ -11,7 +11,7 @@

Installed Plugins

diff --git a/client/pages/config/plugins/_id.vue b/client/pages/config/plugins/_id.vue index 3f7d84b7f5..dda1094f6f 100644 --- a/client/pages/config/plugins/_id.vue +++ b/client/pages/config/plugins/_id.vue @@ -1,14 +1,14 @@