// Abstraction ContentSource (spec §5) : DEV = FS local (grav-reference monté RO), // PROD = cache local alimenté par rsync/SSH. Le reste de l'app ne voit que cette interface. import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { mkdir } from 'node:fs/promises' import { join, normalize } from 'node:path' import type { Project } from '~~/types' import { readAllProjects } from './grav-reader' const execFileAsync = promisify(execFile) export interface SyncResult { ok: boolean summary: string syncedAt: string } export interface ContentSource { /** Racine FS des projets (catégories//…) */ readonly rootDir: string listProjects(): Promise getProject(id: string): Promise /** Chemin absolu d'un média, borné à rootDir (anti path-traversal). */ resolveMediaPath(projectId: string, filename: string): string | null /** Re-synchronise le contenu (no-op en DEV). */ refresh(): Promise /** Invalide le cache mémoire. */ invalidate(): void } const CACHE_TTL_MS = 30_000 abstract class FsContentSource implements ContentSource { private cache: { at: number; projects: Project[] } | null = null constructor(public readonly rootDir: string) {} async listProjects(): Promise { if (this.cache && Date.now() - this.cache.at < CACHE_TTL_MS) { return this.cache.projects } const projects = await readAllProjects(this.rootDir) this.cache = { at: Date.now(), projects } return projects } async getProject(id: string): Promise { const projects = await this.listProjects() return projects.find(p => p.id === id) ?? null } resolveMediaPath(projectId: string, filename: string): string | null { const abs = normalize(join(this.rootDir, projectId, filename)) if (!abs.startsWith(normalize(this.rootDir) + '/')) return null return abs } invalidate(): void { this.cache = null } abstract refresh(): Promise } /** Mode DEV : lecture directe du Grav local monté en lecture seule. */ export class DevContentSource extends FsContentSource { async refresh(): Promise { this.invalidate() const projects = await this.listProjects() return { ok: true, summary: `Mode dev : contenu local relu (${projects.length} projets).`, syncedAt: new Date().toISOString(), } } } /** * Mode PROD : cache local `data/content-cache/` alimenté par rsync/SSH * depuis la prod (spec §6). Lecture seule côté prod, incrémental. */ export class ProdContentSource extends FsContentSource { constructor( rootDir: string, private readonly remote: string, // ex. user@host:/chemin/user/pages/02.projets/ private readonly sshKey?: string, ) { super(rootDir) } async refresh(): Promise { if (!this.remote) { return { ok: false, summary: 'NUXT_SYNC_REMOTE non configuré : impossible de synchroniser.', syncedAt: new Date().toISOString(), } } await mkdir(this.rootDir, { recursive: true }) const ssh = ['ssh', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'BatchMode=yes'] if (this.sshKey) ssh.push('-i', this.sshKey) const started = Date.now() try { const { stdout } = await execFileAsync('rsync', [ '-az', '--delete', '--itemize-changes', '-e', ssh.join(' '), this.remote.endsWith('/') ? this.remote : `${this.remote}/`, `${this.rootDir}/`, ], { timeout: 15 * 60_000 }) this.invalidate() const changed = stdout.split('\n').filter(l => /^[<>ch.]\S+\s/.test(l)).length const projects = await this.listProjects() return { ok: true, summary: `${changed} fichier(s) mis à jour en ${Math.round((Date.now() - started) / 1000)} s — ${projects.length} projets.`, syncedAt: new Date().toISOString(), } } catch (err) { return { ok: false, summary: `Échec rsync : ${err instanceof Error ? err.message : String(err)}`, syncedAt: new Date().toISOString(), } } } } let source: ContentSource | null = null export function useContentSource(): ContentSource { if (source) return source const config = useRuntimeConfig() source = config.contentSource === 'prod' ? new ProdContentSource(join(config.dataDir, 'content-cache'), config.syncRemote, config.syncSshKey || undefined) : new DevContentSource(config.contentDir) return source }