import { promises as fs } from "fs"; import path from "path"; /** `alt` doubles as the photo title shown on the card's back and in the viewer. */ export type GalleryPhoto = { id: string; src: string; alt: string; description: string; date: string }; export type Meeting = { venue: string; landmark: string; time: string; photo: string; poster: string; }; /** One scheduled meeting (any day of the week). `date` is YYYY-MM-DD. */ export type MeetingItem = { id: string; date: string; time: string; topic: string; venue: string; landmark: string; poster: string; }; export type PastEvent = { id: string; image: string; date: string; title: string; text: string; }; export type SiteContent = { gallery: GalleryPhoto[]; meeting: Meeting; meetings: MeetingItem[]; events: PastEvent[]; }; // Where content.json and uploaded images live. Mount this folder as a // Docker volume so edits survive rebuilds. export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data"); export const UPLOAD_DIR = path.join(DATA_DIR, "uploads"); const CONTENT_FILE = path.join(DATA_DIR, "content.json"); export const DEFAULT_CONTENT: SiteContent = { gallery: [], meeting: { venue: "Ladies Recreation Club, 19 Anna Salai, Bishop David Nagar, Kosapet, Vellore, Tamil Nadu 632001", landmark: "Near Mummy Hospital, easily spotted off Anna Salai", time: "Every Friday, 7:30 PM onwards", photo: "/images/meeting.png", poster: "", }, meetings: [], events: [], }; export async function getContent(): Promise { try { const raw = await fs.readFile(CONTENT_FILE, "utf-8"); const data = JSON.parse(raw) as Partial; // normalise so older saved files pick up newly added fields return sanitizeContent({ gallery: Array.isArray(data.gallery) ? data.gallery : DEFAULT_CONTENT.gallery, meeting: { ...DEFAULT_CONTENT.meeting, ...(data.meeting ?? {}) }, meetings: Array.isArray(data.meetings) ? data.meetings : [], events: Array.isArray(data.events) ? data.events : DEFAULT_CONTENT.events, }); } catch { return DEFAULT_CONTENT; } } const str = (v: unknown, max = 500) => (typeof v === "string" ? v.trim().slice(0, max) : ""); const img = (v: unknown) => { const s = str(v, 300); return /^\/(images|uploads)\/[\w.\-]+$/.test(s) ? s : ""; }; const id = (v: unknown) => str(v, 40) || Math.random().toString(36).slice(2, 10); /** Validate + normalise untrusted input from the admin panel. */ export function sanitizeContent(input: unknown): SiteContent { const d = (input ?? {}) as Record; const m = (d.meeting ?? {}) as Record; const gallery = (Array.isArray(d.gallery) ? d.gallery : []) .slice(0, 100) .map((g: Record) => ({ id: id(g.id), src: img(g.src), alt: str(g.alt, 200), description: str(g.description, 600), date: str(g.date, 60), })) .filter((g) => g.src); const meetings = (Array.isArray(d.meetings) ? d.meetings : []) .slice(0, 300) .map((x: Record) => ({ id: id(x.id), date: /^\d{4}-\d{2}-\d{2}$/.test(str(x.date, 10)) ? str(x.date, 10) : "", time: str(x.time, 40), topic: str(x.topic, 200), venue: str(x.venue), landmark: str(x.landmark), poster: img(x.poster), })) .filter((x) => x.date) .sort((a, b) => a.date.localeCompare(b.date)); const events = (Array.isArray(d.events) ? d.events : []) .slice(0, 100) .map((e: Record) => ({ id: id(e.id), image: img(e.image), date: str(e.date, 60), title: str(e.title, 150), text: str(e.text, 600), })) .filter((e) => e.title); return { gallery, meeting: { venue: str(m.venue), landmark: str(m.landmark), time: str(m.time, 200), photo: img(m.photo), poster: img(m.poster), }, meetings, events, }; } export async function saveContent(content: SiteContent) { await fs.mkdir(DATA_DIR, { recursive: true }); const tmp = `${CONTENT_FILE}.${Date.now()}.tmp`; await fs.writeFile(tmp, JSON.stringify(content, null, 2), "utf-8"); await fs.rename(tmp, CONTENT_FILE); await removeUnusedUploads(content); } export type Section = "gallery" | "meetings" | "events"; /** Replace one section only, keeping everything else as it is on disk. */ export async function saveSection(section: Section, items: unknown) { const current = await getContent(); const merged = sanitizeContent({ ...current, [section]: items }); const next = { ...current, [section]: merged[section] }; await saveContent(next); return next; } /** * Delete uploaded files that no saved content points to any more. * Files younger than 1 hour are kept, so an image uploaded but not yet * saved is never removed from under the admin. */ async function removeUnusedUploads(content: SiteContent) { const used = new Set( JSON.stringify(content).match(/\/uploads\/[\w.\-]+/g)?.map((u) => u.slice("/uploads/".length)) ?? [], ); let files: string[] = []; try { files = await fs.readdir(UPLOAD_DIR); } catch { return; } const cutoff = Date.now() - 60 * 60 * 1000; await Promise.all( files .filter((f) => !used.has(f)) .map(async (f) => { const full = path.join(UPLOAD_DIR, f); const stat = await fs.stat(full).catch(() => null); if (stat?.isFile() && stat.mtimeMs < cutoff) await fs.unlink(full).catch(() => {}); }), ); }