From cfe3deff3b9f17c2e5a704e6e9857078351d9ee8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 21 Dec 2024 13:26:42 -0600 Subject: [PATCH] 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 0f701706..5abcb78e 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 1999eb83..9b3ba8f3 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 8c63cbdf..97d88729 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 cae34ccb..1c12bac4 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 fa7ae92e..32a3600c 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 } +}