import { BadRequestException, Injectable } from '@nestjs/common'; import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared'; import { Prisma } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { z } from 'zod'; import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; /** * The typed registry of instance settings. Adding a setting = adding a * line here; readers get parsed, defaulted values and writers get * validation for free. Secrets never go through this table * (security.md §Secrets). */ export const INSTANCE_SETTINGS = { 'auth.registrationMode': z.enum(['open', 'closed']).default('open'), 'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'), 'instance.defaultLocale': z.enum(['de', 'en']).default('en'), // Instance-default quotas (ADR 0011); per-user/per-pond overrides live // in quota_overrides and win over these (QuotaService, issue #22). 'quota.editorsPerPond': z.number().int().min(0).default(5), 'quota.readersPerPond': z.number().int().min(0).default(50), 'quota.additionalPonds': z.number().int().min(0).default(0), 'quota.storageBytes': z .number() .int() .min(0) .default(1024 * 1024 * 1024), 'quota.maxFileBytes': z .number() .int() .min(0) .default(25 * 1024 * 1024), // Trash retention (ADR 0013, issue #31): days a soft-deleted page stays // restorable before the daily purge job removes it for good. 'trash.retentionDays': z.number().int().min(1).default(30), // Audit-trail retention (issue #196): days an `audit_log` entry is kept // before the daily retention job removes it; the deletion itself is // recorded (`audit.pruned`) so the gap is explainable. The read-access // trail (#224) is deliberately NOT covered — it gets its own period. 'audit.retentionDays': z.number().int().min(1).default(365), // Conversion-job payload retention (issue #233): days a finished // (succeeded/failed) import/export job keeps its raw input/result bytes // before the daily prune job nulls them. The row itself survives for // status/audit purposes; PENDING/RUNNING jobs are never touched. 'conversion.payloadRetentionDays': z.number().int().min(1).default(30), // Non-image upload allowlist (ADR 0011, issue #61): lowercase extensions // without the dot. Images are always allowed regardless; SVG is governed // by `upload.svgPolicy`. Normalized (lowercased, dot-stripped, deduped) so // an admin can paste `.PDF` or `pdf` interchangeably. 'upload.allowedExtensions': z .array(z.string()) .transform((exts) => [ ...new Set(exts.map((e) => e.trim().replace(/^\./, '').toLowerCase()).filter(Boolean)), ]) .pipe(z.array(z.string().regex(/^[a-z0-9]+$/))) .default([...DEFAULT_ATTACHMENT_EXTENSIONS]), // SVG upload handling (security.md §Uploads): sanitize strips scripts and // event handlers with a maintained library; reject refuses SVG outright. 'upload.svgPolicy': z.enum(['reject', 'sanitize']).default('sanitize'), // Public REST API master switch (issue #104, default off): every // /api/public/v1 route answers 404 while disabled. Individual ponds // additionally opt in through their pond settings (`apiEnabled`). 'api.enabled': z.boolean().default(false), // Built-in MCP endpoint master switch (issue #105, default off) — // independent of the REST switch; ponds opt in via `mcpEnabled`. 'mcp.enabled': z.boolean().default(false), // Atom feed master switch (issue #191). Default ON: feeds predate the // switch, so existing instances and their subscribed readers keep // working; the VS-NfD reference configuration (#227) turns it off. // While disabled, the feed routes AND the feed-token management answer // 404 (existence hidden, same semantics as the two switches above). 'feeds.enabled': z.boolean().default(true), // Backup targets (ADR 0015, issue #103). The backup sidecar reads these // rows directly (apps/backup settings.ts — keep the schemas in sync); the // Nextcloud app password is NOT here, it lives in the secret store // (security.md §Secrets). localRetentionDays `null` = no admin override, // the sidecar's BACKUP_RETENTION_DAYS env stays authoritative. 'backup.localRetentionDays': z.number().int().min(1).nullable().default(null), 'backup.remoteRetentionDays': z.number().int().min(1).default(30), 'backup.nextcloud.enabled': z.boolean().default(false), 'backup.nextcloud.baseUrl': z.string().trim().url().or(z.literal('')).default(''), 'backup.nextcloud.username': z.string().trim().max(200).default(''), 'backup.nextcloud.folder': z .string() .trim() .max(500) .refine((folder) => !folder.split('/').some((s) => s === '.' || s === '..'), { message: 'validation.invalid', }) .default('dorfteich-backups'), 'backup.nextcloud.uploadSchedule': z.enum(['off', 'daily', 'weekly']).default('daily'), // Instance legal pages (issue #82, security.md §Privacy): Markdown texts // for imprint and privacy policy, rendered publicly at /legal/. // Empty = not configured yet (the legal pages then show a notice and // Site Admins a warning banner instead of silently missing). 'legal.imprint': z.string().max(100_000).default(''), 'legal.privacyPolicy': z.string().max(100_000).default(''), // Landing-page body: the Site Admin's Markdown for the public home page // (`/`), rendered through the same sanitizing pipeline as the legal pages. // Empty = the built-in default welcome text is shown instead. 'home.content': z.string().max(100_000).default(''), // When the first-run setup wizard completed (issue #80). Null = the // instance still requires setup and only /setup/* is reachable; once set // the wizard is locked for good (SetupStateService). Written by the wizard, // env pre-seeding, the fixture seed, and a backfill migration for // instances that predate the wizard. 'setup.completedAt': z.string().nullable().default(null), } as const; export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS; export type InstanceSettingValue = z.infer< (typeof INSTANCE_SETTINGS)[K] >; export type InstanceSettings = { [K in InstanceSettingKey]: InstanceSettingValue }; /** * Typed, cached access to instance_settings. The in-process cache is * invalidated on every write; with one api container per stage * (ADR 0002) that is sufficient — no cross-instance bus needed yet. */ @Injectable() export class InstanceSettingsService { private cache = new Map(); constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly logger: PinoLogger, ) { this.logger.setContext(InstanceSettingsService.name); } async get(key: K): Promise> { if (this.cache.has(key)) return this.cache.get(key) as InstanceSettingValue; const row = await this.prisma.instanceSetting.findUnique({ where: { key } }); const parsed = INSTANCE_SETTINGS[key].safeParse(row?.value); // Unknown/invalid stored values fall back to the schema default // instead of breaking the instance. const value = parsed.success ? parsed.data : INSTANCE_SETTINGS[key].parse(undefined); this.cache.set(key, value); return value as InstanceSettingValue; } async getAll(): Promise { const entries = await Promise.all( (Object.keys(INSTANCE_SETTINGS) as InstanceSettingKey[]).map( async (key) => [key, await this.get(key)] as const, ), ); return Object.fromEntries(entries) as InstanceSettings; } async set( key: K, value: unknown, actorUserId: string, ): Promise> { const parsed = INSTANCE_SETTINGS[key].safeParse(value); if (!parsed.success) { throw new BadRequestException({ code: 'bad_request', details: { [key]: parsed.error.issues.map((i) => i.message) }, }); } // Nullable settings (setup.completedAt) store JSON null explicitly — // Prisma requires the sentinel for that. const stored = parsed.data === null ? Prisma.JsonNull : parsed.data; await this.prisma.instanceSetting.upsert({ where: { key }, create: { key, value: stored }, update: { value: stored }, }); this.cache.set(key, parsed.data); // Values stay out of the trail: legal texts are long, and future keys // could be sensitive — the key names what changed, the log has the actor. await this.audit.record({ action: 'settings.changed', actorId: actorUserId, targetType: 'setting', targetId: key, }); return parsed.data as InstanceSettingValue; } }