Admin and Gallery and event change

This commit is contained in:
2026-09-24 12:29:46 +05:30
parent a91b957579
commit aea69de410
27 changed files with 1815 additions and 126 deletions
+41
View File
@@ -0,0 +1,41 @@
import crypto from "crypto";
import { cookies } from "next/headers";
export const SESSION_COOKIE = "rcv_admin";
const SESSION_HOURS = 12;
const secret = () =>
process.env.ADMIN_SECRET || `${process.env.ADMIN_PASSWORD || "dev"}-rcv-session`;
const sign = (value: string) =>
crypto.createHmac("sha256", secret()).update(value).digest("hex");
const safeEqual = (a: string, b: string) => {
const ha = crypto.createHash("sha256").update(a).digest();
const hb = crypto.createHash("sha256").update(b).digest();
return crypto.timingSafeEqual(ha, hb);
};
export function checkCredentials(username: string, password: string) {
const u = process.env.ADMIN_USERNAME;
const p = process.env.ADMIN_PASSWORD;
if (!u || !p) return false; // admin disabled until credentials are configured
return safeEqual(username, u) && safeEqual(password, p);
}
export function createSessionToken() {
const expires = Date.now() + SESSION_HOURS * 3600 * 1000;
return { token: `${expires}.${sign(String(expires))}`, maxAge: SESSION_HOURS * 3600 };
}
export function verifyToken(token: string | undefined) {
if (!token) return false;
const [exp, sig] = token.split(".");
if (!exp || !sig || Number(exp) < Date.now()) return false;
return safeEqual(sig, sign(exp));
}
export async function isAdmin() {
const store = await cookies();
return verifyToken(store.get(SESSION_COOKIE)?.value);
}
+180
View File
@@ -0,0 +1,180 @@
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(() => {});
}),
);
}
+24
View File
@@ -0,0 +1,24 @@
// Date helpers shared by the site and the admin panel (no server-only imports).
import type { MeetingItem } from "./content";
/** Today's date in India as YYYY-MM-DD. */
export function todayIST() {
return new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Kolkata" });
}
/** The next meeting on or after today, if any. */
export function getNextMeeting(list: MeetingItem[]): MeetingItem | undefined {
const today = todayIST();
return [...list].filter((m) => m.date >= today).sort((a, b) => a.date.localeCompare(b.date))[0];
}
export function formatMeetingDate(date: string) {
const [y, m, d] = date.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-IN", {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
timeZone: "UTC",
});
}