Merge pull request 'dev' (#2) from dev into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-07-18 17:17:21 +00:00
26 changed files with 792 additions and 113 deletions
+3
View File
@@ -3,6 +3,9 @@ node_modules/
# Next.js # Next.js
frontend/.next/ frontend/.next/
frontend/.next-dev/
frontend/.next-build/
frontend/.next-prev/
frontend/out/ frontend/out/
# Backend build # Backend build
+6 -1
View File
@@ -91,7 +91,12 @@ router.get('/ics', async (_req: Request, res: Response) => {
const cutoff = sevenDaysAgo.toISOString().slice(0, 10); const cutoff = sevenDaysAgo.toISOString().slice(0, 10);
const meetups = await prisma.meetup.findMany({ const meetups = await prisma.meetup.findMany({
where: { date: { gte: cutoff } }, // Public subscription feed — never expose HIDDEN or unpublished meetups.
where: {
date: { gte: cutoff },
visibility: 'PUBLIC',
status: 'PUBLISHED',
},
orderBy: { date: 'asc' }, orderBy: { date: 'asc' },
include: { organizer: true }, include: { organizer: true },
}); });
+41 -24
View File
@@ -18,12 +18,23 @@ function ensureStorageDir() {
fs.mkdirSync(STORAGE_PATH, { recursive: true }); fs.mkdirSync(STORAGE_PATH, { recursive: true });
} }
// Stream uploads to disk — buffering up to 100MB in RAM OOMs this small VPS.
// Filename is a ULID so the original client name never touches the filesystem.
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.diskStorage({
destination: (_req, _file, cb) => {
ensureStorageDir();
cb(null, STORAGE_PATH);
},
filename: (_req, _file, cb) => {
cb(null, ulid());
},
}),
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
}); });
const IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; // SVG deliberately excluded: served as image/svg+xml it is executable markup (stored XSS).
const IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const VIDEO_MIMES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime']; const VIDEO_MIMES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
const ALLOWED_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES]; const ALLOWED_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES];
@@ -54,24 +65,23 @@ router.post(
} }
if (!ALLOWED_MIMES.includes(file.mimetype)) { if (!ALLOWED_MIMES.includes(file.mimetype)) {
// Disk storage already wrote the rejected blob — remove it.
if (file.path) fs.unlink(file.path, () => {});
res.status(400).json({ error: `Unsupported file type: ${file.mimetype}` }); res.status(400).json({ error: `Unsupported file type: ${file.mimetype}` });
return; return;
} }
const mediaType = getMediaType(file.mimetype); const mediaType = getMediaType(file.mimetype);
if (!mediaType) { if (!mediaType) {
if (file.path) fs.unlink(file.path, () => {});
res.status(400).json({ error: 'Could not determine media type' }); res.status(400).json({ error: 'Could not determine media type' });
return; return;
} }
const id = ulid(); // multer.diskStorage already wrote the blob under a ULID filename.
const id = file.filename;
const slug = makeSlug(file.originalname); const slug = makeSlug(file.originalname);
ensureStorageDir();
const filePath = path.join(STORAGE_PATH, id);
fs.writeFileSync(filePath, file.buffer);
const metaPath = path.join(STORAGE_PATH, `${id}.json`); const metaPath = path.join(STORAGE_PATH, `${id}.json`);
fs.writeFileSync(metaPath, JSON.stringify({ fs.writeFileSync(metaPath, JSON.stringify({
mimeType: file.mimetype, mimeType: file.mimetype,
@@ -79,23 +89,30 @@ router.post(
size: file.size, size: file.size,
})); }));
const media = await prisma.media.create({ try {
data: { const media = await prisma.media.create({
id, data: {
slug, id,
type: mediaType, slug,
mimeType: file.mimetype, type: mediaType,
size: file.size, mimeType: file.mimetype,
originalFilename: file.originalname, size: file.size,
uploadedBy: req.user!.pubkey, originalFilename: file.originalname,
}, uploadedBy: req.user!.pubkey,
}); },
});
res.status(201).json({ res.status(201).json({
id: media.id, id: media.id,
slug: media.slug, slug: media.slug,
url: `/media/${media.id}`, url: `/media/${media.id}`,
}); });
} catch (dbErr) {
// Roll back the on-disk blob if the DB insert fails.
if (file.path) fs.unlink(file.path, () => {});
if (fs.existsSync(metaPath)) fs.unlink(metaPath, () => {});
throw dbErr;
}
} catch (err) { } catch (err) {
console.error('Upload media error:', err); console.error('Upload media error:', err);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
+46 -1
View File
@@ -1,13 +1,47 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../db/prisma'; import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth'; import { requireAuth, requires } from '../middleware/auth';
import { authService, type ResolvedAccess } from '../services/auth';
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
import { DEFAULT_ORGANIZER_SLUG } from '../constants/organizer'; import { DEFAULT_ORGANIZER_SLUG } from '../constants/organizer';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint'; import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const router = Router(); const router = Router();
const meetupInclude = { organizer: true } as const; const meetupInclude = { organizer: true } as const;
// Resolves access for an optional Bearer token (JWT or API key) without
// rejecting unauthenticated callers, so public listings stay open while staff
// can still see HIDDEN/DRAFT meetups. Mirrors the pattern in api/posts.ts.
async function resolveOptionalAccess(req: Request): Promise<ResolvedAccess | null> {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) return null;
const token = header.slice(7);
try {
if (looksLikeApiKey(token)) {
return await resolveApiKey(token);
}
const payload = jwt.verify(token, JWT_SECRET) as { pubkey: string };
return await authService.resolveAccess(payload.pubkey);
} catch {
return null;
}
}
// True when the requester may see non-public meetups (HIDDEN / drafts).
async function canViewHiddenMeetups(req: Request): Promise<boolean> {
const access = await resolveOptionalAccess(req);
return (
!!access &&
(access.isSuperAdmin ||
access.permissions.has('events.edit') ||
access.permissions.has('events.create'))
);
}
function incrementTitle(title: string): string { function incrementTitle(title: string): string {
const match = title.match(/^(.*#)(\d+)(.*)$/); const match = title.match(/^(.*#)(\d+)(.*)$/);
if (match) { if (match) {
@@ -28,7 +62,8 @@ async function resolveOrganizerIdForCreate(organizerId: unknown): Promise<string
router.get('/', async (req: Request, res: Response) => { router.get('/', async (req: Request, res: Response) => {
try { try {
const status = req.query.status as string | undefined; const status = req.query.status as string | undefined;
const admin = req.query.admin === 'true'; // `admin=true` reveals HIDDEN meetups, so honor it only for authorized staff.
const admin = req.query.admin === 'true' && (await canViewHiddenMeetups(req));
const organizerSlug = req.query.organizerSlug as string | undefined; const organizerSlug = req.query.organizerSlug as string | undefined;
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (status) where.status = status; if (status) where.status = status;
@@ -69,6 +104,16 @@ router.get('/:id', async (req: Request, res: Response) => {
return; return;
} }
// Don't leak HIDDEN / unpublished meetups to the public; treat as not-found.
const staff = await canViewHiddenMeetups(req);
if (
!staff &&
(meetup.visibility !== 'PUBLIC' || meetup.status !== 'PUBLISHED')
) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
res.json(meetup); res.json(meetup);
} catch (err) { } catch (err) {
console.error('Get meetup error:', err); console.error('Get meetup error:', err);
+18
View File
@@ -32,6 +32,24 @@ const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10); const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
// Refuse to boot in production with a missing/weak JWT secret — otherwise anyone
// could forge admin JWTs against the shipped `change-me-in-production` default.
if (process.env.NODE_ENV === 'production') {
const secret = process.env.JWT_SECRET;
if (!secret || secret === 'change-me-in-production' || secret.length < 32) {
console.error(
'FATAL: JWT_SECRET must be set to a strong value (>= 32 chars) in production. Refusing to start.'
);
process.exit(1);
}
}
// A rejected promise that escapes a handler should be logged, not silently crash
// (or, on older Express, hang) the process.
process.on('unhandledRejection', (reason) => {
console.error('Unhandled promise rejection:', reason);
});
// Trust the first proxy (nginx) so req.ip returns the real client IP // Trust the first proxy (nginx) so req.ip returns the real client IP
app.set('trust proxy', 1); app.set('trust proxy', 1);
+15 -9
View File
@@ -30,17 +30,23 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
const token = header.slice(7); const token = header.slice(7);
// API keys authenticate programmatic clients. They carry their own scoped // API keys authenticate programmatic clients. They carry their own scoped
// permission set rather than a role, so we pre-resolve access here. // permission set rather than a role, so we pre-resolve access here. Wrapped in
// try/catch because Express 4 does not catch rejections from async middleware.
if (looksLikeApiKey(token)) { if (looksLikeApiKey(token)) {
const access = await resolveApiKey(token); try {
if (!access) { const access = await resolveApiKey(token);
res.status(401).json({ error: 'Invalid or revoked API key' }); if (!access) {
return; res.status(401).json({ error: 'Invalid or revoked API key' });
return;
}
req.user = { pubkey: access.pubkey, role: 'apikey' };
req.access = access;
req.isApiKey = true;
next();
} catch (err) {
console.error('API key resolution error:', err);
res.status(500).json({ error: 'Internal server error' });
} }
req.user = { pubkey: access.pubkey, role: 'apikey' };
req.access = access;
req.isApiKey = true;
next();
return; return;
} }
+34 -9
View File
@@ -6,13 +6,38 @@ export async function GET(req: NextRequest) {
const upstream = new URL(apiUrl('/nip05')); const upstream = new URL(apiUrl('/nip05'));
if (name) upstream.searchParams.set('name', name); if (name) upstream.searchParams.set('name', name);
const res = await fetch(upstream.toString(), { cache: 'no-store' }); try {
const data = await res.json(); const res = await fetch(upstream.toString(), { cache: 'no-store' });
if (!res.ok) {
return NextResponse.json(data, { return NextResponse.json(
headers: { {},
'Access-Control-Allow-Origin': '*', {
'Cache-Control': 'no-store', status: 502,
}, headers: {
}); 'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
}
);
}
const data = await res.json();
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
});
} catch (err) {
console.error('NIP-05 proxy error:', err);
return NextResponse.json(
{},
{
status: 502,
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
}
);
}
} }
+28 -7
View File
@@ -12,7 +12,7 @@ import {
getPublicKey, getPublicKey,
signEvent, signEvent,
publishEvent, publishEvent,
shortenPubkey, shortenNpub,
fetchNostrProfile, fetchNostrProfile,
fetchLongformFromRelays, fetchLongformFromRelays,
fetchEventFromRelays, fetchEventFromRelays,
@@ -23,6 +23,7 @@ import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer"; import { Footer } from "@/components/public/Footer";
import { markdownComponents } from "./markdownComponents"; import { markdownComponents } from "./markdownComponents";
import { remarkNostr } from "./remarkNostr"; import { remarkNostr } from "./remarkNostr";
import { NostrAuthor } from "./NostrEmbeds";
interface Post { interface Post {
id: string; id: string;
@@ -30,6 +31,7 @@ interface Post {
title: string; title: string;
content: string; content: string;
excerpt?: string; excerpt?: string;
image?: string;
authorName?: string; authorName?: string;
authorPubkey?: string; authorPubkey?: string;
publishedAt?: string; publishedAt?: string;
@@ -59,6 +61,7 @@ function postFromEvent(event: any, slug: string): Post {
title: tag("title") || "Untitled", title: tag("title") || "Untitled",
content: event.content || "", content: event.content || "",
excerpt: tag("summary"), excerpt: tag("summary"),
image: tag("image"),
authorPubkey: event.pubkey, authorPubkey: event.pubkey,
publishedAt: new Date(publishedAtSec * 1000).toISOString(), publishedAt: new Date(publishedAtSec * 1000).toISOString(),
nostrEventId: event.id, nostrEventId: event.id,
@@ -117,6 +120,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null); const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
const [liveContent, setLiveContent] = useState<string | null>(null); const [liveContent, setLiveContent] = useState<string | null>(null);
const [liveImage, setLiveImage] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(false); const [loadingContent, setLoadingContent] = useState(false);
const [resolvingFromRelays, setResolvingFromRelays] = useState(false); const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
const [contentError, setContentError] = useState(false); const [contentError, setContentError] = useState(false);
@@ -134,6 +138,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
setLoading(true); setLoading(true);
setError(null); setError(null);
setLiveContent(null); setLiveContent(null);
setLiveImage(null);
setPost(null); setPost(null);
setAuthorProfile(null); setAuthorProfile(null);
setContentError(false); setContentError(false);
@@ -159,8 +164,14 @@ export default function BlogPostClient({ slug }: { slug: string }) {
fetchPromise fetchPromise
.then((event) => { .then((event) => {
if (cancelled) return; if (cancelled) return;
if (event?.content) setLiveContent(event.content); if (event?.content) {
else setContentError(true); // not found / timed out setLiveContent(event.content);
// Pull the longform header image (`image` tag) for display.
const img = event.tags?.find((t: string[]) => t[0] === "image")?.[1];
if (img) setLiveImage(img);
} else {
setContentError(true); // not found / timed out
}
}) })
.catch(() => !cancelled && setContentError(true)) .catch(() => !cancelled && setContentError(true))
.finally(() => !cancelled && setLoadingContent(false)); .finally(() => !cancelled && setLoadingContent(false));
@@ -258,6 +269,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
}, [comment, post, hasNostr]); }, [comment, post, hasNostr]);
const categories = post?.categories?.map((c) => c.category) || []; const categories = post?.categories?.map((c) => c.category) || [];
const headerImage = post?.image || liveImage;
return ( return (
<> <>
@@ -321,7 +333,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
/> />
)} )}
<span className="font-medium text-on-surface-variant"> <span className="font-medium text-on-surface-variant">
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)} {authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)}
</span> </span>
</div> </div>
)} )}
@@ -338,6 +350,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
</div> </div>
</header> </header>
{headerImage && (
<img
src={headerImage}
alt={post.title}
className="w-full rounded-xl mb-12 object-cover max-h-[28rem]"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<article className="mb-16"> <article className="mb-16">
{loadingContent ? ( {loadingContent ? (
<RelayLoading /> <RelayLoading />
@@ -424,9 +447,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
className="bg-surface-container-high rounded-lg p-4" className="bg-surface-container-high rounded-lg p-4"
> >
<div className="flex items-center gap-2.5 mb-2"> <div className="flex items-center gap-2.5 mb-2">
<span className="font-semibold text-xs font-mono text-on-surface-variant/70"> <NostrAuthor pubkey={r.pubkey} />
{shortenPubkey(r.pubkey)}
</span>
<span className="text-on-surface-variant/30">·</span> <span className="text-on-surface-variant/30">·</span>
<span className="text-xs text-on-surface-variant/50"> <span className="text-xs text-on-surface-variant/50">
{formatDate(new Date(r.created_at * 1000))} {formatDate(new Date(r.created_at * 1000))}
+11 -19
View File
@@ -5,7 +5,7 @@ import { nip19 } from "nostr-tools";
import { import {
loadNostrProfile, loadNostrProfile,
resolveEventFromRelays, resolveEventFromRelays,
shortenPubkey, shortenNpub,
type NostrProfile, type NostrProfile,
} from "@/lib/nostr"; } from "@/lib/nostr";
@@ -39,11 +39,8 @@ export function NostrMention({ bech32 }: { bech32: string }) {
}, [pubkey]); }, [pubkey]);
// Show the username; fall back to a shortened npub only when no profile. // Show the username; fall back to a shortened npub only when no profile.
const npub = useMemo(() => { const name =
if (bech32.startsWith("npub")) return bech32; profile?.name || profile?.displayName || shortenNpub(pubkey || bech32);
return pubkey ? nip19.npubEncode(pubkey) : bech32;
}, [bech32, pubkey]);
const name = profile?.name || profile?.displayName || shortenPubkey(npub);
return ( return (
<a <a
@@ -67,8 +64,10 @@ export function NostrMention({ bech32 }: { bech32: string }) {
); );
} }
// Compact author line used inside an embedded note. // Reusable author line (avatar + username) resolved from the author's kind:0
function NoteAuthor({ pubkey }: { pubkey: string }) { // profile. Falls back to a shortened npub — never the raw hex pubkey. Shared by
// embedded notes and the blog comment list.
export function NostrAuthor({ pubkey }: { pubkey: string }) {
const [profile, setProfile] = useState<NostrProfile | null>(null); const [profile, setProfile] = useState<NostrProfile | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -79,21 +78,14 @@ function NoteAuthor({ pubkey }: { pubkey: string }) {
cancelled = true; cancelled = true;
}; };
}, [pubkey]); }, [pubkey]);
const fallback = useMemo(() => { const name = profile?.name || profile?.displayName || shortenNpub(pubkey);
try {
return shortenPubkey(nip19.npubEncode(pubkey));
} catch {
return shortenPubkey(pubkey);
}
}, [pubkey]);
const name = profile?.name || profile?.displayName || fallback;
return ( return (
<span className="flex items-center gap-2"> <span className="inline-flex items-center gap-2">
{profile?.picture && ( {profile?.picture && (
<img <img
src={profile.picture} src={profile.picture}
alt="" alt=""
className="w-6 h-6 rounded-full object-cover bg-surface-container-high" className="w-6 h-6 rounded-full object-cover bg-surface-container-high shrink-0"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).style.display = "none"; (e.target as HTMLImageElement).style.display = "none";
}} }}
@@ -151,7 +143,7 @@ export function NostrNote({ bech32 }: { bech32: string }) {
{state === "done" && event && ( {state === "done" && event && (
<span className="block"> <span className="block">
<span className="flex items-center justify-between mb-3"> <span className="flex items-center justify-between mb-3">
<NoteAuthor pubkey={event.pubkey} /> <NostrAuthor pubkey={event.pubkey} />
<a <a
href={njump(bech32)} href={njump(bech32)}
target="_blank" target="_blank"
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
// Route-segment error boundary. Catches runtime errors thrown while rendering any
// page/segment (e.g. a request-time fetch or a bad build artifact) and shows a
// recoverable UI with a retry, instead of a bare 500.
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Route error boundary caught:", error);
}, [error]);
return (
<div className="min-h-screen flex flex-col items-center justify-center px-8">
<span className="text-7xl md:text-9xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-primary to-primary-container leading-none">
Oops
</span>
<h1 className="text-2xl md:text-3xl font-bold mt-6 mb-3">
Something went wrong
</h1>
<p className="text-on-surface-variant mb-10 text-center max-w-md">
This page hit an unexpected error. It&apos;s usually temporary try again
in a moment.
</p>
<div className="flex flex-wrap gap-3 justify-center">
<button
onClick={() => reset()}
className="bg-gradient-to-r from-primary to-primary-container text-on-primary px-8 py-3 rounded-lg font-bold hover:scale-105 transition-transform"
>
Try again
</button>
<Link
href="/"
className="border border-zinc-700 px-8 py-3 rounded-lg font-bold hover:bg-zinc-800/50 transition-colors"
>
Back to Home
</Link>
</div>
{error?.digest && (
<p className="text-on-surface-variant/50 text-xs mt-8 font-mono">
ref: {error.digest}
</p>
)}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect } from "react";
// Last-resort boundary: catches errors thrown in the root layout itself. It
// replaces the whole document, so it must render its own <html>/<body> and can't
// rely on the app's global CSS — hence inline styles.
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Global error boundary caught:", error);
}, [error]);
return (
<html lang="en">
<body
style={{
margin: 0,
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
padding: "2rem",
background: "#0a0a0a",
color: "#ededed",
fontFamily:
"ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
textAlign: "center",
}}
>
<h1 style={{ fontSize: "1.75rem", fontWeight: 800, margin: 0 }}>
Something went wrong
</h1>
<p style={{ color: "#a1a1aa", maxWidth: "28rem", margin: 0 }}>
The site hit an unexpected error. Please try again in a moment.
</p>
<button
onClick={() => reset()}
style={{
marginTop: "0.5rem",
padding: "0.75rem 2rem",
borderRadius: "0.5rem",
border: "none",
fontWeight: 700,
cursor: "pointer",
background: "#f7931a",
color: "#0a0a0a",
}}
>
Try again
</button>
{error?.digest && (
<p
style={{
color: "#71717a",
fontSize: "0.75rem",
fontFamily: "ui-monospace, monospace",
}}
>
ref: {error.digest}
</p>
)}
</body>
</html>
);
}
+61 -39
View File
@@ -180,50 +180,72 @@ function handleVideoStream(
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
// Media blobs are stored as ULIDs (Crockford base32, 26 chars). Reject anything
// else before joining into the storage path so `../` cannot escape the root.
const MEDIA_ID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
function resolveSafeMediaPath(root: string, id: string): string | null {
if (!MEDIA_ID_RE.test(id)) return null;
const resolvedRoot = path.resolve(root);
const filePath = path.resolve(resolvedRoot, id);
const rootPrefix = resolvedRoot.endsWith(path.sep)
? resolvedRoot
: resolvedRoot + path.sep;
if (filePath !== resolvedRoot && !filePath.startsWith(rootPrefix)) {
return null;
}
return filePath;
}
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
context: { params: Promise<{ id: string }> | { id: string } } context: { params: Promise<{ id: string }> | { id: string } }
) { ) {
const params = await Promise.resolve(context.params); try {
const id = params.id; const params = await Promise.resolve(context.params);
if (!id) { const id = params.id;
return NextResponse.json({ error: 'Not found' }, { status: 404 }); if (!id) {
} return NextResponse.json({ error: 'Not found' }, { status: 404 });
const root = getMediaStorageRoot();
const filePath = path.join(root, id);
if (!fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const meta = readMeta(root, id);
if (!meta) {
return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const widthParam = searchParams.get('w');
if (meta.type === 'image' && widthParam) {
const width = parseInt(widthParam, 10);
if (isNaN(width) || width < 1 || width > 4096) {
return NextResponse.json({ error: 'Invalid width' }, { status: 400 });
} }
return handleImageResize(root, filePath, width, meta, id);
}
if (meta.type === 'video') { const root = getMediaStorageRoot();
const rangeHeader = request.headers.get('range'); const filePath = resolveSafeMediaPath(root, id);
return handleVideoStream(filePath, meta, rangeHeader); if (!filePath || !fileExists(filePath)) {
} return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const buffer = fs.readFileSync(filePath); const meta = readMeta(root, id);
return new NextResponse(new Uint8Array(buffer), { if (!meta) {
status: 200, return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
headers: { }
'Content-Type': meta.mimeType,
'Content-Length': String(buffer.length), const { searchParams } = new URL(request.url);
...CACHE_HEADERS, const widthParam = searchParams.get('w');
},
}); if (meta.type === 'image' && widthParam) {
const width = parseInt(widthParam, 10);
if (isNaN(width) || width < 1 || width > 4096) {
return NextResponse.json({ error: 'Invalid width' }, { status: 400 });
}
return await handleImageResize(root, filePath, width, meta, id);
}
if (meta.type === 'video') {
const rangeHeader = request.headers.get('range');
return handleVideoStream(filePath, meta, rangeHeader);
}
const buffer = fs.readFileSync(filePath);
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(buffer.length),
...CACHE_HEADERS,
},
});
} catch (err) {
console.error('Media serve error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
} }
+4 -1
View File
@@ -3,10 +3,13 @@ interface JsonLdProps {
} }
export function JsonLd({ data }: JsonLdProps) { export function JsonLd({ data }: JsonLdProps) {
// Escape `<` so user-controlled titles/descriptions cannot break out of the
// <script> tag via `</script>` (JSON.stringify alone does not escape it).
const json = JSON.stringify(data).replace(/</g, "\\u003c");
return ( return (
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} dangerouslySetInnerHTML={{ __html: json }}
/> />
); );
} }
+39 -1
View File
@@ -1,8 +1,16 @@
"use client";
import { useState } from "react";
import { MapPin, Clock, ArrowRight } from "lucide-react"; import { MapPin, Clock, ArrowRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog"; import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
import { formatMeetupCivilDate } from "@/lib/meetupEventTime"; import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
// Initial number of meetups shown before "Load more". Mobile is enforced via
// CSS (3) so there's no SSR/viewport mismatch; desktop shows up to 6.
const INITIAL_MOBILE = 3;
const INITIAL_DESKTOP = 6;
interface MeetupData { interface MeetupData {
id?: string; id?: string;
title: string; title: string;
@@ -19,6 +27,26 @@ interface MeetupsSectionProps {
} }
export function MeetupsSection({ meetups }: MeetupsSectionProps) { export function MeetupsSection({ meetups }: MeetupsSectionProps) {
// Number of extra batches revealed via "Load more". Each batch adds 3 on
// mobile and 6 on desktop, so visible counts are 3*(pages+1) / 6*(pages+1).
const [pages, setPages] = useState(0);
const mobileVisible = INITIAL_MOBILE * (pages + 1);
const desktopVisible = INITIAL_DESKTOP * (pages + 1);
// Visibility per card. Enforced with CSS so the server-rendered markup matches
// both viewports (desktopVisible >= mobileVisible, so "mobile-only" never occurs).
const cardVisibilityClass = (i: number) => {
if (i < mobileVisible) return "flex";
if (i < desktopVisible) return "hidden md:flex";
return "hidden";
};
// Show the button only when content is still hidden at the given breakpoint.
let loadMoreClass = "hidden";
if (meetups.length > desktopVisible) loadMoreClass = "flex";
else if (meetups.length > mobileVisible) loadMoreClass = "flex md:hidden";
return ( return (
<section className="py-24 px-8 border-t border-zinc-800/50"> <section className="py-24 px-8 border-t border-zinc-800/50">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
@@ -59,7 +87,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
<Link <Link
key={meetup.id ?? i} key={meetup.id ?? i}
href={href} href={href}
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200" className={`group ${cardVisibilityClass(i)} flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200`}
> >
<div className="flex items-start gap-4 mb-4"> <div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px]"> <div className="bg-zinc-800 rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px]">
@@ -113,6 +141,16 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
</div> </div>
)} )}
<div className={`${loadMoreClass} justify-center mt-10`}>
<button
type="button"
onClick={() => setPages((p) => p + 1)}
className="flex items-center gap-2 rounded-lg border border-zinc-700 px-6 py-2.5 text-sm font-semibold text-on-surface hover:border-primary hover:text-primary transition-colors"
>
Load more
</button>
</div>
<div className="md:hidden flex flex-col items-center gap-3 mt-8"> <div className="md:hidden flex flex-col items-center gap-3 mt-8">
<Link <Link
href="/events" href="/events"
+14
View File
@@ -82,6 +82,20 @@ export function shortenPubkey(pubkey: string): string {
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`; return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
} }
// Shortened npub for display when no profile is available. Accepts a hex pubkey
// or an existing npub/nprofile and always renders bech32 (never raw hex).
export function shortenNpub(pubkeyOrBech32: string): string {
if (!pubkeyOrBech32) return "";
try {
const npub = pubkeyOrBech32.startsWith("npub")
? pubkeyOrBech32
: nip19.npubEncode(toHexPubkey(pubkeyOrBech32) || pubkeyOrBech32);
return shortenPubkey(npub);
} catch {
return shortenPubkey(pubkeyOrBech32);
}
}
export interface NostrProfile { export interface NostrProfile {
name?: string; name?: string;
picture?: string; picture?: string;
+4
View File
@@ -1,5 +1,9 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
// Isolate dev and prod build output. `next start` (production) reads the
// default `.next`; `next dev` is pointed at `.next-dev` via NEXT_DIST_DIR so a
// stray dev run can never overwrite the live production manifests.
distDir: process.env.NEXT_DIST_DIR || '.next',
experimental: { experimental: {
serverComponentsExternalPackages: ['sharp'], serverComponentsExternalPackages: ['sharp'],
}, },
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "bbe-frontend", "name": "bbe-frontend",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"dev": "next dev", "dev": "NEXT_DIST_DIR=.next-dev next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Guard against serving a corrupted / dev-contaminated Next.js production build.
#
# Runs as an ExecStartPre for bbe-frontend.service. A stray `next dev` against
# the production checkout overwrites .next/build-manifest.json (and friends) with
# development artifacts, which makes every request-time route 500. Rather than
# let the server boot and silently serve errors for days, we refuse to start and
# log a clear, actionable message.
#
# Exit codes: 0 = build looks like a valid production build, non-zero otherwise.
set -euo pipefail
# Resolve the frontend dir relative to this script so it works regardless of cwd.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
NEXT_DIR="${FRONTEND_DIR}/.next"
fail() {
echo "verify-prod-build: FATAL: $1" >&2
echo "verify-prod-build: run 'npm run build' (or scripts/deploy-frontend.sh) to produce a clean production build." >&2
exit 1
}
[ -d "${NEXT_DIR}" ] || fail "no .next directory at ${NEXT_DIR}"
[ -f "${NEXT_DIR}/BUILD_ID" ] || fail ".next/BUILD_ID is missing (incomplete or wiped build)"
[ -f "${NEXT_DIR}/build-manifest.json" ] || fail ".next/build-manifest.json is missing"
[ -f "${NEXT_DIR}/app-build-manifest.json" ] || fail ".next/app-build-manifest.json is missing"
[ -f "${NEXT_DIR}/prerender-manifest.json" ] || fail ".next/prerender-manifest.json is missing (dev builds omit it)"
# A dev run leaves .next/static/development/ behind; production never has it.
if [ -d "${NEXT_DIR}/static/development" ]; then
fail ".next/static/development exists — this .next was contaminated by 'next dev'"
fi
# Development manifests reference static/development/* chunks; production ones don't.
if grep -q "static/development" "${NEXT_DIR}/build-manifest.json" 2>/dev/null; then
fail "build-manifest.json references static/development — dev build detected"
fi
echo "verify-prod-build: OK (BUILD_ID=$(cat "${NEXT_DIR}/BUILD_ID"))"
exit 0
+8 -1
View File
@@ -16,6 +16,13 @@
"plugins": [{ "name": "next" }], "plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] } "paths": { "@/*": ["./*"] }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next-dev/types/**/*.ts",
".next-build/types/**/*.ts"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }
+85
View File
@@ -0,0 +1,85 @@
# Ops: keeping the site up
These files harden the deploy/run path so a stray `next dev` or a half-finished
build can no longer take the public site down (root cause of the `/events` 500).
## What's here
| Path | Purpose |
|------|---------|
| `frontend/scripts/verify-prod-build.sh` | Fails fast if `.next` is missing or dev-contaminated. Wired as `ExecStartPre`. |
| `scripts/deploy-frontend.sh` | Atomic build-verify-swap-restart deploy. Use this to deploy the frontend. |
| `scripts/healthcheck.sh` | Watchdog: probes `/`, `/events`, `/blog`, `/api/health`; restarts frontend after repeated failures. |
| `ops/systemd/bbe-frontend.service.d/verify-build.conf` | Drop-in adding the build guard to `bbe-frontend.service`. |
| `ops/systemd/bbe-site-healthcheck.{service,timer}` | Runs the watchdog every 5 minutes. |
## Layered defenses
1. **Isolation**`next dev` now writes to `.next-dev` (`NEXT_DIST_DIR` in
`frontend/package.json` + `distDir` in `frontend/next.config.js`), so it can
never overwrite the production `.next`.
2. **Fail-fast boot** — the `ExecStartPre` guard refuses to start on a bad build.
3. **Atomic deploys**`deploy-frontend.sh` builds into `.next-build`, verifies,
then renames into place; the running server never sees a partial `.next`.
4. **Self-healing** — the watchdog restarts a wedged frontend within minutes.
5. **Graceful UI**`app/error.tsx` / `app/global-error.tsx` render a retry page
instead of a bare 500 if a request-time route ever throws.
## Install (requires sudo)
One-shot installer (preferred):
```bash
cd /home/bbe/BelgianBitcoinEmbassy
sudo ops/install-systemd.sh
sudo systemctl restart bbe-backend # after backend code changes
```
Or manually:
```bash
cd /home/bbe/BelgianBitcoinEmbassy
# 1. Build guard (ExecStartPre)
sudo mkdir -p /etc/systemd/system/bbe-frontend.service.d
sudo cp ops/systemd/bbe-frontend.service.d/verify-build.conf \
/etc/systemd/system/bbe-frontend.service.d/verify-build.conf
# 2. Health watchdog
sudo cp ops/systemd/bbe-site-healthcheck.service /etc/systemd/system/
sudo cp ops/systemd/bbe-site-healthcheck.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl restart bbe-frontend
sudo systemctl enable --now bbe-site-healthcheck.timer
```
Verify:
```bash
systemctl status bbe-frontend --no-pager
systemctl list-timers bbe-site-healthcheck --no-pager
journalctl -u bbe-healthcheck -n 20 --no-pager
```
## Deploying the frontend from now on
Do **not** run `npm run build` directly in the live tree and then restart. Use:
```bash
scripts/deploy-frontend.sh
```
It refuses to build when free memory is low (`MIN_FREE_MB`, default 600), builds
into `.next-build`, verifies, atomically swaps to `.next`, and restarts the
service. The previous build is kept at `.next-prev` for a quick manual rollback.
## Note on the watchdog restarting the service
`healthcheck.sh` runs `systemctl restart bbe-frontend`. The systemd service above
runs as root, so this works out of the box. If you ever run the script as the
`bbe` user instead, grant a narrow sudoers rule:
```
bbe ALL=(root) NOPASSWD: /usr/bin/systemctl restart bbe-frontend
```
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
#
# Install the production hardening units (build guard + health watchdog).
# Requires sudo. Safe to re-run (idempotent).
#
# sudo ops/install-systemd.sh
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ "$(id -u)" -ne 0 ]; then
echo "install-systemd: re-run as root, e.g.:" >&2
echo " sudo $0" >&2
exit 1
fi
mkdir -p /etc/systemd/system/bbe-frontend.service.d
cp "${REPO_DIR}/ops/systemd/bbe-frontend.service.d/verify-build.conf" \
/etc/systemd/system/bbe-frontend.service.d/verify-build.conf
cp "${REPO_DIR}/ops/systemd/bbe-site-healthcheck.service" /etc/systemd/system/
cp "${REPO_DIR}/ops/systemd/bbe-site-healthcheck.timer" /etc/systemd/system/
systemctl daemon-reload
systemctl restart bbe-frontend
systemctl enable --now bbe-site-healthcheck.timer
echo "install-systemd: OK"
systemctl status bbe-frontend --no-pager -n 5 || true
systemctl list-timers bbe-site-healthcheck --no-pager || true
@@ -0,0 +1,15 @@
# Drop-in override for bbe-frontend.service.
#
# Refuses to (re)start the frontend when the .next production build is missing or
# has been contaminated by a `next dev` run, so we fail loudly at boot instead of
# silently serving 500s on every request-time route (e.g. /events).
#
# Install:
# sudo mkdir -p /etc/systemd/system/bbe-frontend.service.d
# sudo cp ops/systemd/bbe-frontend.service.d/verify-build.conf \
# /etc/systemd/system/bbe-frontend.service.d/verify-build.conf
# sudo systemctl daemon-reload
# sudo systemctl restart bbe-frontend
[Service]
ExecStartPre=/home/bbe/BelgianBitcoinEmbassy/frontend/scripts/verify-prod-build.sh
+18
View File
@@ -0,0 +1,18 @@
# Oneshot health probe for the BBE site, driven by bbe-site-healthcheck.timer.
#
# Runs as root so it can `systemctl restart bbe-frontend` when the site is down.
#
# Install:
# sudo cp ops/systemd/bbe-site-healthcheck.service /etc/systemd/system/
# sudo cp ops/systemd/bbe-site-healthcheck.timer /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now bbe-site-healthcheck.timer
[Unit]
Description=Belgian Bitcoin Embassy — site health watchdog
After=network.target bbe-frontend.service
[Service]
Type=oneshot
ExecStart=/home/bbe/BelgianBitcoinEmbassy/scripts/healthcheck.sh
SyslogIdentifier=bbe-healthcheck
+13
View File
@@ -0,0 +1,13 @@
# Fires the BBE site health watchdog every 5 minutes.
[Unit]
Description=Run BBE site health watchdog every 5 minutes
[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
AccuracySec=30s
Unit=bbe-site-healthcheck.service
[Install]
WantedBy=timers.target
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
#
# Atomic frontend deploy for the Belgian Bitcoin Embassy site.
#
# Builds into a scratch dist dir, verifies the artifact, then swaps it into place
# as .next in a single mv (rename is atomic on the same filesystem). The running
# server therefore never sees a half-written .next, and a failed build leaves the
# current production build untouched.
#
# Usage:
# scripts/deploy-frontend.sh # build, verify, swap, restart service
# scripts/deploy-frontend.sh --no-restart # build + swap, skip systemctl
#
# Requires sudo only for the final `systemctl restart` (skip with --no-restart).
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FRONTEND_DIR="${REPO_DIR}/frontend"
BUILD_DIR="${FRONTEND_DIR}/.next-build"
PROD_DIR="${FRONTEND_DIR}/.next"
BACKUP_DIR="${FRONTEND_DIR}/.next-prev"
MIN_FREE_MB="${MIN_FREE_MB:-600}"
RESTART=1
[ "${1:-}" = "--no-restart" ] && RESTART=0
log() { echo "deploy-frontend: $*"; }
die() { echo "deploy-frontend: ERROR: $*" >&2; exit 1; }
cd "${FRONTEND_DIR}"
# A production `next build` on this box needs headroom; bail early with a clear
# message rather than getting OOM-killed halfway through.
free_mb="$(free -m | awk '/^Mem:/ {print $7}')"
if [ -n "${free_mb}" ] && [ "${free_mb}" -lt "${MIN_FREE_MB}" ]; then
die "only ${free_mb}MB available memory (< ${MIN_FREE_MB}MB). Free memory or set MIN_FREE_MB=... to override."
fi
log "building into ${BUILD_DIR} ..."
rm -rf "${BUILD_DIR}"
NEXT_DIST_DIR=".next-build" npm run build
log "verifying fresh build ..."
NEXT_DIR="${BUILD_DIR}"
[ -f "${NEXT_DIR}/BUILD_ID" ] || die "build produced no BUILD_ID"
[ -f "${NEXT_DIR}/build-manifest.json" ] || die "build produced no build-manifest.json"
[ -f "${NEXT_DIR}/prerender-manifest.json" ] || die "build produced no prerender-manifest.json"
[ -d "${NEXT_DIR}/static/development" ] && die "fresh build contains static/development (dev contamination)"
grep -q "static/development" "${NEXT_DIR}/build-manifest.json" && die "build-manifest references static/development"
log "swapping ${BUILD_DIR} -> ${PROD_DIR} (atomic) ..."
rm -rf "${BACKUP_DIR}"
[ -d "${PROD_DIR}" ] && mv "${PROD_DIR}" "${BACKUP_DIR}"
mv "${BUILD_DIR}" "${PROD_DIR}"
log "deployed BUILD_ID=$(cat "${PROD_DIR}/BUILD_ID")"
if [ "${RESTART}" -eq 1 ]; then
log "restarting bbe-frontend.service ..."
sudo systemctl restart bbe-frontend
log "done. previous build kept at ${BACKUP_DIR}"
else
log "skipped restart (--no-restart). previous build kept at ${BACKUP_DIR}"
fi
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Health watchdog for the BBE site. Probes the frontend's request-time routes and
# the backend health endpoint on their local ports. On sustained failure it
# restarts bbe-frontend and logs loudly to the journal, turning a silent multi-day
# outage (like the /events 500) into a self-healing few-minute blip.
#
# Meant to be run by bbe-site-healthcheck.timer every few minutes. State is kept
# in a tmp file so a single transient blip doesn't trigger a restart — we only act
# after FAIL_THRESHOLD consecutive failing runs.
set -uo pipefail
FRONTEND_BASE="${FRONTEND_BASE:-http://127.0.0.1:4056}"
API_BASE="${API_BASE:-http://127.0.0.1:4055}"
FAIL_THRESHOLD="${FAIL_THRESHOLD:-2}"
STATE_FILE="${STATE_FILE:-/tmp/bbe-healthcheck.fails}"
TIMEOUT="${TIMEOUT:-10}"
FRONTEND_PATHS=(/ /events /blog)
log() { echo "bbe-healthcheck: $*"; }
check() {
local url="$1" code
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time "${TIMEOUT}" "${url}" 2>/dev/null || echo 000)"
if [ "${code}" = "200" ]; then
return 0
fi
log "FAIL ${url} -> ${code}"
return 1
}
failed=0
for p in "${FRONTEND_PATHS[@]}"; do
check "${FRONTEND_BASE}${p}" || failed=1
done
check "${API_BASE}/api/health" || failed=1
if [ "${failed}" -eq 0 ]; then
# Healthy: clear the failure counter and exit quietly.
rm -f "${STATE_FILE}"
log "OK (all probes 200)"
exit 0
fi
# Unhealthy: increment consecutive-failure counter.
count=0
[ -f "${STATE_FILE}" ] && count="$(cat "${STATE_FILE}" 2>/dev/null || echo 0)"
count=$((count + 1))
echo "${count}" > "${STATE_FILE}"
log "unhealthy run ${count}/${FAIL_THRESHOLD}"
if [ "${count}" -ge "${FAIL_THRESHOLD}" ]; then
log "threshold reached — restarting bbe-frontend.service"
if systemctl restart bbe-frontend 2>/dev/null; then
log "restart issued"
else
log "ERROR: restart failed (needs privileges? see PolicyKit/sudoers note in ops/README.md)"
fi
rm -f "${STATE_FILE}"
fi
exit 1