dorfteich/apps/api/src/plugins/plugin-watcher.service.ts
Claude Opus 4.8 f3938b7fdb
All checks were successful
CD / Build and push images (push) Successful in 2m41s
CI / Lint, typecheck, test (push) Successful in 3m20s
CI / Auth e2e pack (push) Successful in 4m9s
CI / Import/export fidelity gate (push) Successful in 54s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Has been skipped
Never crash boot on plugin dropzone setup; default PLUGINS_DIR in image (#71)
The Test stage crash-looped: PluginWatcherService.onModuleInit did `mkdir`
on the default `./data/plugins` (→ /app/data, not writable by the non-root
user) and an unhandled EACCES aborted bootstrap. Two fixes:

- Harden the watcher: its dropzone is an optional convenience over the GUI
  upload, so a setup failure now logs a warning and disables drop-to-install
  instead of taking down the api.
- Bake writable defaults (UPLOADS_DIR/PLUGINS_DIR=/data/…) into the api image
  so it works out of the box even where compose does not set them; compose
  still mounts named volumes there for persistence.

Migrations applied cleanly ("No pending migrations"); this was purely the
boot-time directory permission.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 17:25:42 +02:00

117 lines
4.5 KiB
TypeScript

import { watch, type FSWatcher } from 'node:fs';
import { readFile, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { ClockService } from '../common/clock.service';
import { AppConfig } from '../config/app-config.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginPackageError } from './plugin.constants';
import { PluginsService } from './plugins.service';
/** Debounce window: an editor/copy may fire several `rename` events per file. */
const DEBOUNCE_MS = 300;
/**
* Registers plugin ZIPs a Site Admin drops into `<PLUGINS_DIR>/_dropzone/`
* (ADR 0008 lifecycle). Each dropped file runs the exact same validation as the
* GUI upload; a valid package installs and the ZIP is consumed, an invalid one
* moves to `_quarantine/` with its error logged. The watcher is inert under
* `NODE_ENV=test` — tests call {@link processDropped} directly for determinism.
*/
@Injectable()
export class PluginWatcherService implements OnModuleInit, OnModuleDestroy {
private watcher: FSWatcher | undefined;
private readonly pendingTimers = new Map<string, NodeJS.Timeout>();
constructor(
private readonly plugins: PluginsService,
private readonly storage: PluginStorageService,
private readonly config: AppConfig,
private readonly clock: ClockService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PluginWatcherService.name);
}
async onModuleInit(): Promise<void> {
if (this.config.env.NODE_ENV === 'test') return;
// The dropzone is an optional convenience over the GUI upload. If its
// directory cannot be created or watched (e.g. PLUGINS_DIR is not writable
// on this deployment), log and carry on — it must never take down the api.
try {
await this.storage.ensureServiceDirs();
this.watcher = watch(this.storage.dropzoneDir, (_event, filename) => {
if (!filename || !filename.endsWith('.zip')) return;
this.schedule(filename.toString());
});
this.logger.info({ dir: this.storage.dropzoneDir }, 'watching plugin dropzone');
} catch (error) {
this.logger.warn(
{ err: error, dir: this.storage.dropzoneDir },
'plugin dropzone unavailable; drop-to-install disabled (GUI upload still works)',
);
}
}
onModuleDestroy(): void {
this.watcher?.close();
for (const timer of this.pendingTimers.values()) clearTimeout(timer);
this.pendingTimers.clear();
}
private schedule(filename: string): void {
const existing = this.pendingTimers.get(filename);
if (existing) clearTimeout(existing);
this.pendingTimers.set(
filename,
setTimeout(() => {
this.pendingTimers.delete(filename);
void this.processDropped(join(this.storage.dropzoneDir, filename));
}, DEBOUNCE_MS),
);
}
/**
* Installs one dropped ZIP. On success the file is removed from the dropzone;
* on any validation failure it is quarantined and the error logged. Returns
* the outcome so tests can assert without relying on filesystem events.
*/
async processDropped(
filePath: string,
): Promise<{ installed: true; id: string } | { installed: false; code: string }> {
let zip: Buffer;
try {
zip = await readFile(filePath);
} catch {
// The file vanished between the event and the read — nothing to do.
return { installed: false, code: 'plugin_invalid_zip' };
}
try {
const view = await this.plugins.install(zip);
// The package is now unpacked under PLUGINS_DIR; consume the dropped ZIP.
await rm(filePath, { force: true });
this.logger.info({ id: view.id, version: view.version }, 'installed plugin from dropzone');
return { installed: true, id: view.id };
} catch (error) {
const code = error instanceof PluginPackageError ? error.code : 'plugin_bad_structure';
await this.quarantine(filePath);
this.logger.warn({ code, file: filePath }, 'quarantined invalid plugin drop');
return { installed: false, code };
}
}
private async quarantine(filePath: string): Promise<void> {
const name = filePath.split('/').pop() ?? 'package.zip';
const stamp = this.clock.now().toISOString().replace(/[:.]/g, '-');
await this.storage.ensureServiceDirs();
await rename(filePath, join(this.storage.quarantineDir, `${stamp}-${name}`)).catch(
() => undefined,
);
}
}