import express from 'express'; import Item from '../models/Item'; import Settings from '../models/Settings'; import { requireAuth, requireCollectionRole } from '../middleware/authMiddleware'; import { registry } from '../core/registry'; import { buildConfigFromSubmission, writeCustomPluginDir, deleteCustomPluginDir, getCustomConfig, isValidIcon, CUSTOM_PLUGIN_PALETTE, CUSTOM_PLUGIN_ICONS } from '../core/customPluginStore'; import { sanitizeExtraFields, getExtraFields, nativeLookalikesByPlugin } from '../core/pluginExtraFields'; import { SUPPORTED_LANGUAGES } from '../config/constants'; import { placeholderUrl } from '../core/placeholderImage'; import { cardFieldCandidates, isTranslationKey, getCardLines, MAX_CARD_LINES, CORNER_POSITIONS, DEFAULT_CORNER_POSITION } from '../core/cardFields'; import { filterCandidates, filterOverrides, activeFilters, sanitizeFilterSelection } from '../core/filterFields'; import { registerPluginDirAtRuntime, unregisterPluginAtRuntime } from '../core/pluginRuntime'; import { saveCustomPluginToDB, deleteCustomPluginFromDB } from '../core/customPluginSync'; import { generatePluginDraft } from '../core/ai/pluginGenerate'; import { getAiConfig } from '../core/ai/instance'; import { isAiConfigured } from '../core/ai/config'; /** * /create-plugin: builder for user-created ("custom") plugins. * * Collection admins design a manual-only item type (fields, formats, image shape...) * with a live preview; saving writes plugins//{plugin.json,index.ts} and * hot-registers the plugin without restarting the server. */ const router = express.Router(); router.use(requireAuth, requireCollectionRole('admin')); /** A description is a sentence, not a pasted list: generous but not import-sized. */ const MAX_DESCRIPTION_CHARS = 2000; async function listCustomPlugins() { const out: { config: any; itemCount: number }[] = []; for (const p of registry.getAll()) { const config = getCustomConfig(p); if (!config) continue; // Instance-wide count: the plugin folder is global, not per-collection const itemCount = await Item.countDocuments({ kind: p.kind }); out.push({ config, itemCount }); } return out; } // GET /create-plugin[?edit=] -> plugin editor page router.get('/', async (req: any, res: any) => { try { const customPlugins = await listCustomPlugins(); const editId = typeof req.query.edit === 'string' ? req.query.edit : ''; const edited = editId ? (customPlugins.find(c => c.config.id === editId)?.config || null) : null; // An uploaded default cover is a few hundred kB of base64 and the config is inlined // in the page: the builder only gets a URL to preview it, and the stored value is // kept server-side unless the form actually posts a new one. const editPlaceholder = edited ? placeholderUrl(edited.id, edited.defaultCover) : ''; let editConfig: any = null; if (edited) { const { defaultCover, ...rest } = edited; editConfig = rest; } const settings = res.locals.settings; const customization = settings?.pluginCustomization || {}; // Cosmetic-override targets: every plugin, with translated labels for the modal. // Read through the decorated registry so the collection's user-defined fields are // offered as card fields too. // Read on the bare registry: the decorated one lists the user-defined fields among // the plugin's own, which would make every one of them look like itself. const lookalikes = nativeLookalikesByPlugin( settings, registry.getAll(), key => SUPPORTED_LANGUAGES.map(lng => req.t(key, { lng })) ); const customizablePlugins = res.locals.registry.getAll().map((p: any) => ({ id: p.id, label: req.t(p.label), icon: p.icon, isCustom: !!getCustomConfig(p), enabled: settings?.modules?.[p.collectionType] === true, formats: (p.formats || []).map((f: any) => ({ value: f.value, label: req.t(f.label) })), current: customization[p.id] || {}, extraFields: getExtraFields(settings, p.id), // User-defined field key -> label of the plugin's own field it looks like lookalikes: Object.fromEntries((lookalikes[p.id] || []).map(l => [l.name, req.t(l.nativeLabel)])), cardFieldChoices: cardFieldCandidates(p).map(f => ({ name: f.name, label: req.t(f.label, { defaultValue: f.label }) })), // What the cards show today, so an untouched plugin opens on its real state cardFields: p.defaultCardFields || [p.creatorField], // Read on the bare plugin for the same reason as the lookalikes above filterChoices: (() => { const bare = registry.get(p.id); if (!bare) return []; const candidates = filterCandidates(bare, getExtraFields(settings, p.id)); const on = new Set(activeFilters(candidates, filterOverrides(customization, p.id)).map(c => c.id)); return candidates.map(c => ({ id: c.id, label: isTranslationKey(c.label) ? req.t(c.label, { defaultValue: c.label }) : c.label, on: on.has(c.id) })); })() })); res.render('create-plugin', { user: res.locals.user, customPlugins, customizablePlugins, editConfig, editPlaceholder, palette: CUSTOM_PLUGIN_PALETTE, iconChoices: CUSTOM_PLUGIN_ICONS, maxCardLines: MAX_CARD_LINES, aiConfigured: isAiConfigured(await getAiConfig()), maxAiDescriptionChars: MAX_DESCRIPTION_CHARS }); } catch (err: any) { console.error('[PluginBuilder] page error:', err); res.status(500).send(req.t('errors.generic_server_error')); } }); // POST /create-plugin/customize/:pluginId -> per-collection cosmetic overrides // (icon, format badge colors). An empty submission clears the override. router.post('/customize/:pluginId', async (req: any, res: any) => { try { const plugin = registry.get(req.params.pluginId); if (!plugin) { return res.status(404).json({ success: false, error: req.t('errors.not_found') }); } const activeCollectionId = res.locals.activeCollectionId; if (!activeCollectionId) { return res.status(400).json({ success: false, error: req.t('errors.generic_server_error') }); } const cosmetics: any = {}; const icon = typeof req.body.icon === 'string' ? req.body.icon.trim() : ''; if (icon) { if (!isValidIcon(icon)) { return res.status(400).json({ success: false, error: req.t('create_plugin.err_bad_icon') }); } cosmetics.icon = icon; } const submitted = req.body.formatColors || {}; const formatColors: Record = {}; for (const f of plugin.formats || []) { const color = submitted[f.value]; if (typeof color === 'string' && (CUSTOM_PLUGIN_PALETTE as readonly string[]).includes(color)) { formatColors[f.value] = color; } } if (Object.keys(formatColors).length > 0) cosmetics.formatColors = formatColors; if (req.body.sortFormats === true || req.body.sortFormats === 'true') cosmetics.sortFormats = true; // Corner field. Checked against what the plugin actually offers, so a crafted payload // cannot put an arbitrary path on every cover; an empty submission clears it. if (typeof req.body.cornerField === 'string' && req.body.cornerField) { const decorated = res.locals.registry.get(plugin.id) || plugin; if (cardFieldCandidates(decorated).some(f => f.name === req.body.cornerField)) { cosmetics.cornerField = req.body.cornerField; // Only ever one of the free corners; anything else falls back to the default // rather than being written and rendered as a broken class string. cosmetics.cornerPosition = Object.prototype.hasOwnProperty.call(CORNER_POSITIONS, req.body.cornerPosition) ? req.body.cornerPosition : DEFAULT_CORNER_POSITION; } } // Card fields. Reordered to the plugin's own field order (the modal is a selection, // not a ranking) and capped, so a crafted payload cannot overflow the card. if (Array.isArray(req.body.cardFields)) { const decorated = res.locals.registry.get(plugin.id) || plugin; const picked = new Set(req.body.cardFields.filter((n: any) => typeof n === 'string')); cosmetics.cardFields = cardFieldCandidates(decorated) .map(f => f.name) .filter(name => picked.has(name)) .slice(0, MAX_CARD_LINES); } // Filters shown on the collection page once this type is selected. Only what differs // from the default is kept (see core/filterFields.ts), so leaving every box as it // came stores nothing. Read against the fields declared before this save: one added // in the same submission has no box yet and simply keeps its default. if (Array.isArray(req.body.filters)) { const candidates = filterCandidates(plugin, getExtraFields(res.locals.settings, plugin.id)); const filters = sanitizeFilterSelection(candidates, req.body.filters); if (Object.keys(filters).length > 0) cosmetics.filters = filters; } // User-defined fields. Only validated and written when the submission carries the // key, so a caller that only changes the icon never touches the declared fields. let extraUpdate: { set?: any; unset?: string } | null = null; if (req.body.extraFields !== undefined) { const existingFields = getExtraFields(res.locals.settings, plugin.id); const { fields, errors } = sanitizeExtraFields(req.body.extraFields, plugin, existingFields); if (errors.length > 0) { return res.status(400).json({ success: false, error: errors.map(e => req.t(e)).join(' ') }); } const path = `pluginExtraFields.${plugin.id}`; extraUpdate = fields.length > 0 ? { set: { [path]: fields } } : { unset: path }; } const cosmeticsPath = `pluginCustomization.${plugin.id}`; const $set: any = {}; const $unset: any = {}; if (cosmetics.icon || cosmetics.formatColors || cosmetics.cardFields || cosmetics.sortFormats || cosmetics.cornerField || cosmetics.filters) $set[cosmeticsPath] = cosmetics; else $unset[cosmeticsPath] = ''; if (extraUpdate?.set) Object.assign($set, extraUpdate.set); if (extraUpdate?.unset) $unset[extraUpdate.unset] = ''; const update: any = {}; if (Object.keys($set).length > 0) update.$set = $set; if (Object.keys($unset).length > 0) update.$unset = $unset; await Settings.updateOne({ collection: activeCollectionId }, update); res.json({ success: true }); } catch (err: any) { console.error('[PluginBuilder] customize error:', err); res.status(500).json({ success: false, error: req.t('errors.generic_server_error') }); } }); // POST /create-plugin/ai-generate -> draft a plugin config from a text description router.post('/ai-generate', async (req: any, res: any) => { try { const description = typeof req.body?.description === 'string' ? req.body.description.trim() : ''; if (!description) { return res.status(400).json({ success: false, errors: [req.t('create_plugin.ai_err_empty')] }); } if (description.length > MAX_DESCRIPTION_CHARS) { return res.status(400).json({ success: false, errors: [req.t('create_plugin.ai_err_too_long', { max: MAX_DESCRIPTION_CHARS })] }); } const config = await getAiConfig(); if (!isAiConfigured(config)) { return res.status(400).json({ success: false, errors: [req.t('ai.err_not_configured')] }); } let draft; try { draft = await generatePluginDraft(config, description); } catch (err: any) { return res.status(502).json({ success: false, errors: [req.t('create_plugin.ai_err_generation_failed', { error: err.message })] }); } if (!draft) { return res.status(502).json({ success: false, errors: [req.t('create_plugin.ai_err_failed')] }); } res.json({ success: true, draft }); } catch (err: any) { console.error('[PluginBuilder] AI generate error:', err); res.status(500).json({ success: false, errors: [req.t('errors.generic_server_error')] }); } }); // POST /create-plugin/save -> create or update a custom plugin (JSON API) router.post('/save', async (req: any, res: any) => { try { const editId = typeof req.body.editId === 'string' ? req.body.editId : ''; const existingPlugin = editId ? registry.get(editId) : undefined; const existing = existingPlugin ? getCustomConfig(existingPlugin) : undefined; if (editId && !existing) { return res.status(404).json({ success: false, errors: [req.t('errors.not_found')] }); } const { config, errors } = buildConfigFromSubmission(req.body, existing); if (!config) { return res.status(400).json({ success: false, errors: errors.map((k: string) => req.t(k)) }); } writeCustomPluginDir(config); const result = registerPluginDirAtRuntime(config.id, existing?.id); if (!result.plugin) { // Should not happen (config was validated), but never leave a broken folder behind try { deleteCustomPluginDir(config.id); } catch { /* best effort */ } console.error(`[PluginBuilder] hot-register failed for ${config.id}:`, result.errors); return res.status(500).json({ success: false, errors: result.errors }); } // Rename: drop the old folder once the new registration is live if (existing && existing.id !== config.id) { try { deleteCustomPluginDir(existing.id); } catch (err: any) { console.warn(`[PluginBuilder] could not remove old folder plugins/${existing.id}: ${err.message}`); } } // Persist to the DB (source of truth). The folder is just a regenerable cache, // so this is what makes the plugin survive rebuilds and travel with backups. await saveCustomPluginToDB(config, existing?.id); // Enable the module right away for the admin's active collection const activeCollectionId = res.locals.activeCollectionId; if (activeCollectionId) { const update: any = { $set: { [`modules.${config.id}`]: true } }; if (existing && existing.id !== config.id) { update.$unset = { [`modules.${existing.id}`]: '' }; } await Settings.updateOne({ collection: activeCollectionId }, update); } res.json({ success: true, id: config.id }); } catch (err: any) { console.error('[PluginBuilder] save error:', err); res.status(500).json({ success: false, errors: [req.t('errors.generic_server_error')] }); } }); // POST /create-plugin/delete/:id -> remove a custom plugin (items stay in DB) router.post('/delete/:id', async (req: any, res: any) => { try { const plugin = registry.get(req.params.id); const config = plugin ? getCustomConfig(plugin) : undefined; if (!plugin || !config) { return res.status(404).json({ success: false, error: req.t('errors.not_found') }); } deleteCustomPluginDir(config.id); unregisterPluginAtRuntime(config.id); await deleteCustomPluginFromDB(config.id); res.json({ success: true }); } catch (err: any) { console.error('[PluginBuilder] delete error:', err); res.status(500).json({ success: false, error: req.t('errors.generic_server_error') }); } }); export default router;