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 `/_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(); 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 { 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 { 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, ); } }