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