import { mkdir, open, readdir, stat } from "node:fs/promises"; import path from "node:path"; export type AnnotationPageInfo = { name: string; title: string; size: number; modifiedAt: string; href: string; }; const annotationPagesRoot = path.resolve( process.env.ANNOTATION_PAGES_DIR?.trim() || "/home/mi/annotation-pages", ); export async function listAnnotationPages(): Promise { await mkdir(annotationPagesRoot, { recursive: true }); const entries = await readdir(annotationPagesRoot, { withFileTypes: true }); const pages = await Promise.all( entries .filter((entry) => entry.isFile() && isSafeHtmlName(entry.name)) .map(async (entry) => { const filePath = path.join(annotationPagesRoot, entry.name); const [fileStat, title] = await Promise.all([ stat(filePath), readHtmlTitle(filePath, entry.name), ]); return { name: entry.name, title, size: fileStat.size, modifiedAt: fileStat.mtime.toISOString(), href: `/annotations/${encodeURIComponent(entry.name)}`, }; }), ); return pages.sort( (left, right) => Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt) || left.name.localeCompare(right.name, "zh-CN"), ); } export function resolveAnnotationPage(name: string): string | null { if (!isSafeHtmlName(name)) return null; const resolved = path.resolve(annotationPagesRoot, name); if (path.dirname(resolved) !== annotationPagesRoot) return null; return resolved; } function isSafeHtmlName(name: string): boolean { return ( name === path.basename(name) && !name.startsWith(".") && /\.html?$/i.test(name) ); } async function readHtmlTitle(filePath: string, fileName: string) { const handle = await open(filePath, "r"); try { const buffer = Buffer.alloc(16 * 1024); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); const head = buffer.subarray(0, bytesRead).toString("utf8"); const match = head.match(/]*>([\s\S]*?)<\/title>/i); const title = match?.[1] ?.replace(/<[^>]+>/g, " ") .replace(/\s+/g, " ") .trim(); return title ? decodeHtmlEntities(title) : stripHtmlExtension(fileName); } finally { await handle.close(); } } function stripHtmlExtension(fileName: string) { return fileName.replace(/\.html?$/i, ""); } function decodeHtmlEntities(value: string) { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll(""", '"') .replaceAll("'", "'"); }