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>
This commit is contained in:
bbe
2026-07-18 19:14:57 +02:00
co-authored by Cursor
parent 495289232b
commit ede8817583
22 changed files with 700 additions and 86 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',
},
}
);
}
}
+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 }}
/>
);
}
+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