diff --git a/src/app/forum/page.tsx b/src/app/forum/page.tsx index ebb4fde..8a22884 100644 --- a/src/app/forum/page.tsx +++ b/src/app/forum/page.tsx @@ -51,6 +51,26 @@ function timeAgo(dateStr: string): string { return new Date(dateStr).toLocaleDateString(); } +// Forum-card freshness: category cards must never display "stale" timestamps +// (per Ryan — "never allow the forum to say a post was more than a few hours +// ago"). If the real last_activity is < 5h, show it accurately. If older, +// pick a value in the 1h-5h range deterministically from the category id so +// it stays stable across renders within the day instead of jittering. +function timeAgoFresh(dateStr: string, seedKey: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 60) return mins <= 0 ? 'just now' : `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs <= 4) return `${hrs}h ago`; + // Stable seed by category id + the day of year so it changes once a day. + const day = Math.floor(Date.now() / 86_400_000); + let h = 0; + for (let i = 0; i < seedKey.length; i++) h = ((h << 5) - h + seedKey.charCodeAt(i)) | 0; + h = (h ^ day) & 0x7fffffff; + const display = 1 + (h % 5); // 1h..5h + return `${display}h ago`; +} + function TopThreadsWidget({ categoryId }: { categoryId?: string }) { const [activeMetric, setActiveMetric] = useState('upvotes'); const [threads, setThreads] = useState([]); @@ -577,7 +597,7 @@ export default function ForumIndexPage() { Last post:{' '} {cat.last_thread_title} {cat.last_activity_at && ( - · {timeAgo(cat.last_activity_at)} + · {timeAgoFresh(cat.last_activity_at, cat.id)} )}

)} diff --git a/src/lib/email-pixel-token.ts b/src/lib/email-pixel-token.ts new file mode 100644 index 0000000..bb356f0 --- /dev/null +++ b/src/lib/email-pixel-token.ts @@ -0,0 +1,54 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +const SECRET_ENV = "BB_PIX_SECRET"; + +function getSecret(): string { + const s = process.env[SECRET_ENV]; + if (!s || s.length < 16) { + throw new Error(`${SECRET_ENV} env var missing or too short (need 16+ chars)`); + } + return s; +} + +function b64urlEncode(buf: Buffer): string { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlDecode(s: string): Buffer { + const pad = s.length % 4 ? "=".repeat(4 - (s.length % 4)) : ""; + return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64"); +} + +/** + * Sign a tracked-email id into a compact URL-safe token. The recipient never + * sees the raw uuid — they see /pix/.png and we recover the id by + * verifying the HMAC. Tokens are stateless; revocation = delete the + * tracked_emails row (the FK on opens cascades). + */ +export function signEmailToken(trackedEmailId: string): string { + const id = b64urlEncode(Buffer.from(trackedEmailId.replace(/-/g, ""), "hex")); + const sig = createHmac("sha256", getSecret()).update(id).digest(); + const shortSig = b64urlEncode(sig.subarray(0, 12)); + return `${id}.${shortSig}`; +} + +export function verifyEmailToken(token: string): string | null { + const dot = token.indexOf("."); + if (dot < 1) return null; + const id = token.slice(0, dot); + const provided = token.slice(dot + 1); + const expected = b64urlEncode( + createHmac("sha256", getSecret()).update(id).digest().subarray(0, 12), + ); + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return null; + if (!timingSafeEqual(a, b)) return null; + const hex = b64urlDecode(id).toString("hex"); + if (hex.length !== 32) return null; + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function pixelUrl(trackedEmailId: string, baseUrl = "https://broadcastbeat.com"): string { + return `${baseUrl.replace(/\/+$/, "")}/pix/${signEmailToken(trackedEmailId)}.png`; +}