funktechnik-schubert-website/lib/content/service.ts

153 lines
5.3 KiB
TypeScript

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 { ContentDocuments, ContentKey, ContentSummary } from "./types";
export const contentDirectory = path.join(storageDirectory, "content");
export const contentBackupDirectory = path.join(contentDirectory, "backups");
export const contentVersion = "0.4.0";
const contentKeys = Object.keys(contentFileNames) as ContentKey[];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function sanitizeString(value: string) {
return value
.replace(/<script[\s\S]*?>[\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<T>(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<string, unknown> = { ...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())}`;
}
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<K extends ContentKey>(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<K extends ContentKey>(key: K): Promise<ContentDocuments[K]> {
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<ContentDocuments> {
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<K extends ContentKey>(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<ContentSummary> {
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 getContentSystemStatus() {
const [summary, media] = await Promise.all([getContentSummary(), listMediaFiles()]);
return {
...summary,
mediaCount: media.length,
};
}