Compare commits

...
3 Commits
Author SHA1 Message Date
Michilis e6d7df6995 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-07-18 17:17:21 +00:00
bbeandCursor ede8817583 fix: harden deploys and close top security holes after /events outage
Isolate next dev from production .next, add build-guard/atomic deploy/health
watchdog, error boundaries, and fix JWT startup, meetup leaks, media path
traversal, SVG/memory uploads, and JSON-LD escaping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 19:14:57 +02:00
bbeandCursor 495289232b feat: blog header images, npub display, and meetups load more
Show longform image tags on posts, use shortened npubs for author names,
and paginate the homepage meetups section with responsive load more.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 04:36:10 +02:00
26 changed files with 792 additions and 113 deletions
+3
View File
@@ -3,6 +3,9 @@ node_modules/
# Next.js
frontend/.next/
frontend/.next-dev/
frontend/.next-build/
frontend/.next-prev/
frontend/out/
# 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 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' },
include: { organizer: true },
});
+41 -24
View File
@@ -18,12 +18,23 @@ function ensureStorageDir() {
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({
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
});
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 ALLOWED_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES];
@@ -54,24 +65,23 @@ router.post(
}
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}` });
return;
}
const mediaType = getMediaType(file.mimetype);
if (!mediaType) {
if (file.path) fs.unlink(file.path, () => {});
res.status(400).json({ error: 'Could not determine media type' });
return;
}
const id = ulid();
// multer.diskStorage already wrote the blob under a ULID filename.
const id = file.filename;
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`);
fs.writeFileSync(metaPath, JSON.stringify({
mimeType: file.mimetype,
@@ -79,23 +89,30 @@ router.post(
size: file.size,
}));
const media = await prisma.media.create({
data: {
id,
slug,
type: mediaType,
mimeType: file.mimetype,
size: file.size,
originalFilename: file.originalname,
uploadedBy: req.user!.pubkey,
},
});
try {
const media = await prisma.media.create({
data: {
id,
slug,
type: mediaType,
mimeType: file.mimetype,
size: file.size,
originalFilename: file.originalname,
uploadedBy: req.user!.pubkey,
},
});
res.status(201).json({
id: media.id,
slug: media.slug,
url: `/media/${media.id}`,
});
res.status(201).json({
id: media.id,
slug: media.slug,
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) {
console.error('Upload media error:', err);
res.status(500).json({ error: 'Internal server error' });
+46 -1
View File
@@ -1,13 +1,47 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../db/prisma';
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 { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const router = Router();
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 {
const match = title.match(/^(.*#)(\d+)(.*)$/);
if (match) {
@@ -28,7 +62,8 @@ async function resolveOrganizerIdForCreate(organizerId: unknown): Promise<string
router.get('/', async (req: Request, res: Response) => {
try {
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 where: Record<string, unknown> = {};
if (status) where.status = status;
@@ -69,6 +104,16 @@ router.get('/:id', async (req: Request, res: Response) => {
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);
} catch (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 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
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);
// 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)) {
const access = await resolveApiKey(token);
if (!access) {
res.status(401).json({ error: 'Invalid or revoked API key' });
return;
try {
const access = await resolveApiKey(token);
if (!access) {
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;
}
+34 -9
View File
@@ -6,13 +6,38 @@ export async function GET(req: NextRequest) {
const upstream = new URL(apiUrl('/nip05'));
if (name) upstream.searchParams.set('name', name);
const res = await fetch(upstream.toString(), { cache: 'no-store' });
const data = await res.json();
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
});
try {
const res = await fetch(upstream.toString(), { cache: 'no-store' });
if (!res.ok) {
return NextResponse.json(
{},
{
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,
signEvent,
publishEvent,
shortenPubkey,
shortenNpub,
fetchNostrProfile,
fetchLongformFromRelays,
fetchEventFromRelays,
@@ -23,6 +23,7 @@ import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { markdownComponents } from "./markdownComponents";
import { remarkNostr } from "./remarkNostr";
import { NostrAuthor } from "./NostrEmbeds";
interface Post {
id: string;
@@ -30,6 +31,7 @@ interface Post {
title: string;
content: string;
excerpt?: string;
image?: string;
authorName?: string;
authorPubkey?: string;
publishedAt?: string;
@@ -59,6 +61,7 @@ function postFromEvent(event: any, slug: string): Post {
title: tag("title") || "Untitled",
content: event.content || "",
excerpt: tag("summary"),
image: tag("image"),
authorPubkey: event.pubkey,
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
nostrEventId: event.id,
@@ -117,6 +120,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
const [submitting, setSubmitting] = useState(false);
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
const [liveContent, setLiveContent] = useState<string | null>(null);
const [liveImage, setLiveImage] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(false);
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
const [contentError, setContentError] = useState(false);
@@ -134,6 +138,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
setLoading(true);
setError(null);
setLiveContent(null);
setLiveImage(null);
setPost(null);
setAuthorProfile(null);
setContentError(false);
@@ -159,8 +164,14 @@ export default function BlogPostClient({ slug }: { slug: string }) {
fetchPromise
.then((event) => {
if (cancelled) return;
if (event?.content) setLiveContent(event.content);
else setContentError(true); // not found / timed out
if (event?.content) {
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))
.finally(() => !cancelled && setLoadingContent(false));
@@ -258,6 +269,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
}, [comment, post, hasNostr]);
const categories = post?.categories?.map((c) => c.category) || [];
const headerImage = post?.image || liveImage;
return (
<>
@@ -321,7 +333,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
/>
)}
<span className="font-medium text-on-surface-variant">
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
{authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)}
</span>
</div>
)}
@@ -338,6 +350,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
</div>
</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">
{loadingContent ? (
<RelayLoading />
@@ -424,9 +447,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
className="bg-surface-container-high rounded-lg p-4"
>
<div className="flex items-center gap-2.5 mb-2">
<span className="font-semibold text-xs font-mono text-on-surface-variant/70">
{shortenPubkey(r.pubkey)}
</span>
<NostrAuthor pubkey={r.pubkey} />
<span className="text-on-surface-variant/30">·</span>
<span className="text-xs text-on-surface-variant/50">
{formatDate(new Date(r.created_at * 1000))}
+11 -19
View File
@@ -5,7 +5,7 @@ import { nip19 } from "nostr-tools";
import {
loadNostrProfile,
resolveEventFromRelays,
shortenPubkey,
shortenNpub,
type NostrProfile,
} from "@/lib/nostr";
@@ -39,11 +39,8 @@ export function NostrMention({ bech32 }: { bech32: string }) {
}, [pubkey]);
// Show the username; fall back to a shortened npub only when no profile.
const npub = useMemo(() => {
if (bech32.startsWith("npub")) return bech32;
return pubkey ? nip19.npubEncode(pubkey) : bech32;
}, [bech32, pubkey]);
const name = profile?.name || profile?.displayName || shortenPubkey(npub);
const name =
profile?.name || profile?.displayName || shortenNpub(pubkey || bech32);
return (
<a
@@ -67,8 +64,10 @@ export function NostrMention({ bech32 }: { bech32: string }) {
);
}
// Compact author line used inside an embedded note.
function NoteAuthor({ pubkey }: { pubkey: string }) {
// Reusable author line (avatar + username) resolved from the author's kind:0
// 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);
useEffect(() => {
let cancelled = false;
@@ -79,21 +78,14 @@ function NoteAuthor({ pubkey }: { pubkey: string }) {
cancelled = true;
};
}, [pubkey]);
const fallback = useMemo(() => {
try {
return shortenPubkey(nip19.npubEncode(pubkey));
} catch {
return shortenPubkey(pubkey);
}
}, [pubkey]);
const name = profile?.name || profile?.displayName || fallback;
const name = profile?.name || profile?.displayName || shortenNpub(pubkey);
return (
<span className="flex items-center gap-2">
<span className="inline-flex items-center gap-2">
{profile?.picture && (
<img
src={profile.picture}
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) => {
(e.target as HTMLImageElement).style.display = "none";
}}
@@ -151,7 +143,7 @@ export function NostrNote({ bech32 }: { bech32: string }) {
{state === "done" && event && (
<span className="block">
<span className="flex items-center justify-between mb-3">
<NoteAuthor pubkey={event.pubkey} />
<NostrAuthor pubkey={event.pubkey} />
<a
href={njump(bech32)}
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 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(
request: NextRequest,
context: { params: Promise<{ id: string }> | { id: string } }
) {
const params = await Promise.resolve(context.params);
const id = params.id;
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 });
try {
const params = await Promise.resolve(context.params);
const id = params.id;
if (!id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return handleImageResize(root, filePath, width, meta, id);
}
if (meta.type === 'video') {
const rangeHeader = request.headers.get('range');
return handleVideoStream(filePath, meta, rangeHeader);
}
const root = getMediaStorageRoot();
const filePath = resolveSafeMediaPath(root, id);
if (!filePath || !fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
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,
},
});
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 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) {
// 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 (
<script
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 Link from "next/link";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
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 {
id?: string;
title: string;
@@ -19,6 +27,26 @@ interface 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 (
<section className="py-24 px-8 border-t border-zinc-800/50">
<div className="max-w-6xl mx-auto">
@@ -59,7 +87,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
<Link
key={meetup.id ?? i}
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="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 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">
<Link
href="/events"
+14
View File
@@ -82,6 +82,20 @@ export function shortenPubkey(pubkey: string): string {
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 {
name?: string;
picture?: string;
+4
View File
@@ -1,5 +1,9 @@
/** @type {import('next').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: {
serverComponentsExternalPackages: ['sharp'],
},
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "bbe-frontend",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"dev": "NEXT_DIST_DIR=.next-dev next dev -p 3001",
"build": "next build",
"start": "next start",
"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" }],
"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"]
}
+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