import { existsSync } from "fs"; import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises"; import path from "path"; import { storageDirectory } from "@/lib/runtime/config"; import { listMediaFiles } from "@/lib/admin/media"; import { contentFileNames, defaultContent } from "./defaults"; import type { ContentBackup, ContentDocuments, ContentKey, ContentSummary } from "./types"; export const contentDirectory = path.join(storageDirectory, "content"); export const contentBackupDirectory = path.join(contentDirectory, "backups"); export const contentVersion = "0.4.1"; const contentKeys = Object.keys(contentFileNames) as ContentKey[]; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function sanitizeString(value: string) { return value .replace(/[\s\S]*?<\/script>/gi, "") .replace(/javascript:/gi, "") .replace(/[<>]/g, "") .trim(); } function sanitizeValue(value: unknown): unknown { if (typeof value === "string") return sanitizeString(value); if (Array.isArray(value)) return value.map(sanitizeValue); if (!isRecord(value)) return value; return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeValue(entry)])); } function mergeContent(defaults: T, incoming: unknown): T { if (Array.isArray(defaults)) return Array.isArray(incoming) ? sanitizeValue(incoming) as T : defaults; if (!isRecord(defaults)) return sanitizeValue(incoming ?? defaults) as T; if (!isRecord(incoming)) return defaults; const merged: Record = { ...defaults }; for (const [key, value] of Object.entries(defaults)) { merged[key] = mergeContent(value, incoming[key]); } return sanitizeValue(merged) as T; } function filePathFor(key: ContentKey) { return path.join(contentDirectory, contentFileNames[key]); } function backupStamp() { const date = new Date(); const pad = (value: number) => String(value).padStart(2, "0"); return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; } async function pruneBackups(key: ContentKey) { const prefix = `${key}.`; const entries = await readdir(contentBackupDirectory).catch(() => []); const backups = await Promise.all(entries .filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json")) .map(async (entry) => { const fullPath = path.join(contentBackupDirectory, entry); const fileStat = await stat(fullPath); return { entry, fullPath, modified: fileStat.mtimeMs }; })); const obsolete = backups.sort((left, right) => right.modified - left.modified).slice(20); await Promise.all(obsolete.map((entry) => unlink(entry.fullPath).catch(() => undefined))); } async function ensureContentDocument(key: K) { await mkdir(contentDirectory, { recursive: true }); await mkdir(contentBackupDirectory, { recursive: true }); const target = filePathFor(key); if (!existsSync(target)) { await writeFile(target, `${JSON.stringify(defaultContent[key], null, 2)}\n`, "utf8"); } } export async function ensureContentStorage() { await mkdir(contentDirectory, { recursive: true }); await mkdir(contentBackupDirectory, { recursive: true }); await Promise.all(contentKeys.map((key) => ensureContentDocument(key))); } export async function getContent(key: K): Promise { await ensureContentDocument(key); try { const raw = await readFile(filePathFor(key), "utf8"); const parsed = JSON.parse(raw) as unknown; return mergeContent(defaultContent[key], parsed); } catch { return defaultContent[key]; } } export async function getAllContent(): Promise { const entries = await Promise.all(contentKeys.map(async (key) => [key, await getContent(key)] as const)); return Object.fromEntries(entries) as ContentDocuments; } export async function saveContent(key: K, content: ContentDocuments[K]) { await ensureContentDocument(key); const current = filePathFor(key); const backup = path.join(contentBackupDirectory, `${key}.${backupStamp()}.json`); if (existsSync(current)) await rename(current, backup); const next = mergeContent(defaultContent[key], { ...content, updatedAt: new Date().toISOString(), }); await writeFile(current, `${JSON.stringify(next, null, 2)}\n`, "utf8"); await pruneBackups(key); return next; } export async function getContentSummary(): Promise { await ensureContentStorage(); const documents = await Promise.all(contentKeys.map(async (key) => { const content = await getContent(key); return { key, fileName: contentFileNames[key], title: "title" in content ? content.title : content.siteName, updatedAt: content.updatedAt, }; })); const lastModified = documents .map((document) => document.updatedAt) .filter(Boolean) .sort() .at(-1) ?? null; return { version: contentVersion, lastModified, pageCount: documents.length, documents, }; } export async function listContentBackups(limit = 20): Promise { await ensureContentStorage(); const entries = await readdir(contentBackupDirectory).catch(() => []); const backups = await Promise.all(entries .filter((entry) => entry.endsWith(".json")) .map(async (entry) => { const page = entry.split(".")[0] as ContentKey; const fullPath = path.join(contentBackupDirectory, entry); const fileStat = await stat(fullPath); return { page, fileName: entry, createdAt: fileStat.mtime.toISOString(), }; })); return backups .filter((backup) => contentKeys.includes(backup.page)) .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) .slice(0, limit); } export async function getContentSystemStatus() { const [summary, media, backups] = await Promise.all([getContentSummary(), listMediaFiles(), listContentBackups()]); return { ...summary, mediaCount: media.length, backups, }; }