diff --git a/.gitignore b/.gitignore index 1eda8e1f..d375bae0 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 139794e5..c540fec7 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 a853def4..df7ca109 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 2f2201b6..cdac75c2 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 9a0be347..d51ce4b2 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 70633d5b..4d09a4f5 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 74b767f5..79237cfa 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 00000000..d5d56eff --- /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 95e3d683..837eab20 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 00000000..aed2f140 --- /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 00000000..d7236299 --- /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 235d25cd..5d4187b8 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 00000000..ee0a0c33 --- /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 00000000..0e938fe5 --- /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 00000000..96a31e4d --- /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" + } + } +}