// Réglages exposés à l'UI (onglet Réglages). Frontière de sécurité CENTRALE : la table `settings` // contient aussi des SECRETS (server_secret, vapid_private). Ces routes n'exposent QUE des champs // non sensibles et n'écrivent QUE des clés explicitement allow-listées — jamais les secrets. import type { FastifyInstance } from 'fastify'; import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared'; import type { Config } from '../config.js'; import { type Db, getSetting, setSetting } from '../db/index.js'; import type { PushService } from '../core/push-service.js'; import { SCAN_INTERVAL_KEY, SCAN_ROOTS_KEY, normalizeScanIntervalMin, normalizeScanRoots, readScanIntervalMin, readScanRoots, } from '../core/scan-settings.js'; // Clés de `settings` modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici. const GITEA_URL_KEY = 'gitea_url'; /** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */ function normalizeGiteaUrl(raw: string): string | null { const trimmed = raw.trim(); if (trimmed === '') return null; let url: URL; try { url = new URL(trimmed); } catch { return null; } if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; return url.toString(); } export function registerSettingsRoutes( app: FastifyInstance, db: Db, config: Config, serverVersion: string, push: PushService, ): void { // '' (effacé) est normalisé en null côté réponse. const readGiteaUrl = (): string | null => getSetting(db, GITEA_URL_KEY) || null; const serverInfo = (): ServerInfo => ({ version: serverVersion, port: config.port, bind: config.bind, allowedOrigins: config.allowedOrigins, dataDir: config.dataDir, vapidPublicKey: push.publicKey() || null, vapidContact: config.vapidContact, }); const snapshot = (): SettingsResponse => ({ settings: { giteaUrl: readGiteaUrl(), scanRoots: readScanRoots(db), scanIntervalMin: readScanIntervalMin(db), }, server: serverInfo(), }); app.get('/api/v1/settings', async (): Promise => snapshot()); app.patch('/api/v1/settings', async (req, reply) => { const body = (req.body as Partial | null) ?? {}; if ('giteaUrl' in body) { const v = body.giteaUrl; if (v === null || v === '') { setSetting(db, GITEA_URL_KEY, ''); // effacement } else if (typeof v === 'string') { const normalized = normalizeGiteaUrl(v); if (!normalized) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a valid http(s) URL' } }); } setSetting(db, GITEA_URL_KEY, normalized); } else { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a string or null' } }); } } if ('scanRoots' in body) { const roots = normalizeScanRoots(body.scanRoots); if (!roots) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'scanRoots must be an array of ≤16 absolute, normalized paths (never "/")' } }); } setSetting(db, SCAN_ROOTS_KEY, JSON.stringify(roots)); } if ('scanIntervalMin' in body) { const interval = normalizeScanIntervalMin(body.scanIntervalMin); if (interval === null) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'scanIntervalMin must be an integer between 0 and 1440' } }); } setSetting(db, SCAN_INTERVAL_KEY, String(interval)); } return reply.send(snapshot()); }); }