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
|
# Next.js
|
||||||
frontend/.next/
|
frontend/.next/
|
||||||
|
frontend/.next-dev/
|
||||||
|
frontend/.next-build/
|
||||||
|
frontend/.next-prev/
|
||||||
frontend/out/
|
frontend/out/
|
||||||
|
|
||||||
# Backend build
|
# Backend build
|
||||||
|
|||||||
@@ -91,7 +91,12 @@ router.get('/ics', async (_req: Request, res: Response) => {
|
|||||||
const cutoff = sevenDaysAgo.toISOString().slice(0, 10);
|
const cutoff = sevenDaysAgo.toISOString().slice(0, 10);
|
||||||
|
|
||||||
const meetups = await prisma.meetup.findMany({
|
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' },
|
orderBy: { date: 'asc' },
|
||||||
include: { organizer: true },
|
include: { organizer: true },
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-24
@@ -18,12 +18,23 @@ function ensureStorageDir() {
|
|||||||
fs.mkdirSync(STORAGE_PATH, { recursive: true });
|
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({
|
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
|
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 VIDEO_MIMES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
|
||||||
const ALLOWED_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES];
|
const ALLOWED_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES];
|
||||||
|
|
||||||
@@ -54,24 +65,23 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_MIMES.includes(file.mimetype)) {
|
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}` });
|
res.status(400).json({ error: `Unsupported file type: ${file.mimetype}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaType = getMediaType(file.mimetype);
|
const mediaType = getMediaType(file.mimetype);
|
||||||
if (!mediaType) {
|
if (!mediaType) {
|
||||||
|
if (file.path) fs.unlink(file.path, () => {});
|
||||||
res.status(400).json({ error: 'Could not determine media type' });
|
res.status(400).json({ error: 'Could not determine media type' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = ulid();
|
// multer.diskStorage already wrote the blob under a ULID filename.
|
||||||
|
const id = file.filename;
|
||||||
const slug = makeSlug(file.originalname);
|
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`);
|
const metaPath = path.join(STORAGE_PATH, `${id}.json`);
|
||||||
fs.writeFileSync(metaPath, JSON.stringify({
|
fs.writeFileSync(metaPath, JSON.stringify({
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
@@ -79,23 +89,30 @@ router.post(
|
|||||||
size: file.size,
|
size: file.size,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const media = await prisma.media.create({
|
try {
|
||||||
data: {
|
const media = await prisma.media.create({
|
||||||
id,
|
data: {
|
||||||
slug,
|
id,
|
||||||
type: mediaType,
|
slug,
|
||||||
mimeType: file.mimetype,
|
type: mediaType,
|
||||||
size: file.size,
|
mimeType: file.mimetype,
|
||||||
originalFilename: file.originalname,
|
size: file.size,
|
||||||
uploadedBy: req.user!.pubkey,
|
originalFilename: file.originalname,
|
||||||
},
|
uploadedBy: req.user!.pubkey,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
id: media.id,
|
id: media.id,
|
||||||
slug: media.slug,
|
slug: media.slug,
|
||||||
url: `/media/${media.id}`,
|
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) {
|
} catch (err) {
|
||||||
console.error('Upload media error:', err);
|
console.error('Upload media error:', err);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
|||||||
@@ -1,13 +1,47 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
import { prisma } from '../db/prisma';
|
import { prisma } from '../db/prisma';
|
||||||
import { requireAuth, requires } from '../middleware/auth';
|
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 { DEFAULT_ORGANIZER_SLUG } from '../constants/organizer';
|
||||||
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
|
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const meetupInclude = { organizer: true } as const;
|
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 {
|
function incrementTitle(title: string): string {
|
||||||
const match = title.match(/^(.*#)(\d+)(.*)$/);
|
const match = title.match(/^(.*#)(\d+)(.*)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -28,7 +62,8 @@ async function resolveOrganizerIdForCreate(organizerId: unknown): Promise<string
|
|||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const status = req.query.status as string | undefined;
|
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 organizerSlug = req.query.organizerSlug as string | undefined;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
@@ -69,6 +104,16 @@ router.get('/:id', async (req: Request, res: Response) => {
|
|||||||
return;
|
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);
|
res.json(meetup);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Get meetup error:', err);
|
console.error('Get meetup error:', err);
|
||||||
|
|||||||
@@ -32,6 +32,24 @@ const app = express();
|
|||||||
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
|
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
|
||||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
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
|
// Trust the first proxy (nginx) so req.ip returns the real client IP
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
|||||||
@@ -30,17 +30,23 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
|
|||||||
const token = header.slice(7);
|
const token = header.slice(7);
|
||||||
|
|
||||||
// API keys authenticate programmatic clients. They carry their own scoped
|
// 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)) {
|
if (looksLikeApiKey(token)) {
|
||||||
const access = await resolveApiKey(token);
|
try {
|
||||||
if (!access) {
|
const access = await resolveApiKey(token);
|
||||||
res.status(401).json({ error: 'Invalid or revoked API key' });
|
if (!access) {
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,38 @@ export async function GET(req: NextRequest) {
|
|||||||
const upstream = new URL(apiUrl('/nip05'));
|
const upstream = new URL(apiUrl('/nip05'));
|
||||||
if (name) upstream.searchParams.set('name', name);
|
if (name) upstream.searchParams.set('name', name);
|
||||||
|
|
||||||
const res = await fetch(upstream.toString(), { cache: 'no-store' });
|
try {
|
||||||
const data = await res.json();
|
const res = await fetch(upstream.toString(), { cache: 'no-store' });
|
||||||
|
if (!res.ok) {
|
||||||
return NextResponse.json(data, {
|
return NextResponse.json(
|
||||||
headers: {
|
{},
|
||||||
'Access-Control-Allow-Origin': '*',
|
{
|
||||||
'Cache-Control': 'no-store',
|
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,
|
getPublicKey,
|
||||||
signEvent,
|
signEvent,
|
||||||
publishEvent,
|
publishEvent,
|
||||||
shortenPubkey,
|
shortenNpub,
|
||||||
fetchNostrProfile,
|
fetchNostrProfile,
|
||||||
fetchLongformFromRelays,
|
fetchLongformFromRelays,
|
||||||
fetchEventFromRelays,
|
fetchEventFromRelays,
|
||||||
@@ -23,6 +23,7 @@ import { Navbar } from "@/components/public/Navbar";
|
|||||||
import { Footer } from "@/components/public/Footer";
|
import { Footer } from "@/components/public/Footer";
|
||||||
import { markdownComponents } from "./markdownComponents";
|
import { markdownComponents } from "./markdownComponents";
|
||||||
import { remarkNostr } from "./remarkNostr";
|
import { remarkNostr } from "./remarkNostr";
|
||||||
|
import { NostrAuthor } from "./NostrEmbeds";
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,6 +31,7 @@ interface Post {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
|
image?: string;
|
||||||
authorName?: string;
|
authorName?: string;
|
||||||
authorPubkey?: string;
|
authorPubkey?: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
@@ -59,6 +61,7 @@ function postFromEvent(event: any, slug: string): Post {
|
|||||||
title: tag("title") || "Untitled",
|
title: tag("title") || "Untitled",
|
||||||
content: event.content || "",
|
content: event.content || "",
|
||||||
excerpt: tag("summary"),
|
excerpt: tag("summary"),
|
||||||
|
image: tag("image"),
|
||||||
authorPubkey: event.pubkey,
|
authorPubkey: event.pubkey,
|
||||||
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
||||||
nostrEventId: event.id,
|
nostrEventId: event.id,
|
||||||
@@ -117,6 +120,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
||||||
const [liveContent, setLiveContent] = useState<string | null>(null);
|
const [liveContent, setLiveContent] = useState<string | null>(null);
|
||||||
|
const [liveImage, setLiveImage] = useState<string | null>(null);
|
||||||
const [loadingContent, setLoadingContent] = useState(false);
|
const [loadingContent, setLoadingContent] = useState(false);
|
||||||
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
|
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
|
||||||
const [contentError, setContentError] = useState(false);
|
const [contentError, setContentError] = useState(false);
|
||||||
@@ -134,6 +138,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLiveContent(null);
|
setLiveContent(null);
|
||||||
|
setLiveImage(null);
|
||||||
setPost(null);
|
setPost(null);
|
||||||
setAuthorProfile(null);
|
setAuthorProfile(null);
|
||||||
setContentError(false);
|
setContentError(false);
|
||||||
@@ -159,8 +164,14 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
fetchPromise
|
fetchPromise
|
||||||
.then((event) => {
|
.then((event) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (event?.content) setLiveContent(event.content);
|
if (event?.content) {
|
||||||
else setContentError(true); // not found / timed out
|
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))
|
.catch(() => !cancelled && setContentError(true))
|
||||||
.finally(() => !cancelled && setLoadingContent(false));
|
.finally(() => !cancelled && setLoadingContent(false));
|
||||||
@@ -258,6 +269,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
}, [comment, post, hasNostr]);
|
}, [comment, post, hasNostr]);
|
||||||
|
|
||||||
const categories = post?.categories?.map((c) => c.category) || [];
|
const categories = post?.categories?.map((c) => c.category) || [];
|
||||||
|
const headerImage = post?.image || liveImage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -321,7 +333,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-on-surface-variant">
|
<span className="font-medium text-on-surface-variant">
|
||||||
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
|
{authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -338,6 +350,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<article className="mb-16">
|
||||||
{loadingContent ? (
|
{loadingContent ? (
|
||||||
<RelayLoading />
|
<RelayLoading />
|
||||||
@@ -424,9 +447,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
className="bg-surface-container-high rounded-lg p-4"
|
className="bg-surface-container-high rounded-lg p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 mb-2">
|
<div className="flex items-center gap-2.5 mb-2">
|
||||||
<span className="font-semibold text-xs font-mono text-on-surface-variant/70">
|
<NostrAuthor pubkey={r.pubkey} />
|
||||||
{shortenPubkey(r.pubkey)}
|
|
||||||
</span>
|
|
||||||
<span className="text-on-surface-variant/30">·</span>
|
<span className="text-on-surface-variant/30">·</span>
|
||||||
<span className="text-xs text-on-surface-variant/50">
|
<span className="text-xs text-on-surface-variant/50">
|
||||||
{formatDate(new Date(r.created_at * 1000))}
|
{formatDate(new Date(r.created_at * 1000))}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { nip19 } from "nostr-tools";
|
|||||||
import {
|
import {
|
||||||
loadNostrProfile,
|
loadNostrProfile,
|
||||||
resolveEventFromRelays,
|
resolveEventFromRelays,
|
||||||
shortenPubkey,
|
shortenNpub,
|
||||||
type NostrProfile,
|
type NostrProfile,
|
||||||
} from "@/lib/nostr";
|
} from "@/lib/nostr";
|
||||||
|
|
||||||
@@ -39,11 +39,8 @@ export function NostrMention({ bech32 }: { bech32: string }) {
|
|||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
// Show the username; fall back to a shortened npub only when no profile.
|
// Show the username; fall back to a shortened npub only when no profile.
|
||||||
const npub = useMemo(() => {
|
const name =
|
||||||
if (bech32.startsWith("npub")) return bech32;
|
profile?.name || profile?.displayName || shortenNpub(pubkey || bech32);
|
||||||
return pubkey ? nip19.npubEncode(pubkey) : bech32;
|
|
||||||
}, [bech32, pubkey]);
|
|
||||||
const name = profile?.name || profile?.displayName || shortenPubkey(npub);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@@ -67,8 +64,10 @@ export function NostrMention({ bech32 }: { bech32: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact author line used inside an embedded note.
|
// Reusable author line (avatar + username) resolved from the author's kind:0
|
||||||
function NoteAuthor({ pubkey }: { pubkey: string }) {
|
// 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);
|
const [profile, setProfile] = useState<NostrProfile | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -79,21 +78,14 @@ function NoteAuthor({ pubkey }: { pubkey: string }) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
const fallback = useMemo(() => {
|
const name = profile?.name || profile?.displayName || shortenNpub(pubkey);
|
||||||
try {
|
|
||||||
return shortenPubkey(nip19.npubEncode(pubkey));
|
|
||||||
} catch {
|
|
||||||
return shortenPubkey(pubkey);
|
|
||||||
}
|
|
||||||
}, [pubkey]);
|
|
||||||
const name = profile?.name || profile?.displayName || fallback;
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{profile?.picture && (
|
{profile?.picture && (
|
||||||
<img
|
<img
|
||||||
src={profile.picture}
|
src={profile.picture}
|
||||||
alt=""
|
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) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
}}
|
}}
|
||||||
@@ -151,7 +143,7 @@ export function NostrNote({ bech32 }: { bech32: string }) {
|
|||||||
{state === "done" && event && (
|
{state === "done" && event && (
|
||||||
<span className="block">
|
<span className="block">
|
||||||
<span className="flex items-center justify-between mb-3">
|
<span className="flex items-center justify-between mb-3">
|
||||||
<NoteAuthor pubkey={event.pubkey} />
|
<NostrAuthor pubkey={event.pubkey} />
|
||||||
<a
|
<a
|
||||||
href={njump(bech32)}
|
href={njump(bech32)}
|
||||||
target="_blank"
|
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 dynamic = 'force-dynamic';
|
||||||
export const runtime = 'nodejs';
|
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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> | { id: string } }
|
context: { params: Promise<{ id: string }> | { id: string } }
|
||||||
) {
|
) {
|
||||||
const params = await Promise.resolve(context.params);
|
try {
|
||||||
const id = params.id;
|
const params = await Promise.resolve(context.params);
|
||||||
if (!id) {
|
const id = params.id;
|
||||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
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 });
|
|
||||||
}
|
}
|
||||||
return handleImageResize(root, filePath, width, meta, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (meta.type === 'video') {
|
const root = getMediaStorageRoot();
|
||||||
const rangeHeader = request.headers.get('range');
|
const filePath = resolveSafeMediaPath(root, id);
|
||||||
return handleVideoStream(filePath, meta, rangeHeader);
|
if (!filePath || !fileExists(filePath)) {
|
||||||
}
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = fs.readFileSync(filePath);
|
const meta = readMeta(root, id);
|
||||||
return new NextResponse(new Uint8Array(buffer), {
|
if (!meta) {
|
||||||
status: 200,
|
return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
|
||||||
headers: {
|
}
|
||||||
'Content-Type': meta.mimeType,
|
|
||||||
'Content-Length': String(buffer.length),
|
const { searchParams } = new URL(request.url);
|
||||||
...CACHE_HEADERS,
|
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) {
|
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 (
|
return (
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
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 { MapPin, Clock, ArrowRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
||||||
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
|
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 {
|
interface MeetupData {
|
||||||
id?: string;
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -19,6 +27,26 @@ interface MeetupsSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MeetupsSection({ meetups }: 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 (
|
return (
|
||||||
<section className="py-24 px-8 border-t border-zinc-800/50">
|
<section className="py-24 px-8 border-t border-zinc-800/50">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
@@ -59,7 +87,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
|||||||
<Link
|
<Link
|
||||||
key={meetup.id ?? i}
|
key={meetup.id ?? i}
|
||||||
href={href}
|
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="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]">
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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">
|
<div className="md:hidden flex flex-col items-center gap-3 mt-8">
|
||||||
<Link
|
<Link
|
||||||
href="/events"
|
href="/events"
|
||||||
|
|||||||
@@ -82,6 +82,20 @@ export function shortenPubkey(pubkey: string): string {
|
|||||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
|
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 {
|
export interface NostrProfile {
|
||||||
name?: string;
|
name?: string;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const 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: {
|
experimental: {
|
||||||
serverComponentsExternalPackages: ['sharp'],
|
serverComponentsExternalPackages: ['sharp'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "bbe-frontend",
|
"name": "bbe-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "NEXT_DIST_DIR=.next-dev next dev -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"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" }],
|
"plugins": [{ "name": "next" }],
|
||||||
"paths": { "@/*": ["./*"] }
|
"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"]
|
"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