All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m54s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m9s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 3m57s
CI / Import/export fidelity gate (push) Successful in 43s
Second half of #75 on top of the section node (2e96173/784f21d): - Install gate for section_style CSS (plugin-css.ts): every rule must be scoped under one of the plugin's own .dt-style-<pluginId>-<styleId> classes (enforced, not rewritten — grouping at-rules checked inside, @font-face/@keyframes exempt, statement at-rules rejected); positioning out of the content flow (anything but static/relative) is rejected as an overlay vector; "</style" is rejected as a breakout vector for inlined embedding. Hostile fixtures from the acceptance list are pinned in plugin-css.test.ts. - Web: usePondPlugins loads the pond's active plugins once per visit; SectionStyleSheets links each active style plugin's immutable styles.css; SectionStyleMenu (toolbar) wraps/restyles/unwraps with a picker fed from the plugins' i18n titles. Sections show a faint dashed hint while editing so unstyled (plugin-disabled) sections stay findable. - PDF export: PluginsService.sectionStyleCssForPond inlines the pond's active section-style CSS into the Gotenberg HTML, so styled sections survive the network-isolated render; covered in export.service.db.test. - Reference plugin packages/plugins/section-styles-basic (callout, info, warning, colored-box; theme-neutral semi-transparent backgrounds), a workspace package whose tests validate it against the SDK schema and whose real files run through the api install gate. - e2e section-styles.spec.ts: install → wrap → computed background in edit and read mode → unwrap → neutral fallback after disabling the plugin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client';
|
|
import { PinoLogger } from 'nestjs-pino';
|
|
import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk';
|
|
import type { PluginInstanceMode, PluginView, PondPluginSetting } from '@dorfteich/shared';
|
|
|
|
import { ClockService } from '../common/clock.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<DbPluginMode, PluginInstanceMode> = {
|
|
DISABLED: 'disabled',
|
|
OPTIONAL: 'optional',
|
|
REQUIRED: 'required',
|
|
};
|
|
|
|
const VIEW_MODE_TO_DB: Record<PluginInstanceMode, DbPluginMode> = {
|
|
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 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.
|
|
*/
|
|
async install(zip: Buffer): Promise<PluginView> {
|
|
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);
|
|
}
|
|
|
|
this.logger.info(
|
|
{ plugin: manifest.id, version: manifest.version, update: isActiveUpdate },
|
|
'audit: plugin installed',
|
|
);
|
|
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): Promise<PluginView> {
|
|
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] },
|
|
});
|
|
this.logger.info({ plugin: id, mode }, 'audit: plugin mode set');
|
|
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): Promise<void> {
|
|
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);
|
|
this.logger.info({ plugin: id }, 'audit: plugin uninstalled');
|
|
}
|
|
|
|
/**
|
|
* 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<PluginView[]> {
|
|
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 `</style>` 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<string> {
|
|
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<PondPluginSetting[]> {
|
|
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): Promise<void> {
|
|
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 },
|
|
});
|
|
this.logger.info({ plugin: pluginId, pond: pondId, enabled }, 'audit: pond plugin toggled');
|
|
}
|
|
|
|
/** All installed (non-removed) plugins, for the Site Admin list (#72). */
|
|
async list(): Promise<PluginView[]> {
|
|
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<PluginView | null> {
|
|
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(),
|
|
};
|
|
}
|
|
}
|