176 lines
6.1 KiB
TypeScript
176 lines
6.1 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 { 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<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())}${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<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 listContentBackups(limit = 20): Promise<ContentBackup[]> {
|
|
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,
|
|
};
|
|
}
|