diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index b42a560ea3..df6778c34f 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -112,6 +112,14 @@ export default { } ] + if (this.$store.state.pluginsEnabled) { + configRoutes.push({ + id: 'config-plugins', + title: 'Plugins', + path: '/config/plugins' + }) + } + if (this.currentLibraryId) { configRoutes.push({ id: 'library-stats', diff --git a/client/components/prompt/Confirm.vue b/client/components/prompt/Confirm.vue index 032190cf5f..4e11ce4594 100644 --- a/client/components/prompt/Confirm.vue +++ b/client/components/prompt/Confirm.vue @@ -7,6 +7,14 @@ +
+ +
+
{{ $strings.ButtonCancel }}
@@ -25,7 +33,8 @@ export default { return { el: null, content: null, - checkboxValue: false + checkboxValue: false, + formData: {} } }, watch: { @@ -61,6 +70,9 @@ export default { persistent() { return !!this.confirmPromptOptions.persistent }, + formFields() { + return this.confirmPromptOptions.formFields || [] + }, checkboxLabel() { return this.confirmPromptOptions.checkboxLabel }, @@ -100,11 +112,31 @@ export default { this.show = false }, confirm() { - if (this.callback) this.callback(true, this.checkboxValue) + if (this.callback) { + if (this.formFields.length) { + const formFieldData = { + ...this.formData + } + + this.callback(true, formFieldData) + } else { + this.callback(true, this.checkboxValue) + } + } this.show = false }, setShow() { this.checkboxValue = this.checkboxDefaultValue + + if (this.formFields.length) { + this.formFields.forEach((field) => { + let defaultValue = '' + if (field.type === 'boolean') defaultValue = false + if (field.type === 'select') defaultValue = field.options[0].value + this.$set(this.formData, field.name, defaultValue) + }) + } + this.$eventBus.$emit('showing-prompt', true) document.body.appendChild(this.el) setTimeout(() => { diff --git a/client/components/ui/ContextMenuDropdown.vue b/client/components/ui/ContextMenuDropdown.vue index 52a5c2e2ba..693fbad0cb 100644 --- a/client/components/ui/ContextMenuDropdown.vue +++ b/client/components/ui/ContextMenuDropdown.vue @@ -31,6 +31,7 @@
diff --git a/client/pages/config/plugins/_id.vue b/client/pages/config/plugins/_id.vue new file mode 100644 index 0000000000..e376abb015 --- /dev/null +++ b/client/pages/config/plugins/_id.vue @@ -0,0 +1,154 @@ + + + diff --git a/client/pages/config/plugins/index.vue b/client/pages/config/plugins/index.vue new file mode 100644 index 0000000000..4adfc9368b --- /dev/null +++ b/client/pages/config/plugins/index.vue @@ -0,0 +1,48 @@ + + + diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 139794e555..ae958f228b 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) => { + plugin.extensions.forEach((pext) => { + items.push({ + text: pext.label, + action: `plugin-${plugin.id}-action-${pext.name}`, + icon: 'extension' + }) + }) + }) + } + return items } }, @@ -763,7 +778,54 @@ 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 pluginId = actionStrSplit[0] + const pluginAction = actionStrSplit[1] + this.onPluginAction(pluginId, pluginAction) + } + }, + onPluginAction(pluginId, pluginAction) { + const plugin = this.pluginExtensions.find((p) => p.id === pluginId) + const extension = plugin.extensions.find((ext) => ext.name === pluginAction) + + if (extension.prompt) { + const payload = { + message: extension.prompt.message, + formFields: extension.prompt.formFields || [], + yesButtonText: this.$strings.ButtonSubmit, + callback: (confirmed, promptData) => { + if (confirmed) { + this.sendPluginAction(pluginId, pluginAction, promptData) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + } else { + this.sendPluginAction(pluginId, pluginAction) } + }, + sendPluginAction(pluginId, pluginAction, promptData = null) { + this.$axios + .$post(`/api/plugins/${pluginId}/action`, { + pluginAction, + target: 'item.detail.actions', + data: { + entityId: this.libraryItemId, + entityType: 'libraryItem', + userId: this.$store.state.user.user.id, + promptData + } + }) + .then((data) => { + console.log('Plugin action response', data) + }) + .catch((error) => { + const errorMsg = error.response?.data || 'Plugin action failed' + console.error('Plugin action failed:', error) + this.$toast.error(errorMsg) + }) } }, mounted() { diff --git a/client/pages/login.vue b/client/pages/login.vue index a853def452..f9943b0cf2 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -166,10 +166,14 @@ export default { location.reload() }, - setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) { + setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, plugins }) { this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setSource', Source) this.$store.commit('libraries/setEReaderDevices', ereaderDevices) + if (plugins !== undefined) { + 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 2f2201b66c..f3c8822efd 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -28,7 +28,9 @@ export const state = () => ({ openModal: null, innerModalOpen: false, lastBookshelfScrollData: {}, - routerBasePath: '/' + routerBasePath: '/', + plugins: [], + pluginsEnabled: false }) export const getters = { @@ -61,6 +63,20 @@ export const getters = { getHomeBookshelfView: (state) => { if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD return state.serverSettings.homeBookshelfView + }, + getPluginExtensions: (state) => (target) => { + if (!state.pluginsEnabled) return [] + return state.plugins + .map((pext) => { + const extensionsMatchingTarget = pext.extensions?.filter((ext) => ext.target === target) || [] + if (!extensionsMatchingTarget.length) return null + return { + id: pext.id, + name: pext.name, + extensions: extensionsMatchingTarget + } + }) + .filter(Boolean) } } @@ -239,5 +255,9 @@ export const mutations = { }, setInnerModalOpen(state, val) { state.innerModalOpen = val + }, + setPlugins(state, val) { + state.plugins = val + state.pluginsEnabled = true } } diff --git a/index.js b/index.js index 9a0be347cc..97e71fa41e 100644 --- a/index.js +++ b/index.js @@ -13,6 +13,8 @@ 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.AllowPlugins) process.env.ALLOW_PLUGINS = '1' + if (devEnv.DevPluginsPath) process.env.DEV_PLUGINS_PATH = devEnv.DevPluginsPath process.env.SOURCE = 'local' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' } diff --git a/prod.js b/prod.js index 70633d5b23..6520d317b2 100644 --- a/prod.js +++ b/prod.js @@ -16,8 +16,8 @@ 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 PORT = options.port || process.env.PORT || 3333 const HOST = options.host || process.env.HOST diff --git a/server/Auth.js b/server/Auth.js index 74b767f5b1..089263f03d 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -16,6 +16,8 @@ const Logger = require('./Logger') */ class Auth { constructor() { + this.pluginManifests = [] + // Map of openId sessions indexed by oauth2 state-variable this.openIdAuthSession = new Map() this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/] @@ -933,11 +935,28 @@ class Auth { */ async getUserLoginResponsePayload(user) { const libraryIds = await Database.libraryModel.getAllLibraryIds() + + let plugins = undefined + if (process.env.ALLOW_PLUGINS === '1') { + // TODO: Should be better handled by the PluginManager + // restrict plugin extensions that are not allowed for the user type + plugins = this.pluginManifests.map((manifest) => { + const manifestExtensions = (manifest.extensions || []).filter((ext) => { + if (ext.restrictToAccountTypes?.length) { + return ext.restrictToAccountTypes.includes(user.type) + } + return true + }) + return { ...manifest, extensions: manifestExtensions } + }) + } + return { user: user.toOldJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), serverSettings: Database.serverSettings.toJSONForBrowser(), ereaderDevices: Database.emailSettings.getEReaderDevices(user), + plugins, Source: global.Source } } diff --git a/server/Database.js b/server/Database.js index afb09dae92..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} @@ -305,6 +310,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/Server.js b/server/Server.js index 46850cbb7b..b78c141f95 100644 --- a/server/Server.js +++ b/server/Server.js @@ -28,7 +28,6 @@ const AbMergeManager = require('./managers/AbMergeManager') const CacheManager = require('./managers/CacheManager') const BackupManager = require('./managers/BackupManager') const PlaybackSessionManager = require('./managers/PlaybackSessionManager') -const PodcastManager = require('./managers/PodcastManager') const AudioMetadataMangaer = require('./managers/AudioMetadataManager') const RssFeedManager = require('./managers/RssFeedManager') const CronManager = require('./managers/CronManager') @@ -36,6 +35,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') @@ -79,9 +79,8 @@ class Server { this.backupManager = new BackupManager() this.abMergeManager = new AbMergeManager() this.playbackSessionManager = new PlaybackSessionManager() - this.podcastManager = new PodcastManager() this.audioMetadataManager = new AudioMetadataMangaer() - this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager) + this.cronManager = new CronManager(this.playbackSessionManager) this.apiCacheManager = new ApiCacheManager() this.binaryManager = new BinaryManager() @@ -161,6 +160,15 @@ class Server { LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask) }) } + + if (process.env.ALLOW_PLUGINS === '1') { + Logger.info(`[Server] Experimental plugin support enabled`) + + // Initialize plugins + await PluginManager.init() + // TODO: Prevents circular dependency for SocketAuthority + this.auth.pluginManifests = PluginManager.pluginManifests + } } /** diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index f42a023d4c..6bc06fc893 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -19,6 +19,7 @@ const Scanner = require('../scanner/Scanner') const Database = require('../Database') const Watcher = require('../Watcher') const RssFeedManager = require('../managers/RssFeedManager') +const PodcastManager = require('../managers/PodcastManager') const libraryFilters = require('../utils/queries/libraryFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') @@ -219,7 +220,7 @@ class LibraryController { * @param {Response} res */ async getEpisodeDownloadQueue(req, res) { - const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id) + const libraryDownloadQueueDetails = PodcastManager.getDownloadQueueDetails(req.library.id) res.json(libraryDownloadQueueDetails) } @@ -1288,7 +1289,7 @@ class LibraryController { } }) - const opmlText = this.podcastManager.generateOPMLFileText(podcasts) + const opmlText = PodcastManager.generateOPMLFileText(podcasts) res.type('application/xml') res.send(opmlText) } diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 17c7be8387..f929b6b57c 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -18,6 +18,7 @@ const RssFeedManager = require('../managers/RssFeedManager') const CacheManager = require('../managers/CacheManager') const CoverManager = require('../managers/CoverManager') const ShareManager = require('../managers/ShareManager') +const PodcastManager = require('../managers/PodcastManager') /** * @typedef RequestUserObject @@ -59,10 +60,10 @@ class LibraryItemController { } if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) { - const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id) + const downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id) item.episodeDownloadsQueued = downloadsInQueue.map((d) => d.toJSONForClient()) - if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) { - item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()] + if (PodcastManager.currentDownload?.libraryItemId === req.libraryItem.id) { + item.episodesDownloading = [PodcastManager.currentDownload.toJSONForClient()] } } diff --git a/server/controllers/PluginController.js b/server/controllers/PluginController.js new file mode 100644 index 0000000000..f3041de6a3 --- /dev/null +++ b/server/controllers/PluginController.js @@ -0,0 +1,82 @@ +const { Request, Response, NextFunction } = require('express') +const PluginManager = require('../managers/PluginManager') +const Logger = require('../Logger') + +class PluginController { + constructor() {} + + /** + * + * @param {Request} req + * @param {Response} res + */ + getConfig(req, res) { + if (!req.user.isAdminOrUp) { + return res.sendStatus(403) + } + + res.json({ + config: req.pluginData.instance.config + }) + } + + /** + * POST: /api/plugins/:id/action + * + * @param {Request} req + * @param {Response} res + */ + async handleAction(req, res) { + const actionName = req.body.pluginAction + const target = req.body.target + const data = req.body.data + Logger.info(`[PluginController] Handle plugin "${req.pluginData.manifest.name}" action ${actionName} ${target}`, data) + const actionData = await PluginManager.onAction(req.pluginData, actionName, target, data) + if (!actionData || actionData.error) { + return res.status(400).send(actionData?.error || 'Error performing action') + } + res.sendStatus(200) + } + + /** + * POST: /api/plugins/:id/config + * + * @param {Request} req + * @param {Response} res + */ + async handleConfigSave(req, res) { + if (!req.user.isAdminOrUp) { + return res.sendStatus(403) + } + if (!req.body.config || typeof req.body.config !== 'object') { + return res.status(400).send('Invalid config') + } + + const config = req.body.config + Logger.info(`[PluginController] Handle save config for plugin ${req.pluginData.manifest.name}`, config) + const saveData = await PluginManager.onConfigSave(req.pluginData, config) + if (!saveData || saveData.error) { + return res.status(400).send(saveData?.error || 'Error saving config') + } + res.sendStatus(200) + } + + /** + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async middleware(req, res, next) { + if (req.params.id) { + const pluginData = PluginManager.getPluginDataById(req.params.id) + if (!pluginData) { + return res.sendStatus(404) + } + await pluginData.instance.reload() + req.pluginData = pluginData + } + next() + } +} +module.exports = new PluginController() diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 3610c2ea7f..742bf0e0c0 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -11,6 +11,7 @@ const { validateUrl } = require('../utils/index') const Scanner = require('../scanner/Scanner') const CoverManager = require('../managers/CoverManager') +const PodcastManager = require('../managers/PodcastManager') const LibraryItem = require('../objects/LibraryItem') @@ -114,7 +115,7 @@ class PodcastController { if (payload.episodesToDownload?.length) { Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`) - this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload) + PodcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload) } // Turn on podcast auto download cron if not already on @@ -169,7 +170,7 @@ class PodcastController { } res.json({ - feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText) + feeds: PodcastManager.getParsedOPMLFileFeeds(req.body.opmlText) }) } @@ -203,7 +204,7 @@ class PodcastController { return res.status(404).send('Folder not found') } const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes - this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager) + PodcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager) res.sendStatus(200) } @@ -230,7 +231,7 @@ class PodcastController { const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3 - var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) + var newEpisodes = await PodcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) res.json({ episodes: newEpisodes || [] }) @@ -239,8 +240,6 @@ class PodcastController { /** * GET: /api/podcasts/:id/clear-queue * - * @this {import('../routers/ApiRouter')} - * * @param {RequestWithUser} req * @param {Response} res */ @@ -249,22 +248,20 @@ class PodcastController { Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`) return res.sendStatus(403) } - this.podcastManager.clearDownloadQueue(req.params.id) + PodcastManager.clearDownloadQueue(req.params.id) res.sendStatus(200) } /** * GET: /api/podcasts/:id/downloads * - * @this {import('../routers/ApiRouter')} - * * @param {RequestWithUser} req * @param {Response} res */ getEpisodeDownloads(req, res) { var libraryItem = req.libraryItem - var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id) + var downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(libraryItem.id) res.json({ downloads: downloadsInQueue.map((d) => d.toJSONForClient()) }) @@ -290,8 +287,6 @@ class PodcastController { /** * POST: /api/podcasts/:id/download-episodes * - * @this {import('../routers/ApiRouter')} - * * @param {RequestWithUser} req * @param {Response} res */ @@ -306,7 +301,7 @@ class PodcastController { return res.sendStatus(400) } - this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes) + PodcastManager.downloadPodcastEpisodes(libraryItem, episodes) res.sendStatus(200) } diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index 7a8c9bd0e3..7771b0d99d 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -5,11 +5,10 @@ const Database = require('../Database') const LibraryScanner = require('../scanner/LibraryScanner') const ShareManager = require('./ShareManager') +const PodcastManager = require('./PodcastManager') class CronManager { - constructor(podcastManager, playbackSessionManager) { - /** @type {import('./PodcastManager')} */ - this.podcastManager = podcastManager + constructor(playbackSessionManager) { /** @type {import('./PlaybackSessionManager')} */ this.playbackSessionManager = playbackSessionManager @@ -163,7 +162,7 @@ class CronManager { task }) } catch (error) { - Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error) + Logger.error(`[CronManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error) } } @@ -192,7 +191,7 @@ class CronManager { // Run episode checks for (const libraryItem of libraryItems) { - const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem) + const keepAutoDownloading = await PodcastManager.runEpisodeCheck(libraryItem) if (!keepAutoDownloading) { // auto download was disabled podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItem.id) // Filter it out diff --git a/server/managers/PluginManager.js b/server/managers/PluginManager.js new file mode 100644 index 0000000000..f9d4806666 --- /dev/null +++ b/server/managers/PluginManager.js @@ -0,0 +1,274 @@ +const Path = require('path') +const Logger = require('../Logger') +const Database = require('../Database') +const SocketAuthority = require('../SocketAuthority') +const TaskManager = require('../managers/TaskManager') +const ShareManager = require('../managers/ShareManager') +const RssFeedManager = require('../managers/RssFeedManager') +const PodcastManager = require('../managers/PodcastManager') +const fsExtra = require('../libs/fsExtra') +const { isUUID, parseSemverStrict } = require('../utils') + +/** + * @typedef PluginContext + * @property {import('../Logger')} Logger + * @property {import('../Database')} Database + * @property {import('../SocketAuthority')} SocketAuthority + * @property {import('../managers/TaskManager')} TaskManager + * @property {import('../models/Plugin')} pluginInstance + * @property {import('../managers/ShareManager')} ShareManager + * @property {import('../managers/RssFeedManager')} RssFeedManager + * @property {import('../managers/PodcastManager')} PodcastManager + */ + +/** + * @typedef PluginData + * @property {string} id + * @property {Object} manifest + * @property {import('../models/Plugin')} instance + * @property {Function} init + * @property {Function} onAction + * @property {Function} onConfigSave + */ + +class PluginManager { + constructor() { + /** @type {PluginData[]} */ + this.plugins = [] + } + + get pluginMetadataPath() { + return Path.posix.join(global.MetadataPath, 'plugins') + } + + get pluginManifests() { + return this.plugins.map((plugin) => plugin.manifest) + } + + /** + * + * @param {import('../models/Plugin')} pluginInstance + * @returns {PluginContext} + */ + getPluginContext(pluginInstance) { + return { + Logger, + Database, + SocketAuthority, + TaskManager, + pluginInstance, + ShareManager, + RssFeedManager, + PodcastManager + } + } + + /** + * + * @param {string} id + * @returns {PluginData} + */ + getPluginDataById(id) { + return this.plugins.find((plugin) => plugin.manifest.id === id) + } + + /** + * Validate and load a plugin from a directory + * TODO: Validatation + * + * @param {string} dirname + * @param {string} pluginPath + * @returns {Promise} + */ + async loadPlugin(dirname, pluginPath) { + 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}`) + 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 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 + } + + // 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 pluginContents = null + try { + pluginContents = require(Path.join(pluginPath, indexFile.name)) + } catch (error) { + Logger.error(`Error loading plugin ${pluginPath}`, error) + return null + } + + if (typeof pluginContents.init !== 'function') { + Logger.error(`Plugin ${pluginPath} does not have an init function`) + return null + } + + return { + id: manifestJson.id, + manifest: manifestJson, + init: pluginContents.init, + onAction: pluginContents.onAction, + onConfigSave: pluginContents.onConfigSave + } + } + + /** + * Get all plugins from the /metadata/plugins directory + */ + async getPluginsFromDirPath(pluginsPath) { + // Get all directories in the plugins directory + const pluginDirs = await fsExtra.readdir(pluginsPath, { withFileTypes: true }).then((files) => files.filter((file) => file.isDirectory())) + + const pluginsFound = [] + for (const pluginDir of pluginDirs) { + Logger.debug(`[PluginManager] Checking if directory "${pluginDir.name}" is a plugin`) + const plugin = await this.loadPlugin(pluginDir.name, Path.join(pluginsPath, pluginDir.name)) + if (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() { + await fsExtra.ensureDir(this.pluginMetadataPath) + + const pluginsFound = await this.getPluginsFromDirPath(this.pluginMetadataPath) + + if (process.env.DEV_PLUGINS_PATH) { + const devPluginsFound = await this.getPluginsFromDirPath(process.env.DEV_PLUGINS_PATH) + if (!devPluginsFound.length) { + Logger.warn(`[PluginManager] No plugins found in DEV_PLUGINS_PATH: ${process.env.DEV_PLUGINS_PATH}`) + } else { + pluginsFound.push(...devPluginsFound) + } + } + + 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}"`) + } + plugin.instance = existingPlugin + } else { + plugin.instance = 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() + + for (const plugin of this.plugins) { + Logger.info(`[PluginManager] Initializing plugin ${plugin.manifest.name}`) + plugin.init(this.getPluginContext(plugin.instance)) + } + } + + /** + * + * @param {PluginData} plugin + * @param {string} actionName + * @param {string} target + * @param {Object} data + * @returns {Promise} + */ + onAction(plugin, actionName, target, data) { + if (!plugin.onAction) { + Logger.error(`[PluginManager] onAction not implemented for plugin ${plugin.manifest.name}`) + return false + } + + 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 false + } + + Logger.info(`[PluginManager] Calling onAction for plugin ${plugin.manifest.name}`) + return plugin.onAction(this.getPluginContext(plugin.instance), actionName, target, data) + } + + /** + * + * @param {PluginData} plugin + * @param {Object} config + * @returns {Promise} + */ + onConfigSave(plugin, config) { + if (!plugin.onConfigSave) { + Logger.error(`[PluginManager] onConfigSave not implemented for plugin ${plugin.manifest.name}`) + return false + } + + Logger.info(`[PluginManager] Calling onConfigSave for plugin ${plugin.manifest.name}`) + return plugin.onConfigSave(this.getPluginContext(plugin.instance), config) + } +} +module.exports = new PluginManager() diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 456927c8c1..1f4511823b 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -586,4 +586,4 @@ class PodcastManager { Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`) } } -module.exports = PodcastManager +module.exports = new PodcastManager() 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..97d8872978 --- /dev/null +++ b/server/migrations/v2.18.0-add-plugins-table.js @@ -0,0 +1,68 @@ +/** + * @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, + isMissing: DataTypes.BOOLEAN, + 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..1c12bac456 --- /dev/null +++ b/server/models/Plugin.js @@ -0,0 +1,54 @@ +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 {boolean} */ + this.isMissing + /** @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, + isMissing: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + config: DataTypes.JSON, + extraData: DataTypes.JSON + }, + { + sequelize, + modelName: 'plugin' + } + ) + } +} + +module.exports = Plugin diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 235d25cd5f..d84f9ae7d6 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') @@ -46,8 +47,6 @@ class ApiRouter { this.abMergeManager = Server.abMergeManager /** @type {import('../managers/BackupManager')} */ this.backupManager = Server.backupManager - /** @type {import('../managers/PodcastManager')} */ - this.podcastManager = Server.podcastManager /** @type {import('../managers/AudioMetadataManager')} */ this.audioMetadataManager = Server.audioMetadataManager /** @type {import('../managers/CronManager')} */ @@ -320,6 +319,13 @@ 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.get('/plugins/:id/config', PluginController.middleware.bind(this), PluginController.getConfig.bind(this)) + this.router.post('/plugins/:id/action', PluginController.middleware.bind(this), PluginController.handleAction.bind(this)) + this.router.post('/plugins/:id/config', PluginController.middleware.bind(this), PluginController.handleConfigSave.bind(this)) + // // Misc Routes // 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 } +} 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..fb36790ed3 --- /dev/null +++ b/test/server/managers/plugins/Example/index.js @@ -0,0 +1,152 @@ +/** + * Called on initialization of the plugin + * + * @param {import('../../../server/managers/PluginManager').PluginContext} context + */ +module.exports.init = async (context) => { + // Set default config on first init + if (!context.pluginInstance.config) { + context.Logger.info('[ExamplePlugin] First init. Setting default config') + context.pluginInstance.config = { + requestAddress: '', + enable: false + } + await context.pluginInstance.save() + } + + context.Database.mediaProgressModel.addHook('afterSave', (instance, options) => { + context.Logger.debug(`[ExamplePlugin] mediaProgressModel afterSave hook for mediaProgress ${instance.id}`) + handleMediaProgressUpdate(context, instance) + }) + + context.Logger.info('[ExamplePlugin] Example plugin initialized') +} + +/** + * Called when an extension action is triggered + * + * @param {import('../../../server/managers/PluginManager').PluginContext} context + * @param {string} actionName + * @param {string} target + * @param {*} data + * @returns {Promise} + */ +module.exports.onAction = async (context, actionName, target, data) => { + context.Logger.info('[ExamplePlugin] Example plugin onAction', actionName, target, data) + + createTask(context) + + return true +} + +/** + * Called when the plugin config page is saved + * + * @param {import('../../../server/managers/PluginManager').PluginContext} context + * @param {*} config + * @returns {Promise} + */ +module.exports.onConfigSave = async (context, config) => { + context.Logger.info('[ExamplePlugin] Example plugin onConfigSave', config) + + if (!config.requestAddress || typeof config.requestAddress !== 'string') { + context.Logger.error('[ExamplePlugin] Invalid request address') + return { + error: 'Invalid request address' + } + } + if (typeof config.enable !== 'boolean') { + context.Logger.error('[ExamplePlugin] Invalid enable value') + return { + error: 'Invalid enable value' + } + } + + // Config would need to be validated + const updatedConfig = { + requestAddress: config.requestAddress, + enable: config.enable + } + context.pluginInstance.config = updatedConfig + await context.pluginInstance.save() + context.Logger.info('[ExamplePlugin] Example plugin config saved', updatedConfig) + return true +} + +// +// Helper functions +// +let numProgressSyncs = 0 + +/** + * Send media progress update to external requestAddress defined in config + * + * @param {import('../../../server/managers/PluginManager').PluginContext} context + * @param {import('../../../server/models/MediaProgress')} mediaProgress + */ +async function handleMediaProgressUpdate(context, mediaProgress) { + // Need to reload the model instance since it was passed in during init and may have values changed + await context.pluginInstance.reload() + + if (!context.pluginInstance.config?.enable) { + return + } + const requestAddress = context.pluginInstance.config.requestAddress + if (!requestAddress) { + context.Logger.error('[ExamplePlugin] Request address not set') + return + } + + 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)}% (total numProgressSyncs: ${numProgressSyncs})`) + + fetch(requestAddress, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: mediaItem.title, + progress: progressPercent + }) + }) + .then(() => { + context.Logger.info(`[ExamplePlugin] Media progress update sent for "${mediaItem.title}" ${Math.round(progressPercent)}%`) + numProgressSyncs++ + sendAdminMessageToast(context, `Synced "${mediaItem.title}" (total syncs: ${numProgressSyncs})`) + }) + .catch((error) => { + context.Logger.error(`[ExamplePlugin] Error sending media progress update: ${error.message}`) + }) + } +} + +/** + * Test socket authority + * + * @param {import('../../../server/managers/PluginManager').PluginContext} context + * @param {string} message + */ +async function sendAdminMessageToast(context, message) { + context.SocketAuthority.adminEmitter('admin_message', message) +} + +/** + * Test task manager + * + * @param {import('../../../server/managers/PluginManager').PluginContext} context + */ +async function createTask(context) { + const task = context.TaskManager.createAndAddTask('example_action', { text: 'Example Task' }, { text: 'This is an example task' }, true) + const pluginConfigEnabled = !!context.pluginInstance.config.enable + + setTimeout(() => { + task.setFinished({ text: `Plugin is ${pluginConfigEnabled ? 'enabled' : 'disabled'}` }) + context.TaskManager.taskFinished(task) + }, 5000) +} diff --git a/test/server/managers/plugins/Example/manifest.json b/test/server/managers/plugins/Example/manifest.json new file mode 100644 index 0000000000..11d261cd1e --- /dev/null +++ b/test/server/managers/plugins/Example/manifest.json @@ -0,0 +1,53 @@ +{ + "id": "e6205690-916c-4add-9a2b-2548266996ef", + "name": "Example", + "version": "1.0.0", + "owner": "advplyr", + "repositoryUrl": "https://github.com/example/example-plugin", + "documentationUrl": "https://example.com", + "description": "This is an example plugin", + "descriptionKey": "ExamplePluginDescription", + "extensions": [ + { + "target": "item.detail.actions", + "name": "itemActionExample", + "label": "Item Example Action", + "labelKey": "ItemExampleAction" + } + ], + "config": { + "description": "This is a description on how to configure the plugin", + "descriptionKey": "ExamplePluginConfigurationDescription", + "formFields": [ + { + "name": "requestAddress", + "label": "Request Address", + "labelKey": "LabelRequestAddress", + "type": "text" + }, + { + "name": "enable", + "label": "Enable", + "labelKey": "LabelEnable", + "type": "checkbox" + } + ] + }, + "localization": { + "de": { + "ExamplePluginDescription": "Dies ist ein Beispiel-Plugin", + "ItemExampleAction": "Item Example Action", + "LabelEnable": "Enable", + "ExamplePluginConfigurationDescription": "This is a description on how to configure the plugin", + "LabelRequestAddress": "Request Address" + } + }, + "releases": [ + { + "version": "1.0.0", + "changelog": "Initial release", + "timestamp": "2022-01-01T00:00:00Z", + "downloadUrl": "" + } + ] +} diff --git a/test/server/managers/plugins/Template/index.js b/test/server/managers/plugins/Template/index.js new file mode 100644 index 0000000000..eea8bb34f8 --- /dev/null +++ b/test/server/managers/plugins/Template/index.js @@ -0,0 +1,36 @@ +/** + * Called on initialization of the plugin + * + * @param {import('../../../server/managers/PluginManager').PluginContext} context + */ +module.exports.init = async (context) => { + context.Logger.info('[TemplatePlugin] plugin initialized') + // Can be used to initialize plugin config and/or setup Database hooks +} + +/** + * Called when an extension action is triggered + * + * @param {import('../../../server/managers/PluginManager').PluginContext} context + * @param {string} actionName + * @param {string} target + * @param {Object} data + * @returns {Promise} + */ +module.exports.onAction = async (context, actionName, target, data) => { + context.Logger.info('[TemplatePlugin] plugin onAction', actionName, target, data) + return true +} + +/** + * Called when the plugin config page is saved + * + * @param {import('../../../server/managers/PluginManager').PluginContext} context + * @param {Object} config + * @returns {Promise} + */ +module.exports.onConfigSave = async (context, config) => { + context.Logger.info('[TemplatePlugin] plugin onConfigSave', config) + // Maintener is responsible for validating and saving the config to their `pluginInstance` + return true +} diff --git a/test/server/managers/plugins/Template/manifest.json b/test/server/managers/plugins/Template/manifest.json new file mode 100644 index 0000000000..069f32c4e7 --- /dev/null +++ b/test/server/managers/plugins/Template/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "e6205690-916c-4add-9a2b-2548266996eg", + "name": "Template", + "version": "1.0.0", + "owner": "advplyr", + "repositoryUrl": "https://github.com/advplr/abs-report-for-review-plugin", + "documentationUrl": "https://audiobookshelf.org/guides", + "description": "This is a minimal template for an abs plugin", + "extensions": [], + "config": {}, + "localization": {}, + "releases": [ + { + "version": "1.0.0", + "changelog": "Initial release", + "timestamp": "2022-01-01T00:00:00Z", + "downloadUrl": "" + } + ] +} diff --git a/test/server/managers/plugins/readme.md b/test/server/managers/plugins/readme.md new file mode 100644 index 0000000000..e69de29bb2