import { Injectable } from '@nestjs/common'; import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk'; import type { PluginFallbackView, PluginInstanceMode, PluginView, PondPluginSetting, } from '@dorfteich/shared'; import { ClockService } from '../common/clock.service'; import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { PluginPackageService } from './plugin-package.service'; import { PluginStorageService } from './plugin-storage.service'; import { PluginPackageError, STYLES_FILE } from './plugin.constants'; const DB_MODE_TO_VIEW: Record = { DISABLED: 'disabled', OPTIONAL: 'optional', REQUIRED: 'required', }; const VIEW_MODE_TO_DB: Record = { disabled: 'DISABLED', optional: 'OPTIONAL', required: 'REQUIRED', }; /** * Install registry for plugin packages (ADR 0008, issue #71): validates and * unpacks an uploaded ZIP, records/updates its metadata, and removes it on * uninstall. The install path is shared by the admin upload endpoint and the * directory watcher. Instance-mode and per-pond activation (#72) also live * here, since they read/write the same registry. */ @Injectable() export class PluginsService { constructor( private readonly prisma: PrismaService, private readonly packages: PluginPackageService, private readonly storage: PluginStorageService, private readonly clock: ClockService, private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(PluginsService.name); } /** * Validates and installs (or updates) a plugin from a ZIP buffer. An update * (same id already installed and not removed) is accepted only when its * version is strictly higher; the admin's chosen instance `mode` is preserved * across updates. Files land atomically before the metadata pointer flips. */ /** `actor` is absent for dropzone installs (watcher, no session). */ async install(zip: Buffer, actor?: User): Promise { const { manifest, files } = this.packages.parse(zip); const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } }); const isActiveUpdate = existing !== null && existing.removedAt === null; if (isActiveUpdate && !isHigherVersion(manifest.version, existing.version)) { throw new PluginPackageError( 'plugin_version_not_higher', `Version ${manifest.version} does not exceed the installed ${existing.version}`, ); } // Write the new version's assets first — the metadata still points at the // old version until the upsert below, so serving never 404s mid-update. await this.storage.writeVersion(manifest.id, manifest.version, files); const record = await this.prisma.plugin.upsert({ where: { id: manifest.id }, create: { id: manifest.id, name: manifest.name, version: manifest.version, apiVersion: manifest.apiVersion, kind: manifest.kind, manifest: manifest as unknown as Prisma.InputJsonValue, }, update: { name: manifest.name, version: manifest.version, apiVersion: manifest.apiVersion, kind: manifest.kind, manifest: manifest as unknown as Prisma.InputJsonValue, // Reinstalling a previously removed plugin clears the tombstone. removedAt: null, }, }); // Drop the superseded version's files once the pointer has moved. if (existing && existing.version !== manifest.version) { await this.storage.removeVersion(manifest.id, existing.version); } await this.audit.record({ action: 'plugin.installed', actorId: actor?.id, targetType: 'plugin', targetId: manifest.id, details: { version: manifest.version, update: isActiveUpdate }, }); return this.toView(record); } /** * Sets a plugin's instance mode (Site Admin, #72). `required` makes it active * in every pond and blocks uninstall; `disabled` hides it everywhere; * `optional` lets Pond Admins toggle it per pond. Per-pond activation rows are * kept across mode changes (they are simply ignored while non-optional), so * flipping optional→required→optional restores the previous per-pond choices. */ async setMode(id: string, mode: PluginInstanceMode, actor?: User): Promise { const plugin = await this.prisma.plugin.findUnique({ where: { id } }); if (!plugin || plugin.removedAt !== null) { throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`); } const updated = await this.prisma.plugin.update({ where: { id }, data: { mode: VIEW_MODE_TO_DB[mode] }, }); await this.audit.record({ action: 'plugin.mode_set', actorId: actor?.id, targetType: 'plugin', targetId: id, details: { mode }, }); return this.toView(updated); } /** * Uninstalls a plugin: refused while `required`; otherwise the metadata is * tombstoned (`removedAt` set, per-pond activations dropped) and every file is * removed from disk. */ async uninstall(id: string, actor?: User): Promise { const plugin = await this.prisma.plugin.findUnique({ where: { id } }); if (!plugin || plugin.removedAt !== null) { throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`); } if (plugin.mode === 'REQUIRED') { throw new PluginPackageError( 'plugin_required_cannot_uninstall', `Plugin ${id} is required and cannot be uninstalled`, ); } await this.prisma.$transaction([ this.prisma.pondPlugin.deleteMany({ where: { pluginId: id } }), this.prisma.plugin.update({ where: { id }, data: { removedAt: this.clock.now() }, }), ]); await this.storage.removePlugin(id); await this.audit.record({ action: 'plugin.uninstalled', actorId: actor?.id, targetType: 'plugin', targetId: id, }); } /** * The plugins active for one pond (#72): every `required` plugin plus the * `optional` ones this pond has switched on. This is the list the SPA loads * per pond to decide which plugins to mount. `disabled` and un-activated * optional plugins never appear. */ async listForPond(pondId: string): Promise { const [plugins, activations] = await Promise.all([ this.prisma.plugin.findMany({ where: { removedAt: null, mode: { in: ['REQUIRED', 'OPTIONAL'] } }, orderBy: { name: 'asc' }, }), this.prisma.pondPlugin.findMany({ where: { pondId } }), ]); const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled])); return plugins .filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true)) .map((p) => this.toView(p)); } /** * The concatenated stylesheets of the pond's active `section_style` plugins * (issue #75), for inlining into self-contained renders (PDF export HTML). * Every stylesheet passed the install gate — scoped selectors, no external * fetches, no `` breakout — so embedding it verbatim is safe. A * missing file (e.g. a volume restored without one version dir) degrades to * neutral sections rather than failing the caller. */ async sectionStyleCssForPond(pondId: string): Promise { const parts: string[] = []; for (const plugin of await this.listForPond(pondId)) { if (plugin.kind !== 'section_style') continue; const path = this.storage.assetPath(plugin.id, plugin.version, STYLES_FILE); if (!path || !(await this.storage.assetExists(path))) continue; parts.push(`/* ${plugin.id}@${plugin.version} */\n${await this.storage.readAsset(path)}`); } return parts.join('\n'); } /** * The optional plugins a Pond Admin may toggle for one pond (#72), each with * this pond's current on/off state (default off — activation is opt-in). */ async listPondSettings(pondId: string): Promise { const [plugins, activations] = await Promise.all([ this.prisma.plugin.findMany({ where: { removedAt: null, mode: 'OPTIONAL' }, orderBy: { name: 'asc' }, }), this.prisma.pondPlugin.findMany({ where: { pondId } }), ]); const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled])); return plugins.map((p) => ({ plugin: this.toView(p), enabled: enabled.get(p.id) === true })); } /** * Toggles an `optional` plugin for one pond (Pond Admin, #72). Only optional * plugins are per-pond choices; toggling a required/disabled/absent plugin is * rejected so the UI cannot desync the model. */ async setPondActivation( pondId: string, pluginId: string, enabled: boolean, actor?: User, ): Promise { const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } }); if (!plugin || plugin.removedAt !== null) { throw new PluginPackageError('plugin_not_found', `Plugin ${pluginId} is not installed`); } if (plugin.mode !== 'OPTIONAL') { throw new PluginPackageError( 'plugin_not_optional', `Plugin ${pluginId} is not optional and cannot be toggled per pond`, ); } await this.prisma.pondPlugin.upsert({ where: { pondId_pluginId: { pondId, pluginId } }, create: { pondId, pluginId, enabled }, update: { enabled }, }); await this.audit.record({ action: 'plugin.pond_toggled', actorId: actor?.id, targetType: 'pond', targetId: pondId, details: { plugin: pluginId, enabled }, }); } /** * The stored-manifest fallback a `plugin_block` renders while its plugin is * inactive (issue #76). Looks past `removedAt` on purpose: the manifest row * is the uninstall tombstone (data-model.md), so blocks referencing a gone * plugin still resolve a name and text. An image fallback is served from the * plugin's assets and thus only resolvable while those files exist. */ async fallbackFor(pluginId: string): Promise { const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } }); if (!plugin) return null; const manifest = plugin.manifest as unknown as PluginManifest; const declared = manifest.fallback ?? null; let fallback: PluginFallbackView['fallback'] = null; if (declared?.type === 'text') { fallback = declared; } else if (declared?.type === 'image' && plugin.removedAt === null) { fallback = { type: 'image', url: `/api/v1/plugins/${plugin.id}/${plugin.version}/${declared.value}`, }; } return { pluginId: plugin.id, name: plugin.name, fallback }; } /** All installed (non-removed) plugins, for the Site Admin list (#72). */ async list(): Promise { const plugins = await this.prisma.plugin.findMany({ where: { removedAt: null }, orderBy: { name: 'asc' }, }); return plugins.map((plugin) => this.toView(plugin)); } /** One installed plugin, or `null` if absent/removed. */ async get(id: string): Promise { const plugin = await this.prisma.plugin.findUnique({ where: { id } }); if (!plugin || plugin.removedAt !== null) return null; return this.toView(plugin); } private toView(plugin: Plugin): PluginView { const manifest = plugin.manifest as unknown as PluginManifest; return { id: plugin.id, name: plugin.name, version: plugin.version, apiVersion: plugin.apiVersion, kind: plugin.kind, mode: DB_MODE_TO_VIEW[plugin.mode], permissions: manifest.permissions ?? [], extensionPoints: manifest.extensionPoints.map((point) => ({ type: point.type, id: point.id, title: point.title, })), assetBasePath: `/api/v1/plugins/${plugin.id}/${plugin.version}/`, license: manifest.license, homepage: manifest.homepage, installedAt: plugin.installedAt.toISOString(), updatedAt: plugin.updatedAt.toISOString(), }; } }