181 lines
5.4 KiB
TypeScript
181 lines
5.4 KiB
TypeScript
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<SiteContent> {
|
|
try {
|
|
const raw = await fs.readFile(CONTENT_FILE, "utf-8");
|
|
const data = JSON.parse(raw) as Partial<SiteContent>;
|
|
// 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<string, unknown>;
|
|
const m = (d.meeting ?? {}) as Record<string, unknown>;
|
|
const gallery = (Array.isArray(d.gallery) ? d.gallery : [])
|
|
.slice(0, 100)
|
|
.map((g: Record<string, unknown>) => ({
|
|
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<string, unknown>) => ({
|
|
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<string, unknown>) => ({
|
|
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(() => {});
|
|
}),
|
|
);
|
|
}
|
|
|