From 495289232be40ec84c4d1aca1f6b5cbff11fb8a6 Mon Sep 17 00:00:00 2001 From: bbe Date: Mon, 29 Jun 2026 04:36:10 +0200 Subject: [PATCH 1/2] 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 --- frontend/app/blog/[slug]/BlogPostClient.tsx | 35 ++++++++++++---- frontend/app/blog/[slug]/NostrEmbeds.tsx | 30 +++++--------- frontend/components/public/MeetupsSection.tsx | 40 ++++++++++++++++++- frontend/lib/nostr.ts | 14 +++++++ 4 files changed, 92 insertions(+), 27 deletions(-) diff --git a/frontend/app/blog/[slug]/BlogPostClient.tsx b/frontend/app/blog/[slug]/BlogPostClient.tsx index 85921e8..ccf00b5 100644 --- a/frontend/app/blog/[slug]/BlogPostClient.tsx +++ b/frontend/app/blog/[slug]/BlogPostClient.tsx @@ -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(null); const [liveContent, setLiveContent] = useState(null); + const [liveImage, setLiveImage] = useState(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 }) { /> )} - {authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)} + {authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)} )} @@ -338,6 +350,17 @@ export default function BlogPostClient({ slug }: { slug: string }) { + {headerImage && ( + {post.title} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} +
{loadingContent ? ( @@ -424,9 +447,7 @@ export default function BlogPostClient({ slug }: { slug: string }) { className="bg-surface-container-high rounded-lg p-4" >
- - {shortenPubkey(r.pubkey)} - + · {formatDate(new Date(r.created_at * 1000))} diff --git a/frontend/app/blog/[slug]/NostrEmbeds.tsx b/frontend/app/blog/[slug]/NostrEmbeds.tsx index 18fc676..02bed66 100644 --- a/frontend/app/blog/[slug]/NostrEmbeds.tsx +++ b/frontend/app/blog/[slug]/NostrEmbeds.tsx @@ -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 ( (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 ( - + {profile?.picture && ( { (e.target as HTMLImageElement).style.display = "none"; }} @@ -151,7 +143,7 @@ export function NostrNote({ bech32 }: { bech32: string }) { {state === "done" && event && ( - + = 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 (
@@ -59,7 +87,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
@@ -113,6 +141,16 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
)} +
+ +
+
Date: Sat, 18 Jul 2026 19:14:57 +0200 Subject: [PATCH 2/2] 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 --- .gitignore | 3 + backend/src/api/calendar.ts | 7 +- backend/src/api/media.ts | 65 +++++++----- backend/src/api/meetups.ts | 47 +++++++- backend/src/index.ts | 18 ++++ backend/src/middleware/auth.ts | 24 +++-- frontend/app/.well-known/nostr.json/route.ts | 43 ++++++-- frontend/app/error.tsx | 53 ++++++++++ frontend/app/global-error.tsx | 73 +++++++++++++ frontend/app/media/[id]/route.ts | 100 +++++++++++------- frontend/components/public/JsonLd.tsx | 5 +- frontend/next.config.js | 4 + frontend/package.json | 2 +- frontend/scripts/verify-prod-build.sh | 43 ++++++++ frontend/tsconfig.json | 9 +- ops/README.md | 85 +++++++++++++++ ops/install-systemd.sh | 30 ++++++ .../bbe-frontend.service.d/verify-build.conf | 15 +++ ops/systemd/bbe-site-healthcheck.service | 18 ++++ ops/systemd/bbe-site-healthcheck.timer | 13 +++ scripts/deploy-frontend.sh | 65 ++++++++++++ scripts/healthcheck.sh | 64 +++++++++++ 22 files changed, 700 insertions(+), 86 deletions(-) create mode 100644 frontend/app/error.tsx create mode 100644 frontend/app/global-error.tsx create mode 100755 frontend/scripts/verify-prod-build.sh create mode 100644 ops/README.md create mode 100755 ops/install-systemd.sh create mode 100644 ops/systemd/bbe-frontend.service.d/verify-build.conf create mode 100644 ops/systemd/bbe-site-healthcheck.service create mode 100644 ops/systemd/bbe-site-healthcheck.timer create mode 100755 scripts/deploy-frontend.sh create mode 100755 scripts/healthcheck.sh diff --git a/.gitignore b/.gitignore index dec0ed3..8ee6f91 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ node_modules/ # Next.js frontend/.next/ +frontend/.next-dev/ +frontend/.next-build/ +frontend/.next-prev/ frontend/out/ # Backend build diff --git a/backend/src/api/calendar.ts b/backend/src/api/calendar.ts index 65bf094..93c8db5 100644 --- a/backend/src/api/calendar.ts +++ b/backend/src/api/calendar.ts @@ -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 }, }); diff --git a/backend/src/api/media.ts b/backend/src/api/media.ts index 7597223..a79516c 100644 --- a/backend/src/api/media.ts +++ b/backend/src/api/media.ts @@ -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' }); diff --git a/backend/src/api/meetups.ts b/backend/src/api/meetups.ts index 14d990c..956fe87 100644 --- a/backend/src/api/meetups.ts +++ b/backend/src/api/meetups.ts @@ -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 { + 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 { + 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 { 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 = {}; 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); diff --git a/backend/src/index.ts b/backend/src/index.ts index 0cb9285..228990c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index cf591e6..9c0e2c8 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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; } diff --git a/frontend/app/.well-known/nostr.json/route.ts b/frontend/app/.well-known/nostr.json/route.ts index 1098dee..5f8979d 100644 --- a/frontend/app/.well-known/nostr.json/route.ts +++ b/frontend/app/.well-known/nostr.json/route.ts @@ -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', + }, + } + ); + } } diff --git a/frontend/app/error.tsx b/frontend/app/error.tsx new file mode 100644 index 0000000..5cc8c4e --- /dev/null +++ b/frontend/app/error.tsx @@ -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 ( +
+ + Oops + +

+ Something went wrong +

+

+ This page hit an unexpected error. It's usually temporary — try again + in a moment. +

+
+ + + Back to Home + +
+ {error?.digest && ( +

+ ref: {error.digest} +

+ )} +
+ ); +} diff --git a/frontend/app/global-error.tsx b/frontend/app/global-error.tsx new file mode 100644 index 0000000..24e07b7 --- /dev/null +++ b/frontend/app/global-error.tsx @@ -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 / 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 ( + + +

+ Something went wrong +

+

+ The site hit an unexpected error. Please try again in a moment. +

+ + {error?.digest && ( +

+ ref: {error.digest} +

+ )} + + + ); +} diff --git a/frontend/app/media/[id]/route.ts b/frontend/app/media/[id]/route.ts index 4a325fe..ef75674 100644 --- a/frontend/app/media/[id]/route.ts +++ b/frontend/app/media/[id]/route.ts @@ -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 }); + } } diff --git a/frontend/components/public/JsonLd.tsx b/frontend/components/public/JsonLd.tsx index 9ff6c51..117f102 100644 --- a/frontend/components/public/JsonLd.tsx +++ b/frontend/components/public/JsonLd.tsx @@ -3,10 +3,13 @@ interface JsonLdProps { } export function JsonLd({ data }: JsonLdProps) { + // Escape `<` so user-controlled titles/descriptions cannot break out of the + // ` (JSON.stringify alone does not escape it). + const json = JSON.stringify(data).replace(/ ); } diff --git a/frontend/next.config.js b/frontend/next.config.js index 5238865..7ed4a8f 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -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'], }, diff --git a/frontend/package.json b/frontend/package.json index 7caff2f..e7c1f51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/scripts/verify-prod-build.sh b/frontend/scripts/verify-prod-build.sh new file mode 100755 index 0000000..2b64fca --- /dev/null +++ b/frontend/scripts/verify-prod-build.sh @@ -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 diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 6420eed..a65b73b 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -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"] } diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..8738f31 --- /dev/null +++ b/ops/README.md @@ -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 +``` diff --git a/ops/install-systemd.sh b/ops/install-systemd.sh new file mode 100755 index 0000000..e1e4425 --- /dev/null +++ b/ops/install-systemd.sh @@ -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 diff --git a/ops/systemd/bbe-frontend.service.d/verify-build.conf b/ops/systemd/bbe-frontend.service.d/verify-build.conf new file mode 100644 index 0000000..cf69a0a --- /dev/null +++ b/ops/systemd/bbe-frontend.service.d/verify-build.conf @@ -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 diff --git a/ops/systemd/bbe-site-healthcheck.service b/ops/systemd/bbe-site-healthcheck.service new file mode 100644 index 0000000..a12c5fa --- /dev/null +++ b/ops/systemd/bbe-site-healthcheck.service @@ -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 diff --git a/ops/systemd/bbe-site-healthcheck.timer b/ops/systemd/bbe-site-healthcheck.timer new file mode 100644 index 0000000..4361ff5 --- /dev/null +++ b/ops/systemd/bbe-site-healthcheck.timer @@ -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 diff --git a/scripts/deploy-frontend.sh b/scripts/deploy-frontend.sh new file mode 100755 index 0000000..b707f65 --- /dev/null +++ b/scripts/deploy-frontend.sh @@ -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 diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100755 index 0000000..a5612f5 --- /dev/null +++ b/scripts/healthcheck.sh @@ -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