Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -3,6 +3,9 @@ node_modules/
|
||||
|
||||
# Next.js
|
||||
frontend/.next/
|
||||
frontend/.next-dev/
|
||||
frontend/.next-build/
|
||||
frontend/.next-prev/
|
||||
frontend/out/
|
||||
|
||||
# Backend build
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getPublicKey,
|
||||
signEvent,
|
||||
publishEvent,
|
||||
shortenPubkey,
|
||||
shortenNpub,
|
||||
fetchNostrProfile,
|
||||
fetchLongformFromRelays,
|
||||
fetchEventFromRelays,
|
||||
@@ -23,6 +23,7 @@ import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { markdownComponents } from "./markdownComponents";
|
||||
import { remarkNostr } from "./remarkNostr";
|
||||
import { NostrAuthor } from "./NostrEmbeds";
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
@@ -30,6 +31,7 @@ interface Post {
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt?: string;
|
||||
image?: string;
|
||||
authorName?: string;
|
||||
authorPubkey?: string;
|
||||
publishedAt?: string;
|
||||
@@ -59,6 +61,7 @@ function postFromEvent(event: any, slug: string): Post {
|
||||
title: tag("title") || "Untitled",
|
||||
content: event.content || "",
|
||||
excerpt: tag("summary"),
|
||||
image: tag("image"),
|
||||
authorPubkey: event.pubkey,
|
||||
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
||||
nostrEventId: event.id,
|
||||
@@ -117,6 +120,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
||||
const [liveContent, setLiveContent] = useState<string | null>(null);
|
||||
const [liveImage, setLiveImage] = useState<string | null>(null);
|
||||
const [loadingContent, setLoadingContent] = useState(false);
|
||||
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
|
||||
const [contentError, setContentError] = useState(false);
|
||||
@@ -134,6 +138,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLiveContent(null);
|
||||
setLiveImage(null);
|
||||
setPost(null);
|
||||
setAuthorProfile(null);
|
||||
setContentError(false);
|
||||
@@ -159,8 +164,14 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
fetchPromise
|
||||
.then((event) => {
|
||||
if (cancelled) return;
|
||||
if (event?.content) setLiveContent(event.content);
|
||||
else setContentError(true); // not found / timed out
|
||||
if (event?.content) {
|
||||
setLiveContent(event.content);
|
||||
// Pull the longform header image (`image` tag) for display.
|
||||
const img = event.tags?.find((t: string[]) => t[0] === "image")?.[1];
|
||||
if (img) setLiveImage(img);
|
||||
} else {
|
||||
setContentError(true); // not found / timed out
|
||||
}
|
||||
})
|
||||
.catch(() => !cancelled && setContentError(true))
|
||||
.finally(() => !cancelled && setLoadingContent(false));
|
||||
@@ -258,6 +269,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
}, [comment, post, hasNostr]);
|
||||
|
||||
const categories = post?.categories?.map((c) => c.category) || [];
|
||||
const headerImage = post?.image || liveImage;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -321,7 +333,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium text-on-surface-variant">
|
||||
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
|
||||
{authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -338,6 +350,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{headerImage && (
|
||||
<img
|
||||
src={headerImage}
|
||||
alt={post.title}
|
||||
className="w-full rounded-xl mb-12 object-cover max-h-[28rem]"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<article className="mb-16">
|
||||
{loadingContent ? (
|
||||
<RelayLoading />
|
||||
@@ -424,9 +447,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
className="bg-surface-container-high rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<span className="font-semibold text-xs font-mono text-on-surface-variant/70">
|
||||
{shortenPubkey(r.pubkey)}
|
||||
</span>
|
||||
<NostrAuthor pubkey={r.pubkey} />
|
||||
<span className="text-on-surface-variant/30">·</span>
|
||||
<span className="text-xs text-on-surface-variant/50">
|
||||
{formatDate(new Date(r.created_at * 1000))}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { nip19 } from "nostr-tools";
|
||||
import {
|
||||
loadNostrProfile,
|
||||
resolveEventFromRelays,
|
||||
shortenPubkey,
|
||||
shortenNpub,
|
||||
type NostrProfile,
|
||||
} from "@/lib/nostr";
|
||||
|
||||
@@ -39,11 +39,8 @@ export function NostrMention({ bech32 }: { bech32: string }) {
|
||||
}, [pubkey]);
|
||||
|
||||
// Show the username; fall back to a shortened npub only when no profile.
|
||||
const npub = useMemo(() => {
|
||||
if (bech32.startsWith("npub")) return bech32;
|
||||
return pubkey ? nip19.npubEncode(pubkey) : bech32;
|
||||
}, [bech32, pubkey]);
|
||||
const name = profile?.name || profile?.displayName || shortenPubkey(npub);
|
||||
const name =
|
||||
profile?.name || profile?.displayName || shortenNpub(pubkey || bech32);
|
||||
|
||||
return (
|
||||
<a
|
||||
@@ -67,8 +64,10 @@ export function NostrMention({ bech32 }: { bech32: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Compact author line used inside an embedded note.
|
||||
function NoteAuthor({ pubkey }: { pubkey: string }) {
|
||||
// Reusable author line (avatar + username) resolved from the author's kind:0
|
||||
// profile. Falls back to a shortened npub — never the raw hex pubkey. Shared by
|
||||
// embedded notes and the blog comment list.
|
||||
export function NostrAuthor({ pubkey }: { pubkey: string }) {
|
||||
const [profile, setProfile] = useState<NostrProfile | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -79,21 +78,14 @@ function NoteAuthor({ pubkey }: { pubkey: string }) {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pubkey]);
|
||||
const fallback = useMemo(() => {
|
||||
try {
|
||||
return shortenPubkey(nip19.npubEncode(pubkey));
|
||||
} catch {
|
||||
return shortenPubkey(pubkey);
|
||||
}
|
||||
}, [pubkey]);
|
||||
const name = profile?.name || profile?.displayName || fallback;
|
||||
const name = profile?.name || profile?.displayName || shortenNpub(pubkey);
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{profile?.picture && (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt=""
|
||||
className="w-6 h-6 rounded-full object-cover bg-surface-container-high"
|
||||
className="w-6 h-6 rounded-full object-cover bg-surface-container-high shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
@@ -151,7 +143,7 @@ export function NostrNote({ bech32 }: { bech32: string }) {
|
||||
{state === "done" && event && (
|
||||
<span className="block">
|
||||
<span className="flex items-center justify-between mb-3">
|
||||
<NoteAuthor pubkey={event.pubkey} />
|
||||
<NostrAuthor pubkey={event.pubkey} />
|
||||
<a
|
||||
href={njump(bech32)}
|
||||
target="_blank"
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MapPin, Clock, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
||||
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
|
||||
|
||||
// Initial number of meetups shown before "Load more". Mobile is enforced via
|
||||
// CSS (3) so there's no SSR/viewport mismatch; desktop shows up to 6.
|
||||
const INITIAL_MOBILE = 3;
|
||||
const INITIAL_DESKTOP = 6;
|
||||
|
||||
interface MeetupData {
|
||||
id?: string;
|
||||
title: string;
|
||||
@@ -19,6 +27,26 @@ interface MeetupsSectionProps {
|
||||
}
|
||||
|
||||
export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
||||
// Number of extra batches revealed via "Load more". Each batch adds 3 on
|
||||
// mobile and 6 on desktop, so visible counts are 3*(pages+1) / 6*(pages+1).
|
||||
const [pages, setPages] = useState(0);
|
||||
|
||||
const mobileVisible = INITIAL_MOBILE * (pages + 1);
|
||||
const desktopVisible = INITIAL_DESKTOP * (pages + 1);
|
||||
|
||||
// Visibility per card. Enforced with CSS so the server-rendered markup matches
|
||||
// both viewports (desktopVisible >= mobileVisible, so "mobile-only" never occurs).
|
||||
const cardVisibilityClass = (i: number) => {
|
||||
if (i < mobileVisible) return "flex";
|
||||
if (i < desktopVisible) return "hidden md:flex";
|
||||
return "hidden";
|
||||
};
|
||||
|
||||
// Show the button only when content is still hidden at the given breakpoint.
|
||||
let loadMoreClass = "hidden";
|
||||
if (meetups.length > desktopVisible) loadMoreClass = "flex";
|
||||
else if (meetups.length > mobileVisible) loadMoreClass = "flex md:hidden";
|
||||
|
||||
return (
|
||||
<section className="py-24 px-8 border-t border-zinc-800/50">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
@@ -59,7 +87,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
||||
<Link
|
||||
key={meetup.id ?? i}
|
||||
href={href}
|
||||
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
|
||||
className={`group ${cardVisibilityClass(i)} flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200`}
|
||||
>
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="bg-zinc-800 rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px]">
|
||||
@@ -113,6 +141,16 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${loadMoreClass} justify-center mt-10`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPages((p) => p + 1)}
|
||||
className="flex items-center gap-2 rounded-lg border border-zinc-700 px-6 py-2.5 text-sm font-semibold text-on-surface hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden flex flex-col items-center gap-3 mt-8">
|
||||
<Link
|
||||
href="/events"
|
||||
|
||||
@@ -82,6 +82,20 @@ export function shortenPubkey(pubkey: string): string {
|
||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
|
||||
}
|
||||
|
||||
// Shortened npub for display when no profile is available. Accepts a hex pubkey
|
||||
// or an existing npub/nprofile and always renders bech32 (never raw hex).
|
||||
export function shortenNpub(pubkeyOrBech32: string): string {
|
||||
if (!pubkeyOrBech32) return "";
|
||||
try {
|
||||
const npub = pubkeyOrBech32.startsWith("npub")
|
||||
? pubkeyOrBech32
|
||||
: nip19.npubEncode(toHexPubkey(pubkeyOrBech32) || pubkeyOrBech32);
|
||||
return shortenPubkey(npub);
|
||||
} catch {
|
||||
return shortenPubkey(pubkeyOrBech32);
|
||||
}
|
||||
}
|
||||
|
||||
export interface NostrProfile {
|
||||
name?: string;
|
||||
picture?: string;
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+43
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Executable
+30
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+65
@@ -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
|
||||
Executable
+64
@@ -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
|
||||
Reference in New Issue
Block a user