Scan borné du système de fichiers (racines configurables, défaut home ; profondeur/nombre/timeout bornés ; symlinks non suivis ; exclusions node_modules/dotdirs) qui auto-enregistre les nouveaux dépôts. Insertion atomique ON CONFLICT DO NOTHING (idempotence + anti-résurrection d'un dépôt masqué + anti-course). Scan au démarrage + bouton manuel + re-scan périodique (RepoDiscoveryService, démarré dans runDaemon). Colonne repos.hidden : masquer = conservé en DB mais exclu du dashboard et jamais ré-ajouté ; supprimer = re-découvrable. UI : bouton œil par dépôt + bascule afficher-les-masqués sur le dashboard, section Découverte dans les Réglages (racines + intervalle, allow-list stricte). Robustesse : listAllWorktrees tolère l'échec git par dépôt ; flag --no-discover (escape hatch + hermétisme des acceptations). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
3.6 KiB
TypeScript
97 lines
3.6 KiB
TypeScript
// 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<SettingsResponse> => snapshot());
|
|
|
|
app.patch('/api/v1/settings', async (req, reply) => {
|
|
const body = (req.body as Partial<UpdateSettingsRequest> | 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());
|
|
});
|
|
}
|