Compare commits

..
7 Commits
Author SHA1 Message Date
bbeandCursor ede8817583 fix: harden deploys and close top security holes after /events outage
Isolate next dev from production .next, add build-guard/atomic deploy/health
watchdog, error boundaries, and fix JWT startup, meetup leaks, media path
traversal, SVG/memory uploads, and JSON-LD escaping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 19:14:57 +02:00
bbeandCursor 495289232b feat: blog header images, npub display, and meetups load more
Show longform image tags on posts, use shortened npubs for author names,
and paginate the homepage meetups section with responsive load more.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 04:36:10 +02:00
bbeandCursor 2ef68222bf feat: resolve live Nostr references in blog posts and add embeds
Support naddr/nevent/note slugs for unindexed posts, cache naddr lookups,
render Nostr embeds in markdown, and add a consistency check for events mirrors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 03:57:32 +02:00
bbeandCursor 6023991f5c feat: add SEO metadata, llms.txt, and markdown page mirrors
Centralize site metadata and social URLs for JSON-LD, expose llmstxt.org
content and per-page .md routes for LLM crawlers, and refactor blog/FAQ/events
pages with shared components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 00:59:41 +02:00
bbeandCursor 99380ef6aa fix: auto-publish approved blog submissions on approval
Approved user submissions now import into the blog automatically, with a
backfill script for existing approvals and a WebSocket polyfill so backend
Nostr relay queries work on Node 20.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 23:38:48 +02:00
bbeandCursor a6a2b113ee feat: add scoped API keys for programmatic site access
Introduce ApiKey model, CRUD endpoints, and admin UI so agents can
authenticate with permission-scoped keys. Normalize pubkeys to hex on login,
dedupe legacy npub/hex user rows, and ignore .cursor in git.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 09:29:30 +02:00
bbeandCursor 70e3e0633d feat: roles/permissions system and Nostr profile display on admin users
Introduce granular role-based permissions with SuperAdmin env override, admin roles UI, and permission-gated API routes. Fix admin user Nostr metadata by batching relay profile fetches, normalizing npub pubkeys to hex, and adding reusable NostrAvatar/useNostrProfile components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 08:46:56 +02:00
103 changed files with 5480 additions and 1201 deletions
+5 -2
View File
@@ -1,5 +1,8 @@
# Admin pubkeys (comma-separated hex pubkeys)
ADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2
# SuperAdmin pubkeys (comma-separated hex or npub). SuperAdmins always have every
# permission, are env-only, and can never be edited or removed through the UI.
# SUPERADMIN_PUBKEYS replaces the older ADMIN_PUBKEYS. If SUPERADMIN_PUBKEYS is
# unset, the app falls back to ADMIN_PUBKEYS so existing deployments keep working.
SUPERADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2
# Nostr relays (comma-separated)
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
+4
View File
@@ -3,6 +3,9 @@ node_modules/
# Next.js
frontend/.next/
frontend/.next-dev/
frontend/.next-build/
frontend/.next-prev/
frontend/out/
# Backend build
@@ -21,6 +24,7 @@ Thumbs.db
# IDE
.idea/
.vscode/
.cursor/
*.swp
*.swo
+41
View File
@@ -0,0 +1,41 @@
# Changelog
## Unreleased
### Roles and permissions system
Replaced the single moderator promote/demote toggle with a full role hierarchy
and a runtime-editable, granular permission system.
- **Role hierarchy**: SuperAdmin (env-only) > Admin > Moderator > Writer > Guest
(absence of a role). SuperAdmin always holds every permission and can never be
edited, demoted, or removed through the UI.
- **New env var `SUPERADMIN_PUBKEYS`** (comma-separated hex or npub) replaces the
old env-admin concept. Falls back to `ADMIN_PUBKEYS` when unset so existing
deployments keep working.
- **Permission registry**: a central list of granular keys in
`backend/src/constants/permissions.ts` is the single source of truth. Adding a
key there surfaces it automatically in the API and the Roles matrix page.
- **Authorization**: a single `requires(permission)` guard replaces the old
role-name middleware and is applied to every protected endpoint. Roles and
permissions are resolved live per request, so a stale JWT no longer drives
access. SuperAdmin bypasses all checks.
- **Data model**: `User.role` is now nullable (null means Guest) and uses the
lowercase enum `admin`, `moderator`, `writer`. A new `RolePermission` table
maps roles to permission keys. A migration backfills legacy uppercase roles and
seeds default permission sets; it is idempotent and safe on a live database.
- **New endpoints**: `GET /api/auth/me`, `GET /api/admin/permissions`,
`GET /api/admin/roles`, `PUT /api/admin/roles/:role/permissions`, and
`PUT /api/users/:pubkey/role` (replacing `POST /api/users/promote` and
`POST /api/users/demote`).
- **Dashboard**: User Management now uses a per-user role selector with hierarchy
enforcement and a locked SuperAdmin badge. A new SuperAdmin-only Roles page
renders a permission-by-role matrix. Nav items are gated on resolved
permissions. The NIP-05 reserved-username assignment is unchanged.
#### Migration notes
After pulling, from `backend` run `npm run migrate:deploy` (or, for databases
created with `db push`, `npm run db:baseline-and-migrate`). Set
`SUPERADMIN_PUBKEYS` in your `.env`. Existing moderators become `moderator`,
existing database admins become `admin`, and prior env admins become SuperAdmin.
+44 -5
View File
@@ -31,7 +31,7 @@ cp .env.example backend/.env
cp .env.example frontend/.env.local
```
Edit `backend/.env` with your admin pubkeys and a secure JWT secret.
Edit `backend/.env` with your SuperAdmin pubkeys (`SUPERADMIN_PUBKEYS`) and a secure JWT secret.
### 3. Set up database
@@ -91,15 +91,54 @@ npm run dev
| PATCH | /api/meetups/:id | Update meetup |
| POST | /api/moderation/hide | Hide content |
| POST | /api/moderation/block | Block pubkey |
| GET | /api/auth/me | Current user's effective role and permissions |
| GET | /api/users | List users |
| POST | /api/users/promote | Promote user |
| PUT | /api/users/:pubkey/role | Assign a role to a user |
| GET | /api/admin/permissions | Permission registry (keys, labels, groups) |
| GET | /api/admin/roles | Each role and its permission set |
| PUT | /api/admin/roles/:role/permissions | Update a role's permissions |
| GET | /api/categories | List categories |
| POST | /api/categories | Create category |
## Roles
## Roles and permissions
- **Admin**: Full access. Defined by pubkeys in `.env`
- **Moderator**: Content moderation. Assigned by admins via dashboard.
Access is controlled by an ordered role hierarchy backed by a runtime-editable,
granular permission system. From highest to lowest:
1. **SuperAdmin** sourced only from the `SUPERADMIN_PUBKEYS` env var. Always has
every permission. Cannot be created, edited, demoted, or removed through the
UI. This replaces the old "admin set in env" concept. If `SUPERADMIN_PUBKEYS`
is unset, the app falls back to the legacy `ADMIN_PUBKEYS` variable.
2. **Admin** highest role assignable through the dashboard.
3. **Moderator**
4. **Writer**
5. **Guest** the fallback for any logged-in pubkey with no assigned role. Guest is
not a stored record, it is simply the absence of a role.
Each assignable role (Admin, Moderator, Writer) maps to a set of permission keys
stored in the `RolePermission` table. SuperAdmins manage these from the dashboard
**Roles** page, which renders a permission-by-role matrix. The dashboard nav and
actions are gated on the current user's resolved permissions, not on role names.
### Safeguards
- SuperAdmin is never assignable through the UI or API. Only the env var grants it.
- A user cannot assign a role at or above their own, or modify a SuperAdmin.
- A user cannot grant a role a permission they do not themselves hold.
- A user cannot remove their own `users.assign_role` or `roles.edit_permissions`.
SuperAdmin (env-sourced) is always the recovery path.
- Unknown role values and permission keys are rejected by the server.
### Adding a new permission key
1. Add an entry to `PERMISSIONS` in
[backend/src/constants/permissions.ts](backend/src/constants/permissions.ts)
with a `key`, `label`, and `group`.
2. The key automatically appears in `GET /admin/permissions` and the Roles matrix
page, so no UI changes are needed.
3. Apply it to the relevant endpoints with the `requires('<key>')` guard.
4. Optionally grant it to roles by default in `DEFAULT_ROLE_PERMISSIONS` (used by
the seed) and in the migration so fresh and existing databases get it.
## License
+34 -1
View File
@@ -20,7 +20,8 @@
"nostr-tools": "^2.10.0",
"slugify": "^1.6.8",
"ulid": "^3.0.2",
"uuid": "^11.0.0"
"uuid": "^11.0.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
@@ -30,6 +31,7 @@
"@types/morgan": "^1.9.10",
"@types/multer": "^2.1.0",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
@@ -807,6 +809,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -2553,6 +2565,27 @@
"engines": {
"node": ">= 8"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+5 -2
View File
@@ -10,7 +10,8 @@
"db:baseline-and-migrate": "bash scripts/baseline-and-migrate.sh",
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy"
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy",
"backfill-submissions": "dotenv -e ../.env -e .env -- tsx scripts/backfill-approved-submissions.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
@@ -28,7 +29,8 @@
"nostr-tools": "^2.10.0",
"slugify": "^1.6.8",
"ulid": "^3.0.2",
"uuid": "^11.0.0"
"uuid": "^11.0.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
@@ -38,6 +40,7 @@
"@types/morgan": "^1.9.10",
"@types/multer": "^2.1.0",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
@@ -0,0 +1,83 @@
-- Roles and permissions system.
-- Makes User.role nullable (null means Guest), backfills legacy uppercase roles
-- to the new lowercase enum, adds the RolePermission table, and seeds default
-- permission sets. Safe and idempotent to run on a live database.
-- RedefineTables: make User.role nullable and drop its default, converting values.
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"pubkey" TEXT NOT NULL,
"role" TEXT,
"displayName" TEXT,
"username" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("id", "pubkey", "role", "displayName", "username", "createdAt", "updatedAt")
SELECT
"id",
"pubkey",
CASE
WHEN "role" = 'ADMIN' THEN 'admin'
WHEN "role" = 'MODERATOR' THEN 'moderator'
WHEN "role" = 'WRITER' THEN 'writer'
WHEN "role" IN ('admin', 'moderator', 'writer') THEN "role"
ELSE NULL
END,
"displayName",
"username",
"createdAt",
"updatedAt"
FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateTable
CREATE TABLE IF NOT EXISTS "RolePermission" (
"role" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("role", "permission")
);
-- Seed default permission sets. INSERT OR IGNORE keeps this idempotent and never
-- clobbers permissions an operator has already customized at runtime.
INSERT OR IGNORE INTO "RolePermission" ("role", "permission") VALUES
('admin', 'events.create'),
('admin', 'events.edit'),
('admin', 'events.delete'),
('admin', 'organizers.manage'),
('admin', 'gallery.upload'),
('admin', 'gallery.delete'),
('admin', 'blog.draft'),
('admin', 'blog.publish'),
('admin', 'blog.delete'),
('admin', 'faq.manage'),
('admin', 'submissions.review'),
('admin', 'board.manage'),
('admin', 'moderation.act'),
('admin', 'categories.manage'),
('admin', 'users.assign_role'),
('admin', 'nip05.assign'),
('admin', 'relays.manage'),
('admin', 'settings.edit'),
('admin', 'nostr_tools.use'),
('moderator', 'events.create'),
('moderator', 'events.edit'),
('moderator', 'events.delete'),
('moderator', 'submissions.review'),
('moderator', 'moderation.act'),
('moderator', 'gallery.upload'),
('moderator', 'board.manage'),
('moderator', 'categories.manage'),
('moderator', 'faq.manage'),
('writer', 'blog.draft'),
('writer', 'gallery.upload'),
('writer', 'events.create');
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"permissions" TEXT NOT NULL,
"createdByPubkey" TEXT NOT NULL,
"lastUsedAt" DATETIME,
"revokedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
+27 -1
View File
@@ -10,13 +10,39 @@ datasource db {
model User {
id String @id @default(uuid())
pubkey String @unique
role String @default("USER") // USER, MODERATOR, ADMIN
role String? // admin, moderator, writer. Null means Guest (no elevated role). SuperAdmin is env-sourced and never stored.
displayName String?
username String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Scoped API key for programmatic access. The raw key is shown to the creator
// only once; only its SHA-256 hash is stored. `permissions` is a JSON array of
// permission keys that gate what the key may do.
model ApiKey {
id String @id @default(uuid())
name String
prefix String // leading characters of the raw key, for display
keyHash String @unique // SHA-256 hex of the full key
permissions String // JSON array of permission keys
createdByPubkey String
lastUsedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
}
// Maps an assignable role to a granted permission key. Editable at runtime.
// One row per (role, permission). SuperAdmin is never stored here; it bypasses
// all checks via the env list.
model RolePermission {
role String
permission String
createdAt DateTime @default(now())
@@id([role, permission])
}
model Organizer {
id String @id @default(uuid())
name String
+13
View File
@@ -1,6 +1,7 @@
import dotenv from 'dotenv';
import path from 'path';
import { PrismaClient } from '@prisma/client';
import { DEFAULT_ROLE_PERMISSIONS } from '../src/constants/permissions';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
@@ -87,6 +88,18 @@ async function main() {
});
}
// Seed default role permissions. This only fills in missing rows so it never
// clobbers permissions an operator has customized at runtime.
for (const [role, permissions] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) {
for (const permission of permissions) {
await prisma.rolePermission.upsert({
where: { role_permission: { role, permission } },
update: {},
create: { role, permission },
});
}
}
console.log('Seed completed successfully.');
}
@@ -0,0 +1,64 @@
// One-time backfill: publish any APPROVED submissions that never got a blog
// Post (they predate auto-publish-on-approval). Idempotent — already-imported
// submissions are skipped, so it is safe to re-run.
//
// Usage (from backend/):
// npm run backfill-submissions
import { prisma } from '../src/db/prisma';
import { importPostFromNostr, resolveSubmissionImport } from '../src/services/postImport';
async function main() {
const approved = await prisma.submission.findMany({
where: { status: 'APPROVED' },
orderBy: { createdAt: 'asc' },
});
console.log(`Found ${approved.length} APPROVED submission(s).`);
let imported = 0;
let skipped = 0;
let failed = 0;
for (const submission of approved) {
const label = `"${submission.title}" (${submission.id})`;
try {
const importInput = await resolveSubmissionImport(submission);
if (!importInput) {
console.warn(` FAILED ${label}: could not resolve Nostr event from relays.`);
failed++;
continue;
}
const existing = await prisma.post.findUnique({
where: { nostrEventId: importInput.nostrEventId },
});
if (existing) {
console.log(` SKIP ${label}: already published (slug: ${existing.slug}).`);
skipped++;
continue;
}
const post = await importPostFromNostr(importInput);
console.log(` IMPORT ${label}: published (slug: ${post?.slug}).`);
imported++;
} catch (err) {
console.error(` FAILED ${label}:`, err);
failed++;
}
}
console.log(`\nDone. Imported: ${imported}, Skipped: ${skipped}, Failed: ${failed}.`);
}
main()
.then(async () => {
await prisma.$disconnect();
// The Nostr relay pool keeps websockets open, which would otherwise keep the
// process alive after the work is done.
process.exit(0);
})
.catch(async (err) => {
console.error('Backfill error:', err);
await prisma.$disconnect();
process.exit(1);
});
+2 -2
View File
@@ -1,10 +1,10 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
router.use(requireAuth, requireRole(['ADMIN', 'MODERATOR']));
router.use(requireAuth, requires('board.manage'));
router.get('/', async (_req: Request, res: Response) => {
try {
+150
View File
@@ -0,0 +1,150 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import { generateApiKey } from '../services/apiKeys';
import { isValidPermissionKey } from '../constants/permissions';
const router = Router();
function serialize(key: {
id: string;
name: string;
prefix: string;
permissions: string;
createdByPubkey: string;
lastUsedAt: Date | null;
revokedAt: Date | null;
createdAt: Date;
}) {
let permissions: string[] = [];
try {
const parsed = JSON.parse(key.permissions);
if (Array.isArray(parsed)) permissions = parsed.filter((p): p is string => typeof p === 'string');
} catch {
permissions = [];
}
return {
id: key.id,
name: key.name,
prefix: key.prefix,
permissions,
createdByPubkey: key.createdByPubkey,
lastUsedAt: key.lastUsedAt,
revokedAt: key.revokedAt,
createdAt: key.createdAt,
};
}
router.get(
'/',
requireAuth,
requires('api_keys.manage'),
async (_req: Request, res: Response) => {
try {
const keys = await prisma.apiKey.findMany({ orderBy: { createdAt: 'desc' } });
res.json(keys.map(serialize));
} catch (err) {
console.error('List API keys error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/',
requireAuth,
requires('api_keys.manage'),
async (req: Request, res: Response) => {
try {
const { name, permissions } = req.body as { name?: string; permissions?: unknown };
const trimmedName = typeof name === 'string' ? name.trim() : '';
if (!trimmedName) {
res.status(400).json({ error: 'name is required' });
return;
}
if (trimmedName.length > 100) {
res.status(400).json({ error: 'name must be 100 characters or fewer' });
return;
}
if (!Array.isArray(permissions) || permissions.length === 0) {
res.status(400).json({ error: 'At least one permission is required' });
return;
}
const requested = [...new Set(permissions.filter((p): p is string => typeof p === 'string'))];
const invalid = requested.filter((p) => !isValidPermissionKey(p));
if (invalid.length > 0) {
res.status(400).json({ error: `Unknown permissions: ${invalid.join(', ')}` });
return;
}
// A key can never grant more than its creator holds. SuperAdmins hold all.
const caller = req.access!;
if (!caller.isSuperAdmin) {
const exceeded = requested.filter((p) => !caller.permissions.has(p));
if (exceeded.length > 0) {
res
.status(403)
.json({ error: `You cannot grant permissions you do not hold: ${exceeded.join(', ')}` });
return;
}
}
const { rawKey, prefix, keyHash } = generateApiKey();
const created = await prisma.apiKey.create({
data: {
name: trimmedName,
prefix,
keyHash,
permissions: JSON.stringify(requested),
createdByPubkey: req.user!.pubkey,
},
});
// The raw key is returned exactly once and never stored in plaintext.
res.status(201).json({ ...serialize(created), key: rawKey });
} catch (err) {
console.error('Create API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requires('api_keys.manage'),
async (req: Request, res: Response) => {
try {
const idRaw = req.params.id;
const id = typeof idRaw === 'string' ? idRaw : Array.isArray(idRaw) ? idRaw[0] : '';
if (!id) {
res.status(400).json({ error: 'id is required' });
return;
}
const existing = await prisma.apiKey.findUnique({ where: { id } });
if (!existing) {
res.status(404).json({ error: 'API key not found' });
return;
}
if (existing.revokedAt) {
res.json(serialize(existing));
return;
}
const revoked = await prisma.apiKey.update({
where: { id },
data: { revokedAt: new Date() },
});
res.json(serialize(revoked));
} catch (err) {
console.error('Revoke API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;
+44 -7
View File
@@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express';
import { authService } from '../services/auth';
import { prisma } from '../db/prisma';
import { requireAuth } from '../middleware/auth';
import { normalizePubkey } from '../services/pubkey';
const router = Router();
@@ -34,20 +36,55 @@ router.post('/verify', async (req: Request, res: Response) => {
return;
}
const role = await authService.getRole(pubkey);
// Ensure a user row exists, but never overwrite an existing role on login.
// The role is managed only through role assignment, and SuperAdmin is env-sourced.
// Store the pubkey as hex so the same identity is never duplicated as npub.
const hexPubkey = normalizePubkey(pubkey);
const dbUser = await prisma.user.upsert({
where: { pubkey },
update: { role },
create: { pubkey, role },
where: { pubkey: hexPubkey },
update: {},
create: { pubkey: hexPubkey },
});
const token = authService.generateToken(pubkey, role);
res.json({ token, user: { pubkey, role, username: dbUser.username ?? undefined } });
const access = await authService.resolveAccess(pubkey);
// The token carries a display role for convenience only. Authorization is
// always resolved live from the env list and the database.
const token = authService.generateToken(pubkey, access.role);
res.json({
token,
user: {
pubkey,
role: access.role,
isSuperAdmin: access.isSuperAdmin,
permissions: [...access.permissions],
username: dbUser.username ?? undefined,
},
});
} catch (err) {
console.error('Verify error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Returns the current user's live effective role and permission set, for
// frontend gating. Identity comes from the JWT, authorization is resolved fresh.
router.get('/me', requireAuth, async (req: Request, res: Response) => {
try {
const pubkey = req.user!.pubkey;
const access = await authService.resolveAccess(pubkey);
const dbUser = await prisma.user.findUnique({ where: { pubkey } });
res.json({
pubkey,
role: access.role,
isSuperAdmin: access.isSuperAdmin,
permissions: [...access.permissions],
username: dbUser?.username ?? undefined,
});
} catch (err) {
console.error('Me error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+6 -1
View File
@@ -91,7 +91,12 @@ router.get('/ics', async (_req: Request, res: Response) => {
const cutoff = sevenDaysAgo.toISOString().slice(0, 10);
const meetups = await prisma.meetup.findMany({
where: { date: { gte: cutoff } },
// Public subscription feed — never expose HIDDEN or unpublished meetups.
where: {
date: { gte: cutoff },
visibility: 'PUBLIC',
status: 'PUBLISHED',
},
orderBy: { date: 'asc' },
include: { organizer: true },
});
+4 -4
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -19,7 +19,7 @@ router.get('/', async (_req: Request, res: Response) => {
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('categories.manage'),
async (req: Request, res: Response) => {
try {
const { name, slug, sortOrder } = req.body;
@@ -47,7 +47,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('categories.manage'),
async (req: Request, res: Response) => {
try {
const category = await prisma.category.findUnique({
@@ -80,7 +80,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('categories.manage'),
async (req: Request, res: Response) => {
try {
const category = await prisma.category.findUnique({
+6 -6
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -23,7 +23,7 @@ router.get('/', async (req: Request, res: Response) => {
router.get(
'/all',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (_req: Request, res: Response) => {
try {
const faqs = await prisma.faq.findMany({
@@ -41,7 +41,7 @@ router.get(
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const { question, answer, showOnHomepage } = req.body;
@@ -75,7 +75,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } });
@@ -107,7 +107,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } });
@@ -129,7 +129,7 @@ router.delete(
router.post(
'/reorder',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const { items } = req.body as { items: { id: string; order: number }[] };
+45 -28
View File
@@ -5,7 +5,7 @@ import fs from 'fs';
import { ulid } from 'ulid';
import slugify from 'slugify';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const REPO_ROOT = path.resolve(__dirname, '../../..');
const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH
@@ -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];
@@ -43,7 +54,7 @@ const router = Router();
router.post(
'/upload',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('gallery.upload'),
upload.single('file'),
async (req: Request, res: Response) => {
try {
@@ -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' });
@@ -142,7 +159,7 @@ router.get('/:id', async (req: Request, res: Response) => {
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('gallery.upload'),
async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
@@ -180,7 +197,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('gallery.delete'),
async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
+52 -7
View File
@@ -1,13 +1,47 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } 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 { 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);
@@ -80,7 +125,7 @@ router.get('/:id', async (req: Request, res: Response) => {
router.post(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('events.create'),
async (req: Request, res: Response) => {
try {
const {
@@ -138,7 +183,7 @@ router.post(
router.post(
'/bulk',
requireAuth,
requireRole(['ADMIN']),
requires('events.edit'),
async (req: Request, res: Response) => {
try {
const { action, ids } = req.body as { action: string; ids: string[] };
@@ -200,7 +245,7 @@ router.post(
router.post(
'/:id/duplicate',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('events.create'),
async (req: Request, res: Response) => {
try {
const original = await prisma.meetup.findUnique({ where: { id: req.params.id as string } });
@@ -237,7 +282,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('events.edit'),
async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
@@ -292,7 +337,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('events.delete'),
async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
+7 -7
View File
@@ -1,13 +1,13 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
router.get(
'/hidden',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (_req: Request, res: Response) => {
try {
const hidden = await prisma.hiddenContent.findMany({
@@ -24,7 +24,7 @@ router.get(
router.post(
'/hide',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const { nostrEventId, reason } = req.body;
@@ -52,7 +52,7 @@ router.post(
router.delete(
'/unhide/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const item = await prisma.hiddenContent.findUnique({
@@ -75,7 +75,7 @@ router.delete(
router.get(
'/blocked',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (_req: Request, res: Response) => {
try {
const blocked = await prisma.blockedPubkey.findMany({
@@ -92,7 +92,7 @@ router.get(
router.post(
'/block',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const { pubkey, reason } = req.body;
@@ -120,7 +120,7 @@ router.post(
router.delete(
'/unblock/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const item = await prisma.blockedPubkey.findUnique({
+4 -4
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { nostrService } from '../services/nostr';
const router = Router();
@@ -8,7 +8,7 @@ const router = Router();
router.post(
'/fetch',
requireAuth,
requireRole(['ADMIN']),
requires('nostr_tools.use'),
async (req: Request, res: Response) => {
try {
const { eventId, naddr } = req.body;
@@ -40,7 +40,7 @@ router.post(
router.post(
'/cache/refresh',
requireAuth,
requireRole(['ADMIN']),
requires('nostr_tools.use'),
async (_req: Request, res: Response) => {
try {
const cachedEvents = await prisma.nostrEventCache.findMany();
@@ -62,7 +62,7 @@ router.post(
router.get(
'/debug/:eventId',
requireAuth,
requireRole(['ADMIN']),
requires('nostr_tools.use'),
async (req: Request, res: Response) => {
try {
const cached = await prisma.nostrEventCache.findUnique({
+4 -4
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
@@ -38,7 +38,7 @@ router.get('/by-slug/:slug', async (req: Request, res: Response) => {
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('organizers.manage'),
async (req: Request, res: Response) => {
try {
const { name, slug } = req.body;
@@ -66,7 +66,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('organizers.manage'),
async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
@@ -102,7 +102,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('organizers.manage'),
async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
+120 -71
View File
@@ -1,10 +1,89 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { authService, type ResolvedAccess } from '../services/auth';
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
import { nostrService } from '../services/nostr';
import { importPostFromNostr } from '../services/postImport';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const router = Router();
// Resolves access for an optional Bearer token (JWT or API key) without
// rejecting unauthenticated callers, so listing endpoints can stay public while
// still recognizing staff who pass `?all=true`.
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;
}
}
// Returns true when the requester is authenticated and may see hidden posts.
async function canViewHiddenPosts(req: Request): Promise<boolean> {
const access = await resolveOptionalAccess(req);
return !!access && (access.isSuperAdmin || access.permissions.has('blog.draft'));
}
// Resolves a blog slug to the Nostr event id used for reactions/replies,
// whether it's an indexed post or a live NIP-19 reference. Returns null if
// neither resolves.
async function resolveEventIdForSlug(slug: string): Promise<string | null> {
const post = await prisma.post.findUnique({ where: { slug } });
if (post) return post.nostrEventId;
const event = await nostrService.resolveEventByIdentifier(slug);
return event?.id ?? null;
}
function tagValue(event: { tags?: string[][] }, name: string): string | undefined {
return event.tags?.find((t) => t[0] === name)?.[1];
}
// Shapes a live Nostr longform event into the same JSON the frontend expects
// from an indexed Post, so naddr/nevent/note links render through the regular
// post template instead of 404ing. `identifier` is the original URL segment and
// becomes the slug so canonical URLs and reaction/reply lookups stay stable.
function buildPostShapeFromEvent(
event: { id: string; pubkey: string; content: string; created_at: number; tags?: string[][] },
identifier: string
) {
const title = tagValue(event, 'title') || 'Untitled';
const summary = tagValue(event, 'summary') || null;
const image = tagValue(event, 'image') || null;
const publishedAtSec = Number(tagValue(event, 'published_at')) || event.created_at;
const categories = (event.tags || [])
.filter((t) => t[0] === 't' && t[1])
.map((t) => ({ category: { id: t[1], name: t[1], slug: t[1] } }));
return {
id: event.id,
nostrEventId: event.id,
naddr: identifier.startsWith('naddr') ? identifier : null,
title,
slug: identifier,
content: event.content || '',
excerpt: summary,
image,
authorPubkey: event.pubkey,
authorName: null,
featured: false,
visible: true,
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
createdAt: new Date(event.created_at * 1000).toISOString(),
categories,
};
}
router.get('/', async (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
@@ -12,7 +91,10 @@ router.get('/', async (req: Request, res: Response) => {
const category = req.query.category as string | undefined;
const skip = (page - 1) * limit;
const where: any = { visible: true };
const includeHidden = req.query.all === 'true' && (await canViewHiddenPosts(req));
const where: any = {};
if (!includeHidden) where.visible = true;
if (category) {
where.categories = {
some: { category: { slug: category } },
@@ -34,6 +116,7 @@ router.get('/', async (req: Request, res: Response) => {
res.json({
posts,
total,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
});
} catch (err) {
@@ -44,19 +127,31 @@ router.get('/', async (req: Request, res: Response) => {
router.get('/:slug', async (req: Request, res: Response) => {
try {
const slug = req.params.slug as string;
const post = await prisma.post.findUnique({
where: { slug: req.params.slug as string },
where: { slug },
include: {
categories: { include: { category: true } },
},
});
if (!post) {
res.status(404).json({ error: 'Post not found' });
if (post) {
// Indexed posts store an empty body (the canonical copy lives on Nostr);
// the frontend hydrates the body live from relays. Return as-is.
res.json(post);
return;
}
res.json(post);
// Not indexed: resolve the slug as a live NIP-19 reference so the page has
// real metadata (title, summary, image) for SEO instead of "Post Not
// Found". The frontend still fetches the body from relays for display.
const event = await nostrService.resolveEventByIdentifier(slug);
if (event) {
res.json(buildPostShapeFromEvent(event, slug));
return;
}
res.status(404).json({ error: 'Post not found' });
} catch (err) {
console.error('Get post error:', err);
res.status(500).json({ error: 'Internal server error' });
@@ -66,7 +161,7 @@ router.get('/:slug', async (req: Request, res: Response) => {
router.post(
'/import',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('blog.draft'),
async (req: Request, res: Response) => {
try {
const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags } = req.body;
@@ -76,62 +171,14 @@ router.post(
return;
}
const slugBase = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
const post = await prisma.post.upsert({
where: { nostrEventId },
update: {
title,
excerpt: excerpt || undefined,
naddr: naddr || undefined,
},
create: {
nostrEventId,
naddr: naddr || null,
title,
slug,
excerpt: excerpt || null,
authorPubkey,
publishedAt: publishedAt
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
: new Date(),
},
});
if (Array.isArray(tags) && tags.length > 0) {
const categoryIds: string[] = [];
for (const tag of tags as string[]) {
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
if (!catSlug) continue;
const category = await prisma.category.upsert({
where: { slug: catSlug },
update: {},
create: {
name: tag.charAt(0).toUpperCase() + tag.slice(1),
slug: catSlug,
},
});
categoryIds.push(category.id);
}
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
if (categoryIds.length > 0) {
await prisma.postCategory.createMany({
data: categoryIds.map((categoryId) => ({
postId: post.id,
categoryId,
})),
});
}
}
const result = await prisma.post.findUnique({
where: { id: post.id },
include: { categories: { include: { category: true } } },
const result = await importPostFromNostr({
nostrEventId,
naddr,
title,
excerpt,
authorPubkey,
publishedAt,
tags,
});
res.json(result);
@@ -145,7 +192,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('blog.draft'),
async (req: Request, res: Response) => {
try {
const { title, slug, excerpt, featured, visible, categories } = req.body;
@@ -195,13 +242,14 @@ router.patch(
router.get('/:slug/reactions', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
if (!post) {
const slug = req.params.slug as string;
const eventId = await resolveEventIdForSlug(slug);
if (!eventId) {
res.status(404).json({ error: 'Post not found' });
return;
}
const reactions = await nostrService.fetchReactions(post.nostrEventId);
const reactions = await nostrService.fetchReactions(eventId);
res.json({ count: reactions.length, reactions });
} catch (err) {
console.error('Get reactions error:', err);
@@ -211,14 +259,15 @@ router.get('/:slug/reactions', async (req: Request, res: Response) => {
router.get('/:slug/replies', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
if (!post) {
const slug = req.params.slug as string;
const eventId = await resolveEventIdForSlug(slug);
if (!eventId) {
res.status(404).json({ error: 'Post not found' });
return;
}
const [replies, hiddenContent, blockedPubkeys] = await Promise.all([
nostrService.fetchReplies(post.nostrEventId),
nostrService.fetchReplies(eventId),
prisma.hiddenContent.findMany({ select: { nostrEventId: true } }),
prisma.blockedPubkey.findMany({ select: { pubkey: true } }),
]);
@@ -240,7 +289,7 @@ router.get('/:slug/replies', async (req: Request, res: Response) => {
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('blog.delete'),
async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { id: req.params.id as string } });
+6 -6
View File
@@ -1,7 +1,7 @@
import { Router, Request, Response } from 'express';
import { SimplePool } from 'nostr-tools';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -24,7 +24,7 @@ router.get(
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (_req: Request, res: Response) => {
try {
const relays = await prisma.relay.findMany({
@@ -41,7 +41,7 @@ router.get(
router.post(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const { url, priority } = req.body;
@@ -68,7 +68,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
@@ -101,7 +101,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
@@ -124,7 +124,7 @@ router.delete(
router.post(
'/:id/test',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
+128
View File
@@ -0,0 +1,128 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import {
PERMISSIONS,
ASSIGNABLE_ROLES,
isAssignableRole,
isValidPermissionKey,
} from '../constants/permissions';
const router = Router();
// Returns the full permission registry plus the assignable roles. Adding a key
// to the registry surfaces it here automatically.
router.get(
'/permissions',
requireAuth,
requires('roles.edit_permissions'),
async (_req: Request, res: Response) => {
res.json({
permissions: PERMISSIONS,
roles: ASSIGNABLE_ROLES,
});
}
);
// Returns each assignable role with its current permission set.
router.get(
'/roles',
requireAuth,
requires('roles.edit_permissions'),
async (_req: Request, res: Response) => {
try {
const rows = await prisma.rolePermission.findMany();
const byRole: Record<string, string[]> = {};
for (const role of ASSIGNABLE_ROLES) byRole[role] = [];
for (const row of rows) {
if (byRole[row.role]) byRole[row.role].push(row.permission);
}
res.json({
roles: ASSIGNABLE_ROLES.map((role) => ({ role, permissions: byRole[role] })),
});
} catch (err) {
console.error('List roles error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Replaces a role's permission set. Validates the role and every key, prevents
// privilege escalation, and prevents the caller from locking themselves out.
router.put(
'/roles/:role/permissions',
requireAuth,
requires('roles.edit_permissions'),
async (req: Request, res: Response) => {
try {
const roleRaw = req.params.role;
const role = typeof roleRaw === 'string' ? roleRaw : Array.isArray(roleRaw) ? roleRaw[0] : '';
if (!isAssignableRole(role)) {
res.status(400).json({ error: 'Invalid role' });
return;
}
const { permissions } = req.body as { permissions?: unknown };
if (!Array.isArray(permissions) || permissions.some((p) => typeof p !== 'string')) {
res.status(400).json({ error: 'permissions must be an array of strings' });
return;
}
const requested = [...new Set(permissions as string[])];
const unknown = requested.filter((p) => !isValidPermissionKey(p));
if (unknown.length > 0) {
res.status(400).json({ error: `Unknown permission keys: ${unknown.join(', ')}` });
return;
}
const caller = req.access!;
const newSet = new Set(requested);
const existingRows = await prisma.rolePermission.findMany({ where: { role } });
const currentSet = new Set(existingRows.map((r) => r.permission));
// No privilege escalation: any newly granted permission must be held by the
// caller. SuperAdmin holds everything and is exempt.
if (!caller.isSuperAdmin) {
const added = requested.filter((p) => !currentSet.has(p));
const escalated = added.filter((p) => !caller.permissions.has(p));
if (escalated.length > 0) {
res.status(403).json({
error: `You cannot grant permissions you do not hold: ${escalated.join(', ')}`,
});
return;
}
// Prevent self-lockout: when editing your own role, you cannot drop the
// permissions that keep you able to manage roles and users.
if (caller.role === role) {
const protectedKeys = ['users.assign_role', 'roles.edit_permissions'];
const lockedOut = protectedKeys.filter(
(k) => caller.permissions.has(k) && !newSet.has(k)
);
if (lockedOut.length > 0) {
res.status(403).json({
error: `You cannot remove your own ${lockedOut.join(', ')}`,
});
return;
}
}
}
// Replace the role's permission rows transactionally.
await prisma.$transaction([
prisma.rolePermission.deleteMany({ where: { role } }),
prisma.rolePermission.createMany({
data: requested.map((permission) => ({ role, permission })),
}),
]);
res.json({ role, permissions: requested });
} catch (err) {
console.error('Update role permissions error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;
+3 -3
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -18,7 +18,7 @@ const PUBLIC_SETTINGS = [
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('settings.edit'),
async (req: Request, res: Response) => {
try {
const settings = await prisma.setting.findMany();
@@ -53,7 +53,7 @@ router.get('/public', async (_req: Request, res: Response) => {
router.patch(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('settings.edit'),
async (req: Request, res: Response) => {
try {
const { key, value } = req.body;
+21 -4
View File
@@ -1,6 +1,7 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { importPostFromNostr, resolveSubmissionImport } from '../services/postImport';
const router = Router();
@@ -53,7 +54,7 @@ router.get(
router.get(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('submissions.review'),
async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
@@ -76,7 +77,7 @@ router.get(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('submissions.review'),
async (req: Request, res: Response) => {
try {
const { status, reviewNote } = req.body;
@@ -94,6 +95,22 @@ router.patch(
return;
}
// Approval publishes the referenced Nostr article to the blog. Do this
// before flipping the status so a failed import doesn't leave a submission
// marked APPROVED without a corresponding live post.
let post = null;
if (status === 'APPROVED') {
const importInput = await resolveSubmissionImport(submission);
if (!importInput) {
res.status(422).json({
error:
'Could not resolve the submitted Nostr event from relays. The post was not published, so the submission was left unchanged.',
});
return;
}
post = await importPostFromNostr(importInput);
}
const updated = await prisma.submission.update({
where: { id: req.params.id as string },
data: {
@@ -103,7 +120,7 @@ router.patch(
},
});
res.json(updated);
res.json({ submission: updated, post });
} catch (err) {
console.error('Review submission error:', err);
res.status(500).json({ error: 'Internal server error' });
+140 -30
View File
@@ -1,6 +1,13 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { isSuperadmin } from '../services/auth';
import { toHexPubkey, normalizePubkey } from '../services/pubkey';
import {
ROLE_RANK,
isAssignableRole,
type EffectiveRole,
} from '../constants/permissions';
import fs from 'fs';
import path from 'path';
@@ -36,18 +43,61 @@ function validateUsername(
return null;
}
function pickHigherRole(a: string | null, b: string | null): string | null {
const rankOf = (r: string | null): number =>
r && isAssignableRole(r) ? ROLE_RANK[r as EffectiveRole] : 0;
return rankOf(a) >= rankOf(b) ? a : b;
}
const router = Router();
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('users.assign_role'),
async (_req: Request, res: Response) => {
try {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(users);
// The same person can have been stored both as hex and as npub (no
// normalization existed historically). Collapse those into a single
// identity keyed by hex so the list never shows a duplicate row.
const byHex = new Map<string, (typeof users)[number]>();
for (const u of users) {
const hex = toHexPubkey(u.pubkey) ?? u.pubkey;
const existing = byHex.get(hex);
if (!existing) {
byHex.set(hex, { ...u, pubkey: hex });
continue;
}
// Merge: prefer a stored role over none, the higher-ranked role on
// conflict, and the first available username. Keep the earliest join.
const mergedRole = pickHigherRole(existing.role, u.role);
const mergedUsername = existing.username ?? u.username;
const earliest = existing.createdAt <= u.createdAt ? existing : u;
byHex.set(hex, {
...existing,
role: mergedRole,
username: mergedUsername,
displayName: existing.displayName ?? u.displayName,
createdAt: earliest.createdAt,
pubkey: hex,
});
}
// Flag env-sourced SuperAdmins so the UI can render them locked. Their
// effective role is reported as superadmin regardless of any stored value.
const result = [...byHex.values()].map((u) => {
const superAdmin = isSuperadmin(u.pubkey);
return {
...u,
role: superAdmin ? 'superadmin' : (u.role ?? null),
isSuperAdmin: superAdmin,
};
});
res.json(result);
} catch (err) {
console.error('List users error:', err);
res.status(500).json({ error: 'Internal server error' });
@@ -55,53 +105,109 @@ router.get(
}
);
// Creates a user row from an npub or hex pubkey so admins can pre-register
// people before they have logged in. The pubkey is normalized to hex.
router.post(
'/promote',
'/',
requireAuth,
requireRole(['ADMIN']),
requires('users.assign_role'),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey) {
const { pubkey: rawPubkey } = req.body as { pubkey?: string };
if (!rawPubkey || typeof rawPubkey !== 'string') {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const user = await prisma.user.upsert({
where: { pubkey },
update: { role: 'MODERATOR' },
create: { pubkey, role: 'MODERATOR' },
});
const hex = toHexPubkey(rawPubkey);
if (!hex) {
res.status(400).json({ error: 'Invalid pubkey. Provide an npub or 64-char hex key.' });
return;
}
res.json(user);
const existing = await prisma.user.findUnique({ where: { pubkey: hex } });
if (existing) {
res.status(409).json({ error: 'User already exists' });
return;
}
const user = await prisma.user.create({ data: { pubkey: hex } });
res.status(201).json({ ...user, role: user.role ?? null, isSuperAdmin: isSuperadmin(hex) });
} catch (err) {
console.error('Promote user error:', err);
console.error('Create user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/demote',
// Assigns a role to a user, replacing the old promote/demote toggle. Enforces the
// hierarchy: SuperAdmins are never modifiable here, and a non-SuperAdmin caller
// cannot assign a role at or above their own or modify anyone at or above them.
router.put(
'/:pubkey/role',
requireAuth,
requireRole(['ADMIN']),
requires('users.assign_role'),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey) {
const pubkeyRaw = req.params.pubkey;
const pubkeyParam =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkeyParam) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const pubkey = normalizePubkey(pubkeyParam);
const user = await prisma.user.upsert({
const { role } = req.body as { role?: string | null };
// Normalize the requested role. Null, empty, or "guest" all mean remove
// any elevated role. SuperAdmin can never be assigned here.
let newRole: EffectiveRole;
if (role === null || role === undefined || role === '' || role === 'guest') {
newRole = 'guest';
} else if (isAssignableRole(role)) {
newRole = role;
} else {
res.status(400).json({ error: 'Invalid role' });
return;
}
if (isSuperadmin(pubkey)) {
res.status(403).json({ error: 'SuperAdmins cannot be modified' });
return;
}
const caller = req.access!;
const callerRank = ROLE_RANK[caller.role];
const newRank = ROLE_RANK[newRole];
const target = await prisma.user.findUnique({ where: { pubkey } });
const targetCurrent: EffectiveRole = isAssignableRole(target?.role)
? (target!.role as EffectiveRole)
: 'guest';
const targetRank = ROLE_RANK[targetCurrent];
if (!caller.isSuperAdmin) {
if (newRank >= callerRank) {
res.status(403).json({ error: 'You cannot assign a role at or above your own' });
return;
}
if (targetRank >= callerRank) {
res.status(403).json({ error: 'You cannot modify a user at or above your own role' });
return;
}
}
const storedRole = newRole === 'guest' ? null : newRole;
const updated = await prisma.user.upsert({
where: { pubkey },
update: { role: 'USER' },
create: { pubkey, role: 'USER' },
update: { role: storedRole },
create: { pubkey, role: storedRole },
});
res.json(user);
res.json({ ...updated, role: updated.role ?? null, isSuperAdmin: false });
} catch (err) {
console.error('Demote user error:', err);
console.error('Assign role error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
@@ -182,16 +288,17 @@ router.patch(
router.patch(
'/:pubkey',
requireAuth,
requireRole(['ADMIN']),
requires('nip05.assign'),
async (req: Request, res: Response) => {
try {
const pubkeyRaw = req.params.pubkey;
const pubkey =
const pubkeyParam =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkey) {
if (!pubkeyParam) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const hex = normalizePubkey(pubkeyParam);
const { username } = req.body;
const normalized = (username as string || '').trim().toLowerCase();
@@ -202,7 +309,10 @@ router.patch(
return;
}
const target = await prisma.user.findUnique({ where: { pubkey } });
// Tolerate legacy rows still stored as npub by matching either form.
const target = await prisma.user.findFirst({
where: { OR: [{ pubkey: hex }, { pubkey: pubkeyParam }] },
});
if (!target) {
res.status(404).json({ error: 'User not found' });
return;
@@ -211,7 +321,7 @@ router.patch(
const existing = await prisma.user.findFirst({
where: {
username: { equals: normalized },
NOT: { pubkey },
NOT: { pubkey: target.pubkey },
},
});
@@ -221,7 +331,7 @@ router.patch(
}
const user = await prisma.user.update({
where: { pubkey },
where: { pubkey: target.pubkey },
data: { username: normalized },
});
+101
View File
@@ -0,0 +1,101 @@
// Central permission registry. This is the single source of truth for every
// granular permission key in the dashboard. Adding a key here automatically
// surfaces it in the GET /admin/permissions endpoint and the Roles matrix UI.
// To add a new permission: add an entry to PERMISSIONS below, then (optionally)
// grant it to roles by default in DEFAULT_ROLE_PERMISSIONS and the migration.
export type PermissionKey = string;
export interface PermissionDef {
key: PermissionKey;
label: string;
group: string;
}
// Ordered list of every permission, grouped by feature area for the matrix UI.
export const PERMISSIONS: PermissionDef[] = [
{ key: 'events.create', label: 'Create events', group: 'Events' },
{ key: 'events.edit', label: 'Edit events', group: 'Events' },
{ key: 'events.delete', label: 'Delete events', group: 'Events' },
{ key: 'organizers.manage', label: 'Manage organizers', group: 'Organizers' },
{ key: 'gallery.upload', label: 'Upload media', group: 'Gallery' },
{ key: 'gallery.delete', label: 'Delete media', group: 'Gallery' },
{ key: 'blog.draft', label: 'Draft and edit posts', group: 'Blog' },
{ key: 'blog.publish', label: 'Publish posts', group: 'Blog' },
{ key: 'blog.delete', label: 'Delete posts', group: 'Blog' },
{ key: 'faq.manage', label: 'Manage FAQ', group: 'FAQ' },
{ key: 'submissions.review', label: 'Review submissions', group: 'Submissions' },
{ key: 'board.manage', label: 'Moderate the message board', group: 'Board' },
{ key: 'moderation.act', label: 'Hide content and block pubkeys', group: 'Moderation' },
{ key: 'categories.manage', label: 'Manage categories', group: 'Categories' },
{ key: 'users.assign_role', label: 'Assign user roles', group: 'Users' },
{ key: 'nip05.assign', label: 'Assign NIP-05 usernames', group: 'Users' },
{ key: 'relays.manage', label: 'Manage relays', group: 'Relays' },
{ key: 'settings.edit', label: 'Edit site settings', group: 'Settings' },
{ key: 'roles.edit_permissions', label: 'Edit roles and permissions', group: 'Roles' },
{ key: 'api_keys.manage', label: 'Create and manage API keys', group: 'API Keys' },
{ key: 'nostr_tools.use', label: 'Use Nostr tools', group: 'Nostr Tools' },
];
// Assignable roles, highest to lowest. SuperAdmin is env-sourced and never stored
// or assignable, so it is not part of this list. Guest is the absence of a role.
export const ASSIGNABLE_ROLES = ['admin', 'moderator', 'writer'] as const;
export type AssignableRole = (typeof ASSIGNABLE_ROLES)[number];
export type EffectiveRole = 'superadmin' | AssignableRole | 'guest';
// Hierarchy ranks used for safeguard comparisons.
export const ROLE_RANK: Record<EffectiveRole, number> = {
superadmin: 4,
admin: 3,
moderator: 2,
writer: 1,
guest: 0,
};
export const ALL_PERMISSION_KEYS: ReadonlySet<PermissionKey> = new Set(
PERMISSIONS.map((p) => p.key)
);
export function isValidPermissionKey(key: string): boolean {
return ALL_PERMISSION_KEYS.has(key);
}
export function isAssignableRole(role: unknown): role is AssignableRole {
return typeof role === 'string' && (ASSIGNABLE_ROLES as readonly string[]).includes(role);
}
// Default permission sets seeded per role. Admin gets everything except the
// roles editor and API key management, which stay SuperAdmin-only by default.
// Guest gets nothing and is therefore not represented here.
export const DEFAULT_ROLE_PERMISSIONS: Record<AssignableRole, PermissionKey[]> = {
admin: PERMISSIONS.map((p) => p.key).filter(
(k) => k !== 'roles.edit_permissions' && k !== 'api_keys.manage'
),
moderator: [
'events.create',
'events.edit',
'events.delete',
'submissions.review',
'moderation.act',
'gallery.upload',
'board.manage',
'categories.manage',
'faq.manage',
],
writer: ['blog.draft', 'gallery.upload', 'events.create'],
};
+22
View File
@@ -25,11 +25,31 @@ import nip05Router from './api/nip05';
import messagesRouter from './api/messages';
import adminMessagesRouter from './api/adminMessages';
import userRelaysRouter from './api/userRelays';
import rolesRouter from './api/roles';
import apiKeysRouter from './api/apiKeys';
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);
@@ -55,6 +75,8 @@ app.use('/api/calendar', calendarRouter);
app.use('/api/nip05', nip05Router);
app.use('/api/messages', messagesRouter);
app.use('/api/admin/messages', adminMessagesRouter);
app.use('/api/admin', rolesRouter);
app.use('/api/api-keys', apiKeysRouter);
app.use('/api/user-relays', userRelaysRouter);
app.get('/api/health', (_req, res) => {
+47 -6
View File
@@ -1,5 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { authService, type ResolvedAccess } from '../services/auth';
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
@@ -12,11 +14,13 @@ declare global {
namespace Express {
interface Request {
user?: AuthPayload;
access?: ResolvedAccess;
isApiKey?: boolean;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
@@ -24,6 +28,28 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
}
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. Wrapped in
// try/catch because Express 4 does not catch rejections from async middleware.
if (looksLikeApiKey(token)) {
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' });
}
return;
}
try {
const payload = jwt.verify(token, JWT_SECRET) as AuthPayload;
req.user = payload;
@@ -33,16 +59,31 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
}
}
export function requireRole(roles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
// Resolves the requester's effective access (env SuperAdmin list plus database)
// and gates the request on a single permission key. SuperAdmin bypasses every
// check. The resolved access is attached to req.access for downstream handlers.
export function requires(permission: string) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.user) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
if (!roles.includes(req.user.role)) {
try {
// API key access is already resolved (and scoped) in requireAuth; for JWT
// callers, resolve their live role-based access here.
let access = req.access;
if (!req.isApiKey || !access) {
access = await authService.resolveAccess(req.user.pubkey);
req.access = access;
}
if (access.isSuperAdmin || access.permissions.has(permission)) {
next();
return;
}
res.status(403).json({ error: 'Insufficient permissions' });
return;
} catch (err) {
console.error('Permission check error:', err);
res.status(500).json({ error: 'Internal server error' });
}
next();
};
}
+57
View File
@@ -0,0 +1,57 @@
import crypto from 'crypto';
import { prisma } from '../db/prisma';
import type { ResolvedAccess } from './auth';
// Raw keys are prefixed so the auth middleware can distinguish them from JWTs
// (which always start with "eyJ").
export const API_KEY_PREFIX = 'bbe_';
const DISPLAY_PREFIX_LENGTH = API_KEY_PREFIX.length + 8;
export function hashApiKey(rawKey: string): string {
return crypto.createHash('sha256').update(rawKey).digest('hex');
}
// Generates a new random key. Returns the raw key (shown once), its display
// prefix, and the hash to persist.
export function generateApiKey(): { rawKey: string; prefix: string; keyHash: string } {
const rawKey = API_KEY_PREFIX + crypto.randomBytes(32).toString('hex');
return {
rawKey,
prefix: rawKey.slice(0, DISPLAY_PREFIX_LENGTH),
keyHash: hashApiKey(rawKey),
};
}
export function looksLikeApiKey(token: string): boolean {
return token.startsWith(API_KEY_PREFIX);
}
// Resolves a raw API key into an access object scoped to the key's permissions.
// Returns null when the key is unknown or revoked. Identity is attributed to the
// pubkey that created the key, but the permissions come from the key, not the
// creator's role.
export async function resolveApiKey(rawKey: string): Promise<ResolvedAccess | null> {
const keyHash = hashApiKey(rawKey);
const key = await prisma.apiKey.findUnique({ where: { keyHash } });
if (!key || key.revokedAt) return null;
let permissions: string[] = [];
try {
const parsed = JSON.parse(key.permissions);
if (Array.isArray(parsed)) permissions = parsed.filter((p): p is string => typeof p === 'string');
} catch {
permissions = [];
}
// Best-effort usage tracking; never block the request on it.
prisma.apiKey
.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } })
.catch(() => undefined);
return {
pubkey: key.createdByPubkey,
role: 'guest',
isSuperAdmin: false,
permissions: new Set(permissions),
};
}
+63 -20
View File
@@ -2,10 +2,48 @@ import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { verifyEvent, type VerifiedEvent, nip19 } from 'nostr-tools';
import { prisma } from '../db/prisma';
import {
ALL_PERMISSION_KEYS,
isAssignableRole,
type EffectiveRole,
} from '../constants/permissions';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export interface ResolvedAccess {
pubkey: string;
role: EffectiveRole;
isSuperAdmin: boolean;
permissions: Set<string>;
}
// Reads the SuperAdmin pubkey list from the environment, decoding npub to hex.
// Falls back to the legacy ADMIN_PUBKEYS variable so existing deployments keep
// working. The env-admin concept is now SuperAdmin.
function getSuperadminPubkeys(): string[] {
const raw = process.env.SUPERADMIN_PUBKEYS ?? process.env.ADMIN_PUBKEYS ?? '';
return raw
.split(',')
.map((p) => p.trim())
.filter(Boolean)
.map((p) => {
if (p.startsWith('npub1')) {
try {
const { data } = nip19.decode(p);
return data as string;
} catch {
return p;
}
}
return p;
});
}
export function isSuperadmin(pubkey: string): boolean {
return getSuperadminPubkeys().includes(pubkey);
}
interface StoredChallenge {
challenge: string;
expiresAt: number;
@@ -62,29 +100,34 @@ export const authService = {
return jwt.sign({ pubkey, role }, JWT_SECRET, { expiresIn: '7d' });
},
async getRole(pubkey: string): Promise<string> {
const adminPubkeys = (process.env.ADMIN_PUBKEYS || '')
.split(',')
.map((p) => p.trim())
.filter(Boolean)
.map((p) => {
if (p.startsWith('npub1')) {
try {
const { data } = nip19.decode(p);
return data as string;
} catch {
return p;
}
}
return p;
});
isSuperadmin,
if (adminPubkeys.includes(pubkey)) return 'ADMIN';
// Resolves the effective role and permission set for a pubkey, live, from the
// env SuperAdmin list plus the database. This is the authoritative source for
// authorization. The role baked into a JWT is only used for display.
async resolveAccess(pubkey: string): Promise<ResolvedAccess> {
if (isSuperadmin(pubkey)) {
return {
pubkey,
role: 'superadmin',
isSuperAdmin: true,
permissions: new Set(ALL_PERMISSION_KEYS),
};
}
const user = await prisma.user.findUnique({ where: { pubkey } });
if (user?.role === 'MODERATOR') return 'MODERATOR';
if (user?.role === 'ADMIN') return 'ADMIN';
const role: EffectiveRole = isAssignableRole(user?.role) ? user!.role : 'guest';
return 'USER';
if (role === 'guest') {
return { pubkey, role, isSuperAdmin: false, permissions: new Set() };
}
const rows = await prisma.rolePermission.findMany({ where: { role } });
return {
pubkey,
role,
isSuperAdmin: false,
permissions: new Set(rows.map((r) => r.permission)),
};
},
};
+63
View File
@@ -1,6 +1,14 @@
import WebSocket from 'ws';
import { SimplePool, nip19 } from 'nostr-tools';
import { prisma } from '../db/prisma';
// nostr-tools' SimplePool relies on a global WebSocket. Node only exposes one
// from v22 onward, so on older runtimes (this backend runs Node 20) every relay
// query silently fails without this polyfill. Set before any pool connects.
if (typeof (globalThis as { WebSocket?: unknown }).WebSocket === 'undefined') {
(globalThis as { WebSocket?: unknown }).WebSocket = WebSocket;
}
const pool = new SimplePool();
async function getRelayUrls(): Promise<string[]> {
@@ -110,6 +118,32 @@ export const nostrService = {
return null;
}
// Cache-first: addressable events are keyed by kind+pubkey+d-tag, not by a
// stable event id, so look them up among cached events of the same author.
// Avoids re-querying relays on every repeat visit to the same naddr.
const cached = await prisma.nostrEventCache.findMany({
where: { kind: decoded.kind, pubkey: decoded.pubkey },
});
for (const c of cached) {
let tags: string[][] = [];
try {
tags = JSON.parse(c.tags);
} catch {
continue;
}
const dTag = tags.find((t) => t[0] === 'd');
if (dTag?.[1] === decoded.identifier) {
return {
id: c.eventId,
kind: c.kind,
pubkey: c.pubkey,
content: c.content,
tags,
created_at: c.createdAt,
};
}
}
const siteRelays = await getRelayUrls();
const naddrRelays = decoded.relays || [];
const filter = {
@@ -150,6 +184,35 @@ export const nostrService = {
return null;
},
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
// event id to the underlying Nostr event, reusing the cache-aware fetchers.
// Returns null if the string can't be decoded or the event isn't found.
async resolveEventByIdentifier(identifier: string) {
const trimmed = identifier.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return nostrService.fetchEvent(trimmed.toLowerCase());
}
let decoded;
try {
decoded = nip19.decode(trimmed);
} catch {
return null;
}
switch (decoded.type) {
case 'naddr':
return nostrService.fetchLongformEvent(trimmed);
case 'nevent':
return nostrService.fetchEvent((decoded.data as nip19.EventPointer).id);
case 'note':
return nostrService.fetchEvent(decoded.data as string);
default:
return null;
}
},
async fetchReactions(eventId: string) {
const relays = await getRelayUrls();
if (relays.length === 0) return [];
+124
View File
@@ -0,0 +1,124 @@
import { prisma } from '../db/prisma';
import { nostrService } from './nostr';
export interface ImportPostInput {
nostrEventId: string;
naddr?: string | null;
title: string;
excerpt?: string | null;
authorPubkey: string;
publishedAt?: number | string | null;
tags?: string[];
visible?: boolean;
}
// Upserts a blog Post from a Nostr longform event. Shared by the manual import
// endpoint and the submission-approval flow so both produce identical results
// (auto slug, category links from `t` tags, visible by default).
export async function importPostFromNostr(input: ImportPostInput) {
const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags, visible } = input;
const slugBase = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
const post = await prisma.post.upsert({
where: { nostrEventId },
update: {
title,
excerpt: excerpt || undefined,
naddr: naddr || undefined,
},
create: {
nostrEventId,
naddr: naddr || null,
title,
slug,
excerpt: excerpt || null,
authorPubkey,
visible: visible ?? true,
publishedAt: publishedAt
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
: new Date(),
},
});
if (Array.isArray(tags) && tags.length > 0) {
const categoryIds: string[] = [];
for (const tag of tags) {
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
if (!catSlug) continue;
const category = await prisma.category.upsert({
where: { slug: catSlug },
update: {},
create: {
name: tag.charAt(0).toUpperCase() + tag.slice(1),
slug: catSlug,
},
});
categoryIds.push(category.id);
}
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
if (categoryIds.length > 0) {
await prisma.postCategory.createMany({
data: categoryIds.map((categoryId) => ({
postId: post.id,
categoryId,
})),
});
}
}
return prisma.post.findUnique({
where: { id: post.id },
include: { categories: { include: { category: true } } },
});
}
export interface SubmissionRef {
eventId: string | null;
naddr: string | null;
title: string;
authorPubkey: string;
}
// Resolves the Nostr longform event referenced by a submission into a blog
// import payload. Returns null when the event cannot be resolved into something
// publishable (e.g. naddr that no relay can serve and no fallback event id).
export async function resolveSubmissionImport(
submission: SubmissionRef
): Promise<ImportPostInput | null> {
const event = submission.naddr
? await nostrService.fetchLongformEvent(submission.naddr)
: submission.eventId
? await nostrService.fetchEvent(submission.eventId)
: null;
const nostrEventId: string | undefined = event?.id || submission.eventId || undefined;
if (!nostrEventId) return null;
const eventTags: string[][] = Array.isArray(event?.tags) ? (event!.tags as string[][]) : [];
const titleTag = eventTags.find((t) => t[0] === 'title')?.[1];
const topicTags = eventTags
.filter((t) => t[0] === 't' && t[1])
.map((t) => (t[1] as string).toLowerCase());
const excerpt = ((event?.content as string) || '')
.slice(0, 200)
.replace(/[#*_\n]/g, '')
.trim();
return {
nostrEventId,
naddr: submission.naddr || undefined,
title: submission.title || titleTag || 'Untitled',
excerpt: excerpt || undefined,
authorPubkey: event?.pubkey || submission.authorPubkey,
publishedAt: event?.created_at ?? null,
tags: topicTags.length > 0 ? topicTags : undefined,
visible: true,
};
}
+24
View File
@@ -0,0 +1,24 @@
import { nip19 } from 'nostr-tools';
// Relays and the rest of the app key identities by lowercase hex pubkeys.
// Pubkeys may arrive as npub/nprofile, so normalize them to hex. Returns null
// when the input cannot be interpreted as a pubkey.
export function toHexPubkey(pubkey: string | null | undefined): string | null {
if (!pubkey) return null;
const trimmed = pubkey.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') return decoded.data as string;
if (decoded.type === 'nprofile') return (decoded.data as { pubkey: string }).pubkey;
} catch {
// fall through
}
return null;
}
// Normalizes to hex when possible, otherwise returns the trimmed original so we
// never silently drop an identity we cannot decode.
export function normalizePubkey(pubkey: string): string {
return toHexPubkey(pubkey) ?? pubkey.trim();
}
+34 -9
View File
@@ -6,13 +6,38 @@ export async function GET(req: NextRequest) {
const upstream = new URL(apiUrl('/nip05'));
if (name) upstream.searchParams.set('name', name);
const res = await fetch(upstream.toString(), { cache: 'no-store' });
const data = await res.json();
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
});
try {
const res = await fetch(upstream.toString(), { cache: 'no-store' });
if (!res.ok) {
return NextResponse.json(
{},
{
status: 502,
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
}
);
}
const data = await res.json();
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
});
} catch (err) {
console.error('NIP-05 proxy error:', err);
return NextResponse.json(
{},
{
status: 502,
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
}
);
}
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { formatDate } from "@/lib/utils";
import { Copy, Check, KeyRound, Plus, Trash2, ShieldAlert } from "lucide-react";
interface PermissionDef {
key: string;
label: string;
group: string;
}
interface ApiKey {
id: string;
name: string;
prefix: string;
permissions: string[];
createdByPubkey: string;
lastUsedAt: string | null;
revokedAt: string | null;
createdAt: string;
}
export default function ApiKeysPage() {
const { can } = useAuth();
const allowed = can("api_keys.manage");
const [permissions, setPermissions] = useState<PermissionDef[]>([]);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [name, setName] = useState("");
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [creating, setCreating] = useState(false);
const [revealedKey, setRevealedKey] = useState<{ name: string; key: string } | null>(null);
const [copied, setCopied] = useState(false);
const load = async () => {
setLoading(true);
try {
const [registry, list] = await Promise.all([
api.getPermissionRegistry(),
api.getApiKeys(),
]);
setPermissions(registry.permissions);
setKeys(list);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (allowed) load();
else setLoading(false);
}, [allowed]);
const groups = useMemo(() => {
const order: string[] = [];
const byGroup: Record<string, PermissionDef[]> = {};
for (const perm of permissions) {
if (!byGroup[perm.group]) {
byGroup[perm.group] = [];
order.push(perm.group);
}
byGroup[perm.group].push(perm);
}
return order.map((group) => ({ group, perms: byGroup[group] }));
}, [permissions]);
const selectedKeys = useMemo(
() => Object.entries(selected).filter(([, v]) => v).map(([k]) => k),
[selected]
);
const toggle = (key: string) => {
setSelected((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleCreate = async () => {
if (!name.trim() || selectedKeys.length === 0) return;
setCreating(true);
setError("");
try {
const created = await api.createApiKey({ name: name.trim(), permissions: selectedKeys });
setRevealedKey({ name: created.name, key: created.key });
setCopied(false);
setName("");
setSelected({});
await load();
} catch (err: any) {
setError(err.message);
} finally {
setCreating(false);
}
};
const handleRevoke = async (id: string) => {
setError("");
try {
await api.revokeApiKey(id);
await load();
} catch (err: any) {
setError(err.message);
}
};
const handleCopy = async () => {
if (!revealedKey) return;
try {
await navigator.clipboard.writeText(revealedKey.key);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};
if (!allowed) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">
You do not have permission to manage API keys.
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading API keys...</div>
</div>
);
}
const labelFor = (key: string) => permissions.find((p) => p.key === key)?.label ?? key;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-on-surface">API Keys</h1>
<p className="text-on-surface/60 text-sm mt-1">
Create scoped API keys for programmatic access. A key is shown in full
only once, right after you create it, so copy it immediately.
</p>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{revealedKey && (
<div className="bg-primary-container/10 border border-primary/30 rounded-xl p-6 space-y-3">
<div className="flex items-center gap-2 text-primary font-semibold">
<ShieldAlert size={18} />
Copy your new key now it won&apos;t be shown again
</div>
<p className="text-on-surface/70 text-sm">
Key for <span className="font-semibold">{revealedKey.name}</span>:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm break-all">
{revealedKey.key}
</code>
<button
onClick={handleCopy}
className="flex items-center gap-2 px-4 py-3 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity whitespace-nowrap"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
<button
onClick={() => setRevealedKey(null)}
className="text-on-surface/50 text-sm hover:text-on-surface transition-colors"
>
I&apos;ve saved it, dismiss
</button>
</div>
)}
{/* Create form */}
<div className="bg-surface-container-low rounded-xl p-6 space-y-5">
<h2 className="text-sm font-semibold text-on-surface/70">Create API Key</h2>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Blog publishing bot"
maxLength={100}
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-3">
Permissions
</p>
<div className="space-y-4">
{groups.map(({ group, perms }) => (
<div key={group}>
<p className="text-xs font-bold uppercase tracking-wide text-on-surface/40 mb-2">
{group}
</p>
<div className="grid sm:grid-cols-2 gap-2">
{perms.map((perm) => (
<label
key={perm.key}
className="flex items-start gap-2 cursor-pointer text-sm text-on-surface"
>
<input
type="checkbox"
checked={!!selected[perm.key]}
onChange={() => toggle(perm.key)}
className="mt-0.5 h-4 w-4 accent-primary cursor-pointer"
/>
<span>
{perm.label}
<span className="block text-on-surface/40 text-xs font-mono">
{perm.key}
</span>
</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<button
onClick={handleCreate}
disabled={!name.trim() || selectedKeys.length === 0 || creating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Plus size={16} />
{creating ? "Creating..." : "Create API Key"}
</button>
</div>
{/* Existing keys */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-on-surface/70">Existing Keys</h2>
{keys.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-8 text-center">
<KeyRound size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">No API keys yet.</p>
</div>
) : (
keys.map((key) => (
<div
key={key.id}
className={`bg-surface-container-low rounded-xl p-6 ${key.revokedAt ? "opacity-60" : ""}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-on-surface">{key.name}</h3>
{key.revokedAt && (
<span className="text-xs font-bold text-error bg-error/10 px-2 py-0.5 rounded-full">
Revoked
</span>
)}
</div>
<p className="font-mono text-sm text-on-surface/60 mt-1">{key.prefix}</p>
<p className="text-on-surface/40 text-xs mt-1">
Created {formatDate(key.createdAt)}
{key.lastUsedAt ? ` · Last used ${formatDate(key.lastUsedAt)}` : " · Never used"}
</p>
<div className="flex flex-wrap gap-1.5 mt-3">
{key.permissions.map((p) => (
<span
key={p}
className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full"
title={p}
>
{labelFor(p)}
</span>
))}
</div>
</div>
{!key.revokedAt && (
<button
onClick={() => handleRevoke(key.id)}
className="text-on-surface-variant/50 hover:text-error transition-colors p-2 rounded-lg hover:bg-error/10 shrink-0"
title="Revoke key"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
))
)}
</div>
</div>
);
}
+8 -4
View File
@@ -6,21 +6,25 @@ import { useAuth } from "@/hooks/useAuth";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const { user, loading, isSuperAdmin, permissions } = useAuth();
const router = useRouter();
// Access to the dashboard requires at least one elevated permission, or being
// a SuperAdmin. Guests (no permissions) are sent to their own dashboard.
const hasDashboardAccess = isSuperAdmin || permissions.length > 0;
useEffect(() => {
if (loading) return;
if (!user) {
router.push("/login");
return;
}
if (user.role !== "ADMIN" && user.role !== "MODERATOR") {
if (!hasDashboardAccess) {
router.push("/dashboard");
}
}, [user, loading, router]);
}, [user, loading, hasDashboardAccess, router]);
if (loading || !user || (user.role !== "ADMIN" && user.role !== "MODERATOR")) {
if (loading || !user || !hasDashboardAccess) {
return null;
}
+5 -1
View File
@@ -86,7 +86,11 @@ export default function OverviewPage() {
<StatCard icon={Calendar} label="Total Meetups" value={meetups.length} />
<StatCard icon={FileText} label="Blog Posts" value={posts.length} />
<StatCard icon={Tag} label="Categories" value={categories.length} />
<StatCard icon={User} label="Your Role" value={user.role} />
<StatCard
icon={User}
label="Your Role"
value={user.isSuperAdmin ? "SuperAdmin" : user.role}
/>
</div>
{upcomingMeetup && (
+3 -3
View File
@@ -6,7 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { LogIn } from "lucide-react";
export default function AdminPage() {
const { user, loading, login } = useAuth();
const { user, loading, login, isSuperAdmin, permissions } = useAuth();
const router = useRouter();
const [error, setError] = useState("");
const [loggingIn, setLoggingIn] = useState(false);
@@ -14,12 +14,12 @@ export default function AdminPage() {
useEffect(() => {
if (loading) return;
if (!user) return;
if (user.role === "ADMIN" || user.role === "MODERATOR") {
if (isSuperAdmin || permissions.length > 0) {
router.push("/admin/overview");
} else {
router.push("/dashboard");
}
}, [user, loading, router]);
}, [user, loading, isSuperAdmin, permissions, router]);
const handleLogin = async () => {
setError("");
+234
View File
@@ -0,0 +1,234 @@
"use client";
import { Fragment, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { Check, Lock, Save } from "lucide-react";
interface PermissionDef {
key: string;
label: string;
group: string;
}
type Matrix = Record<string, Record<string, boolean>>;
const ROLE_LABELS: Record<string, string> = {
admin: "Admin",
moderator: "Moderator",
writer: "Writer",
};
function buildMatrix(
roles: string[],
perms: PermissionDef[],
granted: Record<string, string[]>
): Matrix {
const matrix: Matrix = {};
for (const role of roles) {
matrix[role] = {};
const set = new Set(granted[role] ?? []);
for (const perm of perms) {
matrix[role][perm.key] = set.has(perm.key);
}
}
return matrix;
}
export default function RolesPage() {
const { can } = useAuth();
const allowed = can("roles.edit_permissions");
const [permissions, setPermissions] = useState<PermissionDef[]>([]);
const [roles, setRoles] = useState<string[]>([]);
const [matrix, setMatrix] = useState<Matrix>({});
const [original, setOriginal] = useState<Matrix>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState("");
const load = async () => {
setLoading(true);
try {
const [registry, current] = await Promise.all([
api.getPermissionRegistry(),
api.getRolePermissions(),
]);
const granted: Record<string, string[]> = {};
for (const r of current.roles) granted[r.role] = r.permissions;
const built = buildMatrix(registry.roles, registry.permissions, granted);
setPermissions(registry.permissions);
setRoles(registry.roles);
setMatrix(built);
setOriginal(built);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (allowed) load();
else setLoading(false);
}, [allowed]);
const groups = useMemo(() => {
const order: string[] = [];
const byGroup: Record<string, PermissionDef[]> = {};
for (const perm of permissions) {
if (!byGroup[perm.group]) {
byGroup[perm.group] = [];
order.push(perm.group);
}
byGroup[perm.group].push(perm);
}
return order.map((group) => ({ group, perms: byGroup[group] }));
}, [permissions]);
const dirtyRoles = useMemo(() => {
return roles.filter((role) =>
permissions.some((perm) => matrix[role]?.[perm.key] !== original[role]?.[perm.key])
);
}, [roles, permissions, matrix, original]);
const toggle = (role: string, key: string) => {
setNotice("");
setMatrix((prev) => ({
...prev,
[role]: { ...prev[role], [key]: !prev[role]?.[key] },
}));
};
const saveAll = async () => {
if (dirtyRoles.length === 0) return;
setSaving(true);
setError("");
setNotice("");
try {
for (const role of dirtyRoles) {
const keys = permissions.filter((p) => matrix[role]?.[p.key]).map((p) => p.key);
await api.updateRolePermissions(role, keys);
}
setOriginal(() => {
const next: Matrix = {};
for (const role of roles) next[role] = { ...matrix[role] };
return next;
});
setNotice("Permissions saved.");
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (!allowed) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">
You do not have permission to manage roles.
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading roles...</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-on-surface">Roles and Permissions</h1>
<p className="text-on-surface/60 text-sm mt-1">
Toggle what each role can do. SuperAdmin always has every permission and
cannot be changed.
</p>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{notice && <p className="text-primary text-sm">{notice}</p>}
<div className="bg-surface-container-low rounded-xl overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="text-left">
<th className="sticky left-0 bg-surface-container-low p-4 font-semibold text-on-surface/70">
Permission
</th>
{roles.map((role) => (
<th key={role} className="p-4 text-center font-semibold text-on-surface/70">
{ROLE_LABELS[role] ?? role}
</th>
))}
<th className="p-4 text-center font-semibold text-on-surface/70">
<span className="inline-flex items-center gap-1">
<Lock size={12} />
SuperAdmin
</span>
</th>
</tr>
</thead>
<tbody>
{groups.map(({ group, perms }) => (
<Fragment key={group}>
<tr>
<td
colSpan={roles.length + 2}
className="bg-surface-container px-4 py-2 text-xs font-bold uppercase tracking-wide text-on-surface/50"
>
{group}
</td>
</tr>
{perms.map((perm) => (
<tr key={perm.key} className="border-b border-surface-container-high/40">
<td className="sticky left-0 bg-surface-container-low p-4">
<div className="text-on-surface">{perm.label}</div>
<div className="text-on-surface/40 text-xs font-mono">{perm.key}</div>
</td>
{roles.map((role) => (
<td key={role} className="p-4 text-center">
<input
type="checkbox"
checked={!!matrix[role]?.[perm.key]}
onChange={() => toggle(role, perm.key)}
className="h-4 w-4 accent-primary cursor-pointer"
aria-label={`${ROLE_LABELS[role] ?? role}: ${perm.label}`}
/>
</td>
))}
<td className="p-4 text-center text-primary/70">
<Check size={16} className="inline" aria-label="Always granted" />
</td>
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={saveAll}
disabled={dirtyRoles.length === 0 || saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{saving ? "Saving..." : "Save changes"}
</button>
{dirtyRoles.length > 0 && !saving && (
<span className="text-on-surface/50 text-sm">
Unsaved changes to {dirtyRoles.map((r) => ROLE_LABELS[r] ?? r).join(", ")}
</span>
)}
</div>
</div>
);
}
+10 -1
View File
@@ -40,6 +40,7 @@ export default function AdminSubmissionsPage() {
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [reviewNote, setReviewNote] = useState("");
const [processing, setProcessing] = useState(false);
const [success, setSuccess] = useState("");
const loadSubmissions = async () => {
try {
@@ -61,10 +62,17 @@ export default function AdminSubmissionsPage() {
const handleReview = async (id: string, status: "APPROVED" | "REJECTED") => {
setProcessing(true);
setError("");
setSuccess("");
try {
await api.reviewSubmission(id, { status, reviewNote: reviewNote.trim() || undefined });
const res = await api.reviewSubmission(id, {
status,
reviewNote: reviewNote.trim() || undefined,
});
setReviewingId(null);
setReviewNote("");
if (status === "APPROVED" && res?.post) {
setSuccess(`Approved and published "${res.post.title}" to the blog.`);
}
await loadSubmissions();
} catch (err: any) {
setError(err.message);
@@ -87,6 +95,7 @@ export default function AdminSubmissionsPage() {
</div>
{error && <p className="text-error text-sm">{error}</p>}
{success && <p className="text-primary text-sm">{success}</p>}
<div className="flex gap-2">
{TABS.map((tab) => (
+254 -226
View File
@@ -1,16 +1,39 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { nip19 } from "nostr-tools";
import { api } from "@/lib/api";
import { cn, formatDate } from "@/lib/utils";
import { fetchNostrProfile, type NostrProfile } from "@/lib/nostr";
import { ShieldCheck, ShieldOff, UserPlus } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { useNostrProfile } from "@/hooks/useNostrProfile";
import { NostrAvatar } from "@/components/nostr/NostrAvatar";
import { formatDate } from "@/lib/utils";
import { Lock, Plus } from "lucide-react";
function hexToNpub(hex: string): string {
const ROLE_RANK: Record<string, number> = {
superadmin: 4,
admin: 3,
moderator: 2,
writer: 1,
guest: 0,
};
const ROLE_OPTIONS: { value: string; label: string }[] = [
{ value: "admin", label: "Admin" },
{ value: "moderator", label: "Moderator" },
{ value: "writer", label: "Writer" },
{ value: "guest", label: "Guest (no role)" },
];
function normalizeRole(role: string | null | undefined): string {
return role && ROLE_RANK[role] !== undefined ? role : "guest";
}
function hexToNpub(pubkey: string): string {
if (!pubkey) return "";
// Already an npub (some users are stored in npub form).
if (pubkey.startsWith("npub1")) return pubkey;
try {
return nip19.npubEncode(hex);
return nip19.npubEncode(pubkey);
} catch {
return "";
}
@@ -21,25 +44,173 @@ function shortenNpub(npub: string): string {
return `${npub.slice(0, 14)}...${npub.slice(-10)}`;
}
function profileInitials(profile: NostrProfile | undefined, npub: string): string {
const n = profile?.name || profile?.displayName;
if (n?.trim()) return n.trim().slice(0, 2).toUpperCase();
if (npub.length >= 8) return npub.slice(5, 7).toUpperCase();
return "?";
interface UserRowProps {
user: any;
draft: string;
onDraftChange: (pubkey: string, value: string) => void;
savingPubkey: string | null;
onSaveUsername: (pubkey: string, currentUsername: string | null | undefined) => void;
onCancelUsername: (pubkey: string, stored: string | null | undefined) => void;
hostname: string;
copiedPubkey: string | null;
onCopyNpub: (pubkey: string) => void;
callerRank: number;
isSuperAdmin: boolean;
savingRole: string | null;
onRoleChange: (pubkey: string, role: string) => void;
}
function UserRow({
user,
draft,
onDraftChange,
savingPubkey,
onSaveUsername,
onCancelUsername,
hostname,
copiedPubkey,
onCopyNpub,
callerRank,
isSuperAdmin,
savingRole,
onRoleChange,
}: UserRowProps) {
const { profile, loading: profileLoading } = useNostrProfile(user.pubkey);
const stored = user.username ?? "";
const usernameDirty = draft.trim().toLowerCase() !== stored.toLowerCase();
const fullNpub = hexToNpub(user.pubkey);
const nostrDisplay = profile?.name || profile?.displayName;
return (
<div className="bg-surface-container-low rounded-xl p-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1 min-w-0 flex gap-4 items-start">
<NostrAvatar pubkey={user.pubkey} size={56} fallbackText={fullNpub} />
<div className="flex-1 min-w-0 space-y-3">
<div>
<p className="text-on-surface font-semibold text-base truncate">
{profileLoading ? (
<span className="text-on-surface/40 font-normal"></span>
) : nostrDisplay ? (
nostrDisplay
) : (
<span className="text-on-surface/50 font-normal">No Nostr name</span>
)}
</p>
<button
type="button"
onClick={() => onCopyNpub(user.pubkey)}
className="mt-1 text-left font-mono text-sm text-on-surface/80 hover:text-primary transition-colors cursor-pointer break-all w-full"
title={fullNpub || "Copy npub"}
>
{copiedPubkey === user.pubkey
? "Copied!"
: fullNpub
? shortenNpub(fullNpub)
: `${user.pubkey?.slice(0, 12)}...${user.pubkey?.slice(-8)}`}
</button>
</div>
<div>
<p className="text-xs font-semibold text-on-surface/50 mb-1 uppercase tracking-wide">
NIP-05 username
</p>
<p className="text-on-surface-variant text-xs mb-2">
Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard).
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={draft}
onChange={(e) => onDraftChange(user.pubkey, e.target.value)}
disabled={savingPubkey === user.pubkey}
placeholder="local-part"
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm font-mono min-w-[8rem] max-w-full flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
/>
<span className="text-on-surface/50 text-sm font-mono shrink-0">
@{hostname || "…"}
</span>
<button
type="button"
onClick={() => onSaveUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !draft.trim() || !usernameDirty}
className="px-3 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{savingPubkey === user.pubkey ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => onCancelUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !usernameDirty}
className="px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 text-sm hover:text-on-surface transition-colors disabled:opacity-50 whitespace-nowrap"
>
Cancel
</button>
</div>
{draft.trim() && (
<p className="text-on-surface-variant text-xs font-mono mt-2">
{draft.trim().toLowerCase()}@{hostname || "…"}
</p>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
{user.isSuperAdmin ? (
<span className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold bg-primary-container/20 text-primary">
<Lock size={12} />
SuperAdmin
</span>
) : (
(() => {
const targetRole = normalizeRole(user.role);
const targetRank = ROLE_RANK[targetRole];
const canModify = callerRank > targetRank;
return (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold text-on-surface/50 uppercase tracking-wide">
Role
</span>
<select
value={targetRole}
disabled={!canModify || savingRole === user.pubkey}
onChange={(e) => onRoleChange(user.pubkey, e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
>
{ROLE_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
disabled={!isSuperAdmin && ROLE_RANK[opt.value] >= callerRank}
>
{opt.label}
</option>
))}
</select>
</label>
);
})()
)}
{user.createdAt && (
<span className="text-on-surface/40 text-xs">
Joined {formatDate(user.createdAt)}
</span>
)}
</div>
</div>
</div>
</div>
);
}
export default function UsersPage() {
const { user: currentUser, isSuperAdmin } = useAuth();
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [promotePubkey, setPromotePubkey] = useState("");
const [promoting, setPromoting] = useState(false);
const [savingRole, setSavingRole] = useState<string | null>(null);
const [hostname, setHostname] = useState("");
const [usernameDrafts, setUsernameDrafts] = useState<Record<string, string>>({});
const [savingPubkey, setSavingPubkey] = useState<string | null>(null);
const [nostrByPubkey, setNostrByPubkey] = useState<Record<string, NostrProfile>>({});
const [nostrLoading, setNostrLoading] = useState(false);
const [copiedPubkey, setCopiedPubkey] = useState<string | null>(null);
const [newUserPubkey, setNewUserPubkey] = useState("");
const [addingUser, setAddingUser] = useState(false);
const [addUserSuccess, setAddUserSuccess] = useState("");
const loadUsers = async () => {
try {
@@ -65,32 +236,6 @@ export default function UsersPage() {
setHostname(typeof window !== "undefined" ? window.location.hostname : "");
}, []);
useEffect(() => {
if (users.length === 0) {
setNostrByPubkey({});
setNostrLoading(false);
return;
}
let cancelled = false;
setNostrLoading(true);
setNostrByPubkey({});
(async () => {
const entries = await Promise.all(
users.map(async (u: { pubkey: string }) => {
const profile = await fetchNostrProfile(u.pubkey);
return [u.pubkey, profile] as const;
})
);
if (!cancelled) {
setNostrByPubkey(Object.fromEntries(entries));
setNostrLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [users]);
const handleCopyNpub = async (pubkey: string) => {
const full = hexToNpub(pubkey);
if (!full) return;
@@ -103,39 +248,20 @@ export default function UsersPage() {
}
};
const handlePromote = async () => {
if (!promotePubkey.trim()) return;
setPromoting(true);
const callerRank = isSuperAdmin
? ROLE_RANK.superadmin
: ROLE_RANK[normalizeRole(currentUser?.role)];
const handleRoleChange = async (pubkey: string, role: string) => {
setSavingRole(pubkey);
setError("");
try {
await api.promoteUser(promotePubkey);
setPromotePubkey("");
await api.setUserRole(pubkey, role === "guest" ? null : role);
await loadUsers();
} catch (err: any) {
setError(err.message);
} finally {
setPromoting(false);
}
};
const handleDemote = async (pubkey: string) => {
if (!confirm("Demote this user to regular user?")) return;
setError("");
try {
await api.demoteUser(pubkey);
await loadUsers();
} catch (err: any) {
setError(err.message);
}
};
const handlePromoteUser = async (pubkey: string) => {
setError("");
try {
await api.promoteUser(pubkey);
await loadUsers();
} catch (err: any) {
setError(err.message);
setSavingRole(null);
}
};
@@ -159,6 +285,28 @@ export default function UsersPage() {
setUsernameDrafts((prev) => ({ ...prev, [pubkey]: stored ?? "" }));
};
const handleDraftChange = (pubkey: string, value: string) => {
setUsernameDrafts((prev) => ({ ...prev, [pubkey]: value }));
};
const handleAddUser = async () => {
const value = newUserPubkey.trim();
if (!value) return;
setAddingUser(true);
setError("");
setAddUserSuccess("");
try {
await api.createUser(value);
setNewUserPubkey("");
setAddUserSuccess("User added.");
await loadUsers();
} catch (err: any) {
setError(err.message);
} finally {
setAddingUser(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
@@ -174,177 +322,57 @@ export default function UsersPage() {
{error && <p className="text-error text-sm">{error}</p>}
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Promote User</h2>
<div className="flex gap-3">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Add User</h2>
<p className="text-on-surface-variant text-xs mb-3">
Pre-register a user by their npub or hex pubkey so you can assign a role before they log in.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<input
placeholder="Pubkey (hex)"
value={promotePubkey}
onChange={(e) => setPromotePubkey(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
placeholder="npub1... or hex pubkey"
value={newUserPubkey}
onChange={(e) => setNewUserPubkey(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddUser();
}}
disabled={addingUser}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full font-mono text-sm focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1 disabled:opacity-50"
/>
<button
onClick={handlePromote}
disabled={promoting || !promotePubkey.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
onClick={handleAddUser}
disabled={!newUserPubkey.trim() || addingUser}
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
<UserPlus size={16} />
{promoting ? "Promoting..." : "Promote"}
<Plus size={16} />
{addingUser ? "Adding…" : "Add User"}
</button>
</div>
{addUserSuccess && (
<p className="text-green-400 text-sm mt-3">{addUserSuccess}</p>
)}
</div>
<div className="space-y-3">
{users.length === 0 ? (
<p className="text-on-surface/50 text-sm">No users found.</p>
) : (
users.map((user) => {
const draft = usernameDrafts[user.pubkey] ?? "";
const stored = user.username ?? "";
const usernameDirty = draft.trim().toLowerCase() !== stored.toLowerCase();
const profile = nostrByPubkey[user.pubkey];
const fullNpub = hexToNpub(user.pubkey);
const nostrDisplay = profile?.name || profile?.displayName;
return (
<div
users.map((user) => (
<UserRow
key={user.pubkey || user.id}
className="bg-surface-container-low rounded-xl p-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div className="flex-1 min-w-0 flex gap-4 items-start">
<div className="shrink-0 w-14 h-14 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden text-on-surface">
{nostrLoading ? (
<span className="text-on-surface/40 text-xs"></span>
) : profile?.picture ? (
<Image
src={profile.picture}
alt={nostrDisplay ? `Avatar: ${nostrDisplay}` : "Nostr profile picture"}
width={56}
height={56}
className="object-cover w-full h-full"
unoptimized
/>
) : (
<span className="font-semibold text-sm" aria-hidden>
{profileInitials(profile, fullNpub)}
</span>
)}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div>
<p className="text-on-surface font-semibold text-base truncate">
{nostrLoading ? (
<span className="text-on-surface/40 font-normal"></span>
) : nostrDisplay ? (
nostrDisplay
) : (
<span className="text-on-surface/50 font-normal">No Nostr name</span>
)}
</p>
<button
type="button"
onClick={() => handleCopyNpub(user.pubkey)}
className="mt-1 text-left font-mono text-sm text-on-surface/80 hover:text-primary transition-colors cursor-pointer break-all w-full"
title={fullNpub || "Copy npub"}
>
{copiedPubkey === user.pubkey
? "Copied!"
: fullNpub
? shortenNpub(fullNpub)
: `${user.pubkey?.slice(0, 12)}...${user.pubkey?.slice(-8)}`}
</button>
</div>
<div>
<p className="text-xs font-semibold text-on-surface/50 mb-1 uppercase tracking-wide">
NIP-05 username
</p>
<p className="text-on-surface-variant text-xs mb-2">
Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard).
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={draft}
onChange={(e) =>
setUsernameDrafts((prev) => ({ ...prev, [user.pubkey]: e.target.value }))
}
disabled={savingPubkey === user.pubkey}
placeholder="local-part"
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm font-mono min-w-[8rem] max-w-full flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
/>
<span className="text-on-surface/50 text-sm font-mono shrink-0">
@{hostname || "…"}
</span>
<button
type="button"
onClick={() => handleSaveUsername(user.pubkey, user.username)}
disabled={
savingPubkey === user.pubkey ||
!draft.trim() ||
!usernameDirty
}
className="px-3 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{savingPubkey === user.pubkey ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => handleCancelUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !usernameDirty}
className="px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 text-sm hover:text-on-surface transition-colors disabled:opacity-50 whitespace-nowrap"
>
Cancel
</button>
</div>
{draft.trim() && (
<p className="text-on-surface-variant text-xs font-mono mt-2">
{draft.trim().toLowerCase()}@{hostname || "…"}
</p>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-bold",
user.role === "ADMIN"
? "bg-primary-container/20 text-primary"
: user.role === "MODERATOR"
? "bg-secondary-container text-on-secondary-container"
: "bg-surface-container-highest text-on-surface/50"
)}
>
{user.role}
</span>
{user.createdAt && (
<span className="text-on-surface/40 text-xs">
Joined {formatDate(user.createdAt)}
</span>
)}
</div>
</div>
</div>
{user.role !== "ADMIN" && (
<div className="flex items-center gap-2">
{user.role !== "MODERATOR" && (
<button
onClick={() => handlePromoteUser(user.pubkey)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-primary text-sm transition-colors"
>
<ShieldCheck size={14} />
Promote
</button>
)}
{user.role === "MODERATOR" && (
<button
onClick={() => handleDemote(user.pubkey)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-error text-sm transition-colors"
>
<ShieldOff size={14} />
Demote
</button>
)}
</div>
)}
</div>
);
})
user={user}
draft={usernameDrafts[user.pubkey] ?? ""}
onDraftChange={handleDraftChange}
savingPubkey={savingPubkey}
onSaveUsername={handleSaveUsername}
onCancelUsername={handleCancelUsername}
hostname={hostname}
copiedPubkey={copiedPubkey}
onCopyNpub={handleCopyNpub}
callerRank={callerRank}
isSuperAdmin={isSuperAdmin}
savingRole={savingRole}
onRoleChange={handleRoleChange}
/>
))
)}
</div>
</div>
+10
View File
@@ -0,0 +1,10 @@
import { buildBlogMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildBlogMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+164 -121
View File
@@ -3,14 +3,27 @@
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { ArrowLeft, Heart, Send } from "lucide-react";
import ReactMarkdown from "react-markdown";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, fetchLongformFromRelays, fetchEventFromRelays, type NostrProfile } from "@/lib/nostr";
import {
hasNostrExtension,
getPublicKey,
signEvent,
publishEvent,
shortenNpub,
fetchNostrProfile,
fetchLongformFromRelays,
fetchEventFromRelays,
resolveEventFromRelays,
type NostrProfile,
} from "@/lib/nostr";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import type { Components } from "react-markdown";
import { markdownComponents } from "./markdownComponents";
import { remarkNostr } from "./remarkNostr";
import { NostrAuthor } from "./NostrEmbeds";
interface Post {
id: string;
@@ -18,6 +31,7 @@ interface Post {
title: string;
content: string;
excerpt?: string;
image?: string;
authorName?: string;
authorPubkey?: string;
publishedAt?: string;
@@ -34,84 +48,29 @@ interface NostrReply {
created_at: number;
}
const markdownComponents: Components = {
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
),
p: ({ children }) => (
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
),
a: ({ href, children }) => (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
),
li: ({ children }) => (
<li className="leading-relaxed">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
{children}
</blockquote>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
if (isBlock) {
return (
<code className={`${className} block`}>
{children}
</code>
);
}
return (
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
{children}
</pre>
),
img: ({ src, alt }) => (
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
),
hr: () => <hr className="border-surface-container-high my-8" />,
table: ({ children }) => (
<div className="overflow-x-auto mb-6">
<table className="w-full text-left text-on-surface-variant">{children}</table>
</div>
),
th: ({ children }) => (
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2">{children}</td>
),
};
// Builds a renderable Post from a raw long-form Nostr event, used when a slug is
// a NIP-19 reference (naddr/nevent/note) that was never indexed by the backend.
function postFromEvent(event: any, slug: string): Post {
const tag = (name: string): string | undefined =>
event.tags?.find((t: string[]) => t[0] === name)?.[1];
const publishedAtSec = Number(tag("published_at")) || event.created_at;
return {
id: event.id,
slug,
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,
naddr: slug.startsWith("naddr") ? slug : undefined,
categories: (event.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => ({ category: { id: t[1], name: t[1], slug: t[1] } })),
};
}
function ArticleSkeleton() {
const widths = [85, 92, 78, 95, 88, 72, 90, 83];
@@ -137,6 +96,18 @@ function ArticleSkeleton() {
);
}
function RelayLoading() {
return (
<div className="flex items-center justify-center gap-3 py-16 text-on-surface-variant">
<span
className="inline-block w-5 h-5 rounded-full border-2 border-primary/30 border-t-primary animate-spin"
aria-hidden
/>
<span className="text-sm font-medium">Fetching from Nostr relays</span>
</div>
);
}
export default function BlogPostClient({ slug }: { slug: string }) {
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
@@ -149,7 +120,13 @@ 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);
const [retryKey, setRetryKey] = useState(0);
const retry = useCallback(() => setRetryKey((k) => k + 1), []);
useEffect(() => {
setHasNostr(hasNostrExtension());
@@ -157,39 +134,77 @@ export default function BlogPostClient({ slug }: { slug: string }) {
useEffect(() => {
if (!slug) return;
let cancelled = false;
setLoading(true);
setError(null);
setLiveContent(null);
setLiveImage(null);
setPost(null);
setAuthorProfile(null);
setContentError(false);
setResolvingFromRelays(false);
// Loads the author's profile, then hydrates the article body from relays
// when only metadata is present (indexed posts store an empty body).
const hydrate = (data: Post) => {
if (cancelled) return;
setPost(data);
setLoading(false);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => !cancelled && setAuthorProfile(profile))
.catch(() => {});
}
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
setLoadingContent(true);
setContentError(false);
const fetchPromise = data.naddr
? fetchLongformFromRelays(data.naddr)
: fetchEventFromRelays(data.nostrEventId!);
fetchPromise
.then((event) => {
if (cancelled) return;
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));
}
};
api
.getPost(slug)
.then((data) => {
setPost(data);
setLoading(false);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => setAuthorProfile(profile))
.catch(() => {});
}
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
setLoadingContent(true);
const fetchPromise = data.naddr
? fetchLongformFromRelays(data.naddr)
: fetchEventFromRelays(data.nostrEventId!);
fetchPromise
.then((event) => {
if (event?.content) {
setLiveContent(event.content);
}
})
.catch(() => {})
.finally(() => setLoadingContent(false));
}
})
.catch((err) => {
setError(err.message);
.then((data) => hydrate(data))
.catch(() => {
// Not indexed: treat the slug itself as a NIP-19 reference and resolve
// the long-form note live from relays.
if (cancelled) return;
setLoading(false);
setResolvingFromRelays(true);
resolveEventFromRelays(slug)
.then((event) => {
if (cancelled) return;
setResolvingFromRelays(false);
if (event) hydrate(postFromEvent(event, slug));
else setError("Post not found");
})
.catch((err) => {
if (cancelled) return;
setResolvingFromRelays(false);
setError(err?.message || "Post not found");
});
});
}, [slug]);
return () => {
cancelled = true;
};
}, [slug, retryKey]);
useEffect(() => {
if (!slug) return;
@@ -254,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 (
<>
@@ -271,9 +287,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
{loading && <ArticleSkeleton />}
{resolvingFromRelays && !post && <RelayLoading />}
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6">
Failed to load post: {error}
<p className="mb-4">Failed to load post: {error}</p>
<button
onClick={retry}
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
>
Try again
</button>
</div>
)}
@@ -309,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>
)}
@@ -326,21 +350,42 @@ 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 ? (
<div className="animate-pulse space-y-4">
{[85, 92, 78, 95, 88, 72, 90, 83].map((w, i) => (
<div
key={i}
className="h-4 bg-surface-container-high rounded"
style={{ width: `${w}%` }}
/>
))}
<RelayLoading />
) : contentError && !(post.content || liveContent) ? (
<div className="text-center py-12">
<p className="text-on-surface-variant mb-4">
Couldn&apos;t fetch this article from the Nostr relays. It may be
temporarily unavailable.
</p>
<button
onClick={retry}
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
>
Try again
</button>
</div>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkNostr]}
components={markdownComponents}
// Preserve nostr: links (react-markdown strips unknown
// schemes by default), so mentions/notes resolve correctly.
urlTransform={(url) =>
url.startsWith("nostr:") ? url : defaultUrlTransform(url)
}
>
{post.content || liveContent || ""}
</ReactMarkdown>
@@ -402,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))}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { nip19 } from "nostr-tools";
import {
loadNostrProfile,
resolveEventFromRelays,
shortenNpub,
type NostrProfile,
} from "@/lib/nostr";
function njump(bech32: string): string {
return `https://njump.me/${bech32}`;
}
// Inline @mention chip: avatar + display name, resolved live from the author's
// kind:0 profile. Falls back to a shortened pubkey until (or unless) it loads.
export function NostrMention({ bech32 }: { bech32: string }) {
const pubkey = useMemo(() => {
try {
const d = nip19.decode(bech32);
if (d.type === "npub") return d.data as string;
if (d.type === "nprofile") return (d.data as { pubkey: string }).pubkey;
} catch {}
return null;
}, [bech32]);
const [profile, setProfile] = useState<NostrProfile | null>(null);
useEffect(() => {
if (!pubkey) return;
let cancelled = false;
loadNostrProfile(pubkey)
.then((p) => !cancelled && setProfile(p))
.catch(() => {});
return () => {
cancelled = true;
};
}, [pubkey]);
// Show the username; fall back to a shortened npub only when no profile.
const name =
profile?.name || profile?.displayName || shortenNpub(pubkey || bech32);
return (
<a
href={njump(bech32)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 align-middle text-primary hover:underline font-medium no-underline"
>
{profile?.picture && (
<img
src={profile.picture}
alt=""
className="w-5 h-5 rounded-full object-cover bg-surface-container-high inline-block"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span>@{name}</span>
</a>
);
}
// 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;
loadNostrProfile(pubkey)
.then((p) => !cancelled && setProfile(p))
.catch(() => {});
return () => {
cancelled = true;
};
}, [pubkey]);
const name = profile?.name || profile?.displayName || shortenNpub(pubkey);
return (
<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 shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="font-semibold text-on-surface text-sm">{name}</span>
</span>
);
}
// Embedded referenced note (note/nevent/naddr). Renders as a bordered card.
// Uses span/block elements (not <div>) so it stays valid when the reference
// sits inside a markdown paragraph.
export function NostrNote({ bech32 }: { bech32: string }) {
const [event, setEvent] = useState<any>(null);
const [state, setState] = useState<"loading" | "done" | "error">("loading");
useEffect(() => {
let cancelled = false;
setState("loading");
resolveEventFromRelays(bech32)
.then((ev) => {
if (cancelled) return;
if (ev) {
setEvent(ev);
setState("done");
} else {
setState("error");
}
})
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, [bech32]);
return (
<span className="block my-4 rounded-xl border border-surface-container-high bg-surface-container-low p-4">
{state === "loading" && (
<span className="flex items-center gap-2 text-on-surface-variant text-sm">
<span className="inline-block w-4 h-4 rounded-full border-2 border-primary/30 border-t-primary animate-spin" />
Loading note
</span>
)}
{state === "error" && (
<a
href={njump(bech32)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm break-all"
>
View referenced note on njump.me
</a>
)}
{state === "done" && event && (
<span className="block">
<span className="flex items-center justify-between mb-3">
<NostrAuthor pubkey={event.pubkey} />
<a
href={njump(bech32)}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-on-surface-variant/60 hover:text-primary"
>
View note
</a>
</span>
<span className="block whitespace-pre-wrap break-words text-on-surface-variant text-sm leading-relaxed">
{(event.content || "").slice(0, 1000)}
{(event.content || "").length > 1000 ? "…" : ""}
</span>
</span>
)}
</span>
);
}
// Dispatches a `nostr:<bech32>` reference to the right embed by entity type.
export function NostrEntity({ bech32 }: { bech32: string }) {
let type: string;
try {
type = nip19.decode(bech32).type;
} catch {
return <>nostr:{bech32}</>;
}
if (type === "npub" || type === "nprofile") return <NostrMention bech32={bech32} />;
if (type === "note" || type === "nevent" || type === "naddr") return <NostrNote bech32={bech32} />;
return <>nostr:{bech32}</>;
}
@@ -0,0 +1,89 @@
import type { Components } from "react-markdown";
import { NostrEntity } from "./NostrEmbeds";
// Shared markdown renderers for blog article bodies. The `remarkNostr` plugin
// rewrites NIP-27 `nostr:` references into links with that scheme, which the
// `a` renderer below swaps for live profile/note embeds.
export const markdownComponents: Components = {
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
),
p: ({ children }) => (
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
),
a: ({ href, children }) => {
if (href?.startsWith("nostr:")) {
return <NostrEntity bech32={href.slice("nostr:".length)} />;
}
return (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
},
ul: ({ children }) => (
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
),
li: ({ children }) => (
<li className="leading-relaxed">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
{children}
</blockquote>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
if (isBlock) {
return (
<code className={`${className} block`}>
{children}
</code>
);
}
return (
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
{children}
</pre>
),
img: ({ src, alt }) => (
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
),
hr: () => <hr className="border-surface-container-high my-8" />,
table: ({ children }) => (
<div className="overflow-x-auto mb-6">
<table className="w-full text-left text-on-surface-variant">{children}</table>
</div>
),
th: ({ children }) => (
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2">{children}</td>
),
};
+3 -1
View File
@@ -30,7 +30,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
post.excerpt ||
`Read "${post.title}" on the Belgian Bitcoin Embassy blog.`;
const author = post.authorName || "Belgian Bitcoin Embassy";
const ogImageUrl = `/og?title=${encodeURIComponent(post.title)}&type=blog`;
// Prefer the post's own header image (Nostr `image` tag); fall back to a
// generated OG card so every post still gets a real preview image.
const ogImageUrl = post.image || `/og?title=${encodeURIComponent(post.title)}&type=blog`;
return {
title: post.title,
+64
View File
@@ -0,0 +1,64 @@
import { nip19 } from "nostr-tools";
// Matches NIP-27 `nostr:` references (and bare bech32 entities) embedded in
// article text: npub/nprofile (mentions) and note/nevent/naddr (notes).
const NOSTR_RE =
/(?:nostr:)?((?:npub|nprofile|note|nevent|naddr)1[023456789acdefghjklmnpqrstuvwxyz]+)/gi;
function isValidEntity(bech32: string): boolean {
try {
const { type } = nip19.decode(bech32);
return ["npub", "nprofile", "note", "nevent", "naddr"].includes(type);
} catch {
return false;
}
}
// Splits a plain-text value into text + `link` nodes, where each link's url is
// `nostr:<bech32>`. The markdown `a` renderer detects that scheme and swaps in
// the live profile/note component.
function splitText(value: string): any[] {
const out: any[] = [];
let last = 0;
NOSTR_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = NOSTR_RE.exec(value)) !== null) {
const bech32 = m[1];
if (!isValidEntity(bech32)) continue;
if (m.index > last) {
out.push({ type: "text", value: value.slice(last, m.index) });
}
out.push({
type: "link",
url: `nostr:${bech32}`,
children: [{ type: "text", value: m[0] }],
});
last = m.index + m[0].length;
}
if (out.length === 0) return [{ type: "text", value }];
if (last < value.length) out.push({ type: "text", value: value.slice(last) });
return out;
}
// Remark plugin: walk the mdast tree and replace `nostr:` tokens inside text
// nodes. Skips code and existing links so identifiers there are left untouched.
export function remarkNostr() {
return (tree: any) => {
const walk = (node: any) => {
if (!node || !Array.isArray(node.children)) return;
const next: any[] = [];
for (const child of node.children) {
if (child.type === "text") {
next.push(...splitText(child.value));
continue;
}
if (child.type !== "link" && child.type !== "inlineCode" && child.type !== "code") {
walk(child);
}
next.push(child);
}
node.children = next;
};
walk(tree);
};
}
+40 -252
View File
@@ -1,109 +1,45 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { ArrowRight, ArrowLeft, ChevronRight } from "lucide-react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import {
BlogIndex,
type BlogPost,
type BlogCategory,
} from "@/components/public/BlogIndex";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
import { apiUrl } from "@/lib/api-base";
interface Post {
id: string;
slug: string;
title: string;
excerpt?: string;
content?: string;
author?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
featured?: boolean;
const LIMIT = 9;
// Render at request time so the first page of posts is in the HTML for crawlers.
export const dynamic = "force-dynamic";
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
try {
const res = await fetch(apiUrl(path), { cache: "no-store" });
if (!res.ok) return fallback;
return (await res.json()) as T;
} catch {
return fallback;
}
}
interface Category {
id: string;
name: string;
slug: string;
}
function PostCardSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse">
<div className="p-6 space-y-4">
<div className="flex gap-2">
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
</div>
<div className="h-7 w-3/4 bg-surface-container-high rounded" />
<div className="space-y-2">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-2/3 bg-surface-container-high rounded" />
</div>
<div className="flex justify-between items-center pt-4">
<div className="h-4 w-32 bg-surface-container-high rounded" />
<div className="h-4 w-24 bg-surface-container-high rounded" />
</div>
</div>
</div>
);
}
function FeaturedPostSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse mb-12">
<div className="p-8 md:p-12 space-y-4">
<div className="h-5 w-24 bg-surface-container-high rounded-full" />
<div className="h-10 w-2/3 bg-surface-container-high rounded" />
<div className="space-y-2 max-w-2xl">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-1/2 bg-surface-container-high rounded" />
</div>
<div className="h-4 w-48 bg-surface-container-high rounded" />
</div>
</div>
);
}
export default function BlogPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [activeCategory, setActiveCategory] = useState<string>("all");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const limit = 9;
useEffect(() => {
api.getCategories().then(setCategories).catch(() => {});
}, []);
useEffect(() => {
setLoading(true);
setError(null);
api
.getPosts({
category: activeCategory === "all" ? undefined : activeCategory,
page,
limit,
})
.then(({ posts: data, total: t }) => {
setPosts(data);
setTotal(t);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [activeCategory, page]);
const totalPages = Math.ceil(total / limit);
const featured = posts.find((p) => p.featured);
const regularPosts = featured ? posts.filter((p) => p.id !== featured.id) : posts;
export default async function BlogPage() {
const [{ posts, total }, categories] = await Promise.all([
fetchJson<{ posts: BlogPost[]; total: number }>(`/posts?page=1&limit=${LIMIT}`, {
posts: [],
total: 0,
}),
fetchJson<BlogCategory[]>("/categories", []),
]);
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Blog", href: "/blog" },
]}
/>
<Navbar />
<div className="min-h-screen">
@@ -121,160 +57,12 @@ export default function BlogPage() {
</div>
</header>
<div className="max-w-7xl mx-auto px-8 mb-12">
<div className="flex flex-wrap gap-3">
<button
onClick={() => { setActiveCategory("all"); setPage(1); }}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === "all"
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => { setActiveCategory(cat.slug); setPage(1); }}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === cat.slug
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
{cat.name}
</button>
))}
</div>
</div>
<div className="max-w-7xl mx-auto px-8 pb-24">
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6 mb-8">
Failed to load posts: {error}
</div>
)}
{loading ? (
<>
<FeaturedPostSkeleton />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
</>
) : posts.length === 0 ? (
<div className="text-center py-24">
<p className="text-2xl font-bold text-on-surface-variant mb-2">
No posts yet
</p>
<p className="text-on-surface-variant/60">
Check back soon for curated Bitcoin content.
</p>
</div>
) : (
<>
{featured && page === 1 && (
<Link
href={`/blog/${featured.slug}`}
className="block bg-surface-container-low rounded-xl overflow-hidden mb-12 group hover:bg-surface-container-high transition-colors"
>
<div className="p-8 md:p-12">
<span className="inline-block px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full mb-6">
Featured
</span>
<h2 className="text-3xl md:text-4xl font-black tracking-tight mb-4 group-hover:text-primary transition-colors">
{featured.title}
</h2>
{featured.excerpt && (
<p className="text-on-surface-variant text-lg leading-relaxed max-w-2xl mb-6">
{featured.excerpt}
</p>
)}
<div className="flex items-center gap-4 text-sm text-on-surface-variant/60">
{featured.author && <span>{featured.author}</span>}
{featured.publishedAt && (
<span>{formatDate(featured.publishedAt)}</span>
)}
</div>
</div>
</Link>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{regularPosts.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
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"
>
{post.categories && post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((pc) => (
<span
key={pc.category.id}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{pc.category.name}
</span>
))}
</div>
)}
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
{post.title}
</h3>
{post.excerpt && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
{post.excerpt}
</p>
)}
<div className="flex items-center justify-between mt-auto pt-4 border-t border-zinc-800/60">
<div className="flex items-center gap-2 text-xs text-on-surface-variant/50">
{post.author && <span>{post.author}</span>}
{post.author && (post.publishedAt || post.createdAt) && <span>·</span>}
{(post.publishedAt || post.createdAt) && (
<span>
{formatDate(post.publishedAt || post.createdAt!)}
</span>
)}
</div>
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all">
Read <ArrowRight size={12} />
</span>
</div>
</Link>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 mt-16">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowLeft size={16} /> Previous
</button>
<span className="text-sm text-on-surface-variant">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
Next <ChevronRight size={16} />
</button>
</div>
)}
</>
)}
</div>
<BlogIndex
initialPosts={Array.isArray(posts) ? posts : []}
initialTotal={total ?? 0}
categories={Array.isArray(categories) ? categories : []}
limit={LIMIT}
/>
</div>
<Footer />
+12 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Message Board — Pay with Lightning",
@@ -12,5 +13,15 @@ export const metadata: Metadata = {
};
export default function BoardLayout({ children }: { children: React.ReactNode }) {
return children;
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Board", href: "/board" },
]}
/>
{children}
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { buildCommunityMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildCommunityMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+12 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Community - Connect with Belgian Bitcoiners",
@@ -13,5 +14,15 @@ export const metadata: Metadata = {
};
export default function CommunityLayout({ children }: { children: React.ReactNode }) {
return children;
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Community", href: "/community" },
]}
/>
{children}
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { buildContactMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildContactMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+7
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { ContactChannelGrid } from "@/components/public/ContactChannelGrid";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Contact Us",
@@ -18,6 +19,12 @@ export const metadata: Metadata = {
export default function ContactPage() {
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Contact", href: "/contact" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
+50 -53
View File
@@ -5,7 +5,7 @@ import Image from "next/image";
import { Send, FileText, Clock, CheckCircle, XCircle, Plus, User, Loader2, AtSign, Radio, Trash2, Download, Eye, Pencil } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { shortenPubkey } from "@/lib/nostr";
import { shortenPubkey, fetchEventFromRelays, fetchLongformFromRelays } from "@/lib/nostr";
import { formatDate } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
@@ -61,9 +61,7 @@ export default function DashboardPage() {
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [loadingSubs, setLoadingSubs] = useState(true);
const [showForm, setShowForm] = useState(false);
const [title, setTitle] = useState("");
const [eventId, setEventId] = useState("");
const [naddr, setNaddr] = useState("");
const [noteInput, setNoteInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [formSuccess, setFormSuccess] = useState("");
@@ -125,31 +123,56 @@ export default function DashboardPage() {
loadRelays();
}, [loadSubmissions, loadRelays]);
const extractTitle = (event: any): string => {
const titleTag = event?.tags?.find((t: string[]) => t[0] === "title");
return titleTag?.[1]?.trim() || "Untitled";
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError("");
setFormSuccess("");
if (!title.trim()) {
setFormError("Title is required");
return;
}
if (!eventId.trim() && !naddr.trim()) {
setFormError("Either an Event ID or naddr is required");
const input = noteInput.trim();
if (!input) {
setFormError("A Note ID or naddr is required");
return;
}
setSubmitting(true);
try {
await api.createSubmission({
title: title.trim(),
eventId: eventId.trim() || undefined,
naddr: naddr.trim() || undefined,
});
setFormSuccess("Submission sent for review!");
setTitle("");
setEventId("");
setNaddr("");
let payload: { title: string; eventId?: string; naddr?: string };
if (input.startsWith("naddr1")) {
const event = await fetchLongformFromRelays(input);
if (!event) {
throw new Error("Could not find that article on the relays");
}
payload = { title: extractTitle(event), naddr: input };
} else {
let hexId = input;
if (input.startsWith("note1")) {
try {
const { nip19 } = await import("nostr-tools");
const decoded = nip19.decode(input);
if (decoded.type !== "note") throw new Error();
hexId = decoded.data as string;
} catch {
throw new Error("Invalid note id");
}
} else if (!/^[0-9a-f]{64}$/i.test(input)) {
throw new Error("Enter a valid note id, naddr, or hex event id");
}
const event = await fetchEventFromRelays(hexId);
if (!event) {
throw new Error("Could not find that note on the relays");
}
payload = { title: extractTitle(event), eventId: hexId };
}
await api.createSubmission(payload);
setFormSuccess(`Submission "${payload.title}" sent for review!`);
setNoteInput("");
setShowForm(false);
await loadSubmissions();
} catch (err: any) {
@@ -381,46 +404,20 @@ export default function DashboardPage() {
className="bg-surface-container-low rounded-xl p-6 mb-8 space-y-4"
>
<p className="text-on-surface-variant text-sm mb-2">
Submit a Nostr longform post for moderator review. Provide the
event ID or naddr of the article you&apos;d like published on the
blog.
Submit a Nostr longform post for moderator review. Paste the
note ID or naddr of the article you&apos;d like published on the
blog. The title is pulled automatically from the note.
</p>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Title
Note ID or naddr
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Bitcoin Article"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Nostr Event ID
</label>
<input
type="text"
value={eventId}
onChange={(e) => setEventId(e.target.value)}
placeholder="note1... or hex event id"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Or naddr
</label>
<input
type="text"
value={naddr}
onChange={(e) => setNaddr(e.target.value)}
placeholder="naddr1..."
value={noteInput}
onChange={(e) => setNoteInput(e.target.value)}
placeholder="naddr1... or note1... or hex event id"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
@@ -438,7 +435,7 @@ export default function DashboardPage() {
>
<span className="flex items-center gap-2">
<Send size={16} />
{submitting ? "Submitting..." : "Submit for Review"}
{submitting ? "Fetching & submitting..." : "Submit for Review"}
</span>
</Button>
<Button
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
// Route-segment error boundary. Catches runtime errors thrown while rendering any
// page/segment (e.g. a request-time fetch or a bad build artifact) and shows a
// recoverable UI with a retry, instead of a bare 500.
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Route error boundary caught:", error);
}, [error]);
return (
<div className="min-h-screen flex flex-col items-center justify-center px-8">
<span className="text-7xl md:text-9xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-primary to-primary-container leading-none">
Oops
</span>
<h1 className="text-2xl md:text-3xl font-bold mt-6 mb-3">
Something went wrong
</h1>
<p className="text-on-surface-variant mb-10 text-center max-w-md">
This page hit an unexpected error. It&apos;s usually temporary try again
in a moment.
</p>
<div className="flex flex-wrap gap-3 justify-center">
<button
onClick={() => reset()}
className="bg-gradient-to-r from-primary to-primary-container text-on-primary px-8 py-3 rounded-lg font-bold hover:scale-105 transition-transform"
>
Try again
</button>
<Link
href="/"
className="border border-zinc-700 px-8 py-3 rounded-lg font-bold hover:bg-zinc-800/50 transition-colors"
>
Back to Home
</Link>
</div>
{error?.digest && (
<p className="text-on-surface-variant/50 text-xs mt-8 font-mono">
ref: {error.digest}
</p>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { buildEventsMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildEventsMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+12 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import EventDetailClient from "./EventDetailClient";
import { EventJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
import { apiUrl } from "@/lib/api-base";
import { getMeetupStartUtc, getMeetupEndUtc } from "@/lib/meetupEventTime";
async function fetchEvent(id: string) {
try {
@@ -59,6 +60,15 @@ export default async function EventDetailPage({ params }: Props) {
const event = await fetchEvent(id);
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
let startDate = event?.date;
let endDate: string | undefined;
if (event?.date) {
const start = getMeetupStartUtc(event.date, event.time || "00:00");
if (!Number.isNaN(start.getTime())) startDate = start.toISOString();
const end = getMeetupEndUtc(event.date, event.time || "");
if (end && !Number.isNaN(end.getTime())) endDate = end.toISOString();
}
return (
<>
{event && (
@@ -66,7 +76,8 @@ export default async function EventDetailPage({ params }: Props) {
<EventJsonLd
name={event.title}
description={event.description}
startDate={event.date}
startDate={startDate}
endDate={endDate}
location={event.location}
url={`${siteUrl}/events/${id}`}
imageUrl={event.imageId ? `${siteUrl}/media/${event.imageId}` : undefined}
+12 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Events - Bitcoin Meetups in Belgium",
@@ -13,5 +14,15 @@ export const metadata: Metadata = {
};
export default function EventsLayout({ children }: { children: React.ReactNode }) {
return children;
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Events", href: "/events" },
]}
/>
{children}
</>
);
}
+23 -76
View File
@@ -1,60 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { MeetupCard } from "@/components/public/MeetupCard";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
import { fetchMeetupsLive, partitionMeetups } from "@/lib/meetupsData";
function CardSkeleton() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 animate-pulse">
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 rounded-lg w-[52px] h-[58px] shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-zinc-800 rounded w-3/4" />
<div className="h-3 bg-zinc-800 rounded w-1/2" />
</div>
</div>
<div className="space-y-2 mb-4">
<div className="h-3 bg-zinc-800 rounded w-full" />
<div className="h-3 bg-zinc-800 rounded w-5/6" />
</div>
</div>
);
}
// Render at request time from the live backend so crawlers (no JS) always get
// the real list, and the count stays in lock-step with /events.md and /llms.txt.
export const dynamic = "force-dynamic";
export default function EventsPage() {
const [meetups, setMeetups] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.getMeetups()
.then((data: any) => {
const list = Array.isArray(data) ? data : [];
setMeetups(list);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const now = new Date();
const upcoming = meetups.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start >= now;
});
const past = meetups
.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start < now;
})
.reverse();
export default async function EventsPage() {
const meetups = await fetchMeetupsLive();
const { upcoming, past } = partitionMeetups(meetups);
return (
<>
@@ -74,18 +30,16 @@ export default function EventsPage() {
</div>
</header>
<div className="max-w-6xl mx-auto px-8 pb-24 space-y-20">
{error && (
<div className="bg-red-900/20 text-red-400 rounded-xl p-6 text-sm">
Failed to load events: {error}
</div>
)}
<div
className="max-w-6xl mx-auto px-8 pb-24 space-y-20"
data-upcoming-count={upcoming.length}
data-past-count={past.length}
>
<div>
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-black flex items-center gap-3">
Upcoming
{!loading && upcoming.length > 0 && (
{upcoming.length > 0 && (
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
{upcoming.length}
</span>
@@ -94,11 +48,7 @@ export default function EventsPage() {
<AddToCalendarButton />
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => <CardSkeleton key={i} />)}
</div>
) : upcoming.length === 0 ? (
{upcoming.length === 0 ? (
<div className="border border-zinc-800/60 rounded-xl px-8 py-12 text-center">
<p className="text-on-surface-variant text-sm">
No upcoming events scheduled. Check back soon.
@@ -106,26 +56,23 @@ export default function EventsPage() {
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{upcoming.map((m) => <MeetupCard key={m.id} meetup={m} />)}
{upcoming.map((m) => (
<MeetupCard key={m.id} meetup={m} />
))}
</div>
)}
</div>
{(loading || past.length > 0) && (
{past.length > 0 && (
<div>
<h2 className="text-xl font-black mb-8 text-on-surface-variant/60">
Past Events
</h2>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => <CardSkeleton key={i} />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{past.map((m) => <MeetupCard key={m.id} meetup={m} muted />)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{past.map((m) => (
<MeetupCard key={m.id} meetup={m} muted />
))}
</div>
</div>
)}
</div>
+10
View File
@@ -0,0 +1,10 @@
import { buildFaqMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildFaqMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+31 -70
View File
@@ -1,12 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { FaqPageJsonLd } from "@/components/public/JsonLd";
import { FaqAccordion } from "@/components/public/FaqAccordion";
import { FaqPageJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
import { apiUrl } from "@/lib/api-base";
interface FaqItem {
id: string;
@@ -16,25 +12,37 @@ interface FaqItem {
showOnHomepage: boolean;
}
export default function FaqPage() {
const [items, setItems] = useState<FaqItem[]>([]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
// Render at request time from the live backend so the Q&A and FAQPage JSON-LD
// are always present in the HTML for crawlers, never a build-time-empty shell.
export const dynamic = "force-dynamic";
useEffect(() => {
api.getFaqsAll()
.then((data) => {
if (Array.isArray(data)) setItems(data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
async function fetchFaqs(): Promise<FaqItem[]> {
try {
const res = await fetch(apiUrl("/faqs?all=true"), { cache: "no-store" });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export default async function FaqPage() {
const items = await fetchFaqs();
return (
<>
{items.length > 0 && (
<FaqPageJsonLd items={items.map((i) => ({ question: i.question, answer: i.answer }))} />
<FaqPageJsonLd
items={items.map((i) => ({ question: i.question, answer: i.answer }))}
/>
)}
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "FAQ", href: "/faq" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
@@ -43,57 +51,10 @@ export default function FaqPage() {
Everything you need to know about the Belgian Bitcoin Embassy.
</p>
{loading && (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-surface-container-low rounded-xl h-[72px] animate-pulse" />
))}
</div>
)}
{!loading && items.length === 0 && (
{items.length === 0 ? (
<p className="text-on-surface-variant">No FAQs available yet.</p>
)}
{!loading && items.length > 0 && (
<div className="space-y-4">
{items.map((item, i) => {
const isOpen = openIndex === i;
return (
<div
key={item.id}
className="bg-surface-container-low rounded-xl overflow-hidden"
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
className="w-full flex items-center justify-between p-6 text-left"
>
<span className="text-lg font-bold pr-4">{item.question}</span>
<ChevronDown
size={20}
className={cn(
"shrink-0 text-primary transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"grid transition-all duration-200",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<p className="px-6 pb-6 text-on-surface-variant leading-relaxed">
{item.answer}
</p>
</div>
</div>
</div>
);
})}
</div>
) : (
<FaqAccordion items={items} />
)}
</div>
</div>
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect } from "react";
// Last-resort boundary: catches errors thrown in the root layout itself. It
// replaces the whole document, so it must render its own <html>/<body> and can't
// rely on the app's global CSS — hence inline styles.
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Global error boundary caught:", error);
}, [error]);
return (
<html lang="en">
<body
style={{
margin: 0,
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
padding: "2rem",
background: "#0a0a0a",
color: "#ededed",
fontFamily:
"ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
textAlign: "center",
}}
>
<h1 style={{ fontSize: "1.75rem", fontWeight: 800, margin: 0 }}>
Something went wrong
</h1>
<p style={{ color: "#a1a1aa", maxWidth: "28rem", margin: 0 }}>
The site hit an unexpected error. Please try again in a moment.
</p>
<button
onClick={() => reset()}
style={{
marginTop: "0.5rem",
padding: "0.75rem 2rem",
borderRadius: "0.5rem",
border: "none",
fontWeight: 700,
cursor: "pointer",
background: "#f7931a",
color: "#0a0a0a",
}}
>
Try again
</button>
{error?.digest && (
<p
style={{
color: "#71717a",
fontSize: "0.75rem",
fontFamily: "ui-monospace, monospace",
}}
>
ref: {error.digest}
</p>
)}
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { buildHomeMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildHomeMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+5 -13
View File
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { ClientProviders } from "@/components/providers/ClientProviders";
import { OrganizationJsonLd, WebSiteJsonLd } from "@/components/public/JsonLd";
import { fetchPublicSettings, socialUrlsFromSettings } from "@/lib/seo";
import "./globals.css";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
@@ -24,17 +25,6 @@ export const metadata: Metadata = {
},
description:
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
keywords: [
"Bitcoin",
"Belgium",
"Antwerp",
"Bitcoin meetup",
"Bitcoin education",
"Nostr",
"Belgian Bitcoin Embassy",
"Bitcoin community Belgium",
"Bitcoin events Antwerp",
],
authors: [{ name: "Belgian Bitcoin Embassy" }],
creator: "Belgian Bitcoin Embassy",
publisher: "Belgian Bitcoin Embassy",
@@ -90,7 +80,9 @@ export const viewport: Viewport = {
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const settings = await fetchPublicSettings();
const sameAs = socialUrlsFromSettings(settings);
return (
<html lang="en" dir="ltr" className="dark">
<body>
@@ -102,7 +94,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
strategy="afterInteractive"
/>
) : null}
<OrganizationJsonLd />
<OrganizationJsonLd sameAs={sameAs} />
<WebSiteJsonLd />
<ClientProviders>{children}</ClientProviders>
</body>
+10
View File
@@ -0,0 +1,10 @@
import { buildLlmsTxt } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildLlmsTxt();
return new Response(body, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
+6 -6
View File
@@ -38,11 +38,11 @@ export default function LoginPage() {
const [bunkerInput, setBunkerInput] = useState("");
useEffect(() => {
if (!loading && user) redirectByRole(user.role);
if (!loading && user) redirectFor(user);
}, [user, loading]);
function redirectByRole(role: string) {
if (role === "ADMIN" || role === "MODERATOR") {
function redirectFor(u: { isSuperAdmin?: boolean; permissions?: string[] }) {
if (u.isSuperAdmin || (u.permissions?.length ?? 0) > 0) {
router.push("/admin/overview");
} else {
router.push("/dashboard");
@@ -78,7 +78,7 @@ export default function LoginPage() {
setLoggingIn(true);
const loggedInUser = await loginWithConnectedSigner(signer);
await signer.close().catch(() => {});
redirectByRole(loggedInUser.role);
redirectFor(loggedInUser);
} catch (err: any) {
if (controller.signal.aborted) return;
setError(err.message || "Connection failed");
@@ -104,7 +104,7 @@ export default function LoginPage() {
setLoggingIn(true);
try {
const loggedInUser = await login();
redirectByRole(loggedInUser.role);
redirectFor(loggedInUser);
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
@@ -118,7 +118,7 @@ export default function LoginPage() {
setLoggingIn(true);
try {
const loggedInUser = await loginWithBunker(bunkerInput.trim());
redirectByRole(loggedInUser.role);
redirectFor(loggedInUser);
} catch (err: any) {
setError(err.message || "Connection failed");
} finally {
+61 -39
View File
@@ -180,50 +180,72 @@ function handleVideoStream(
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
// Media blobs are stored as ULIDs (Crockford base32, 26 chars). Reject anything
// else before joining into the storage path so `../` cannot escape the root.
const MEDIA_ID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
function resolveSafeMediaPath(root: string, id: string): string | null {
if (!MEDIA_ID_RE.test(id)) return null;
const resolvedRoot = path.resolve(root);
const filePath = path.resolve(resolvedRoot, id);
const rootPrefix = resolvedRoot.endsWith(path.sep)
? resolvedRoot
: resolvedRoot + path.sep;
if (filePath !== resolvedRoot && !filePath.startsWith(rootPrefix)) {
return null;
}
return filePath;
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> | { id: string } }
) {
const params = await Promise.resolve(context.params);
const id = params.id;
if (!id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const root = getMediaStorageRoot();
const filePath = path.join(root, id);
if (!fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const meta = readMeta(root, id);
if (!meta) {
return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const widthParam = searchParams.get('w');
if (meta.type === 'image' && widthParam) {
const width = parseInt(widthParam, 10);
if (isNaN(width) || width < 1 || width > 4096) {
return NextResponse.json({ error: 'Invalid width' }, { status: 400 });
try {
const params = await Promise.resolve(context.params);
const id = params.id;
if (!id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return handleImageResize(root, filePath, width, meta, id);
}
if (meta.type === 'video') {
const rangeHeader = request.headers.get('range');
return handleVideoStream(filePath, meta, rangeHeader);
}
const root = getMediaStorageRoot();
const filePath = resolveSafeMediaPath(root, id);
if (!filePath || !fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const buffer = fs.readFileSync(filePath);
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(buffer.length),
...CACHE_HEADERS,
},
});
const meta = readMeta(root, id);
if (!meta) {
return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const widthParam = searchParams.get('w');
if (meta.type === 'image' && widthParam) {
const width = parseInt(widthParam, 10);
if (isNaN(width) || width < 1 || width > 4096) {
return NextResponse.json({ error: 'Invalid width' }, { status: 400 });
}
return await handleImageResize(root, filePath, width, meta, id);
}
if (meta.type === 'video') {
const rangeHeader = request.headers.get('range');
return handleVideoStream(filePath, meta, rangeHeader);
}
const buffer = fs.readFileSync(filePath);
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(buffer.length),
...CACHE_HEADERS,
},
});
} catch (err) {
console.error('Media serve error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+7
View File
@@ -0,0 +1,7 @@
import { PRIVACY_MARKDOWN } from "@/lib/content/legalMarkdown";
export async function GET() {
return new Response(PRIVACY_MARKDOWN, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+7
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Privacy Policy",
@@ -18,6 +19,12 @@ export const metadata: Metadata = {
export default function PrivacyPage() {
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Privacy Policy", href: "/privacy" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
+7
View File
@@ -0,0 +1,7 @@
import { TERMS_MARKDOWN } from "@/lib/content/legalMarkdown";
export async function GET() {
return new Response(TERMS_MARKDOWN, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+7
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Terms of Use",
@@ -18,6 +19,12 @@ export const metadata: Metadata = {
export default function TermsPage() {
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Terms of Use", href: "/terms" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
+39 -25
View File
@@ -21,36 +21,47 @@ import {
HelpCircle,
MessageSquare,
Building2,
KeyRound,
Key,
} from "lucide-react";
const navItems = [
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard, adminOnly: false },
{ href: "/admin/events", label: "Events", icon: Calendar, adminOnly: false },
{ href: "/admin/organizers", label: "Organizers", icon: Building2, adminOnly: false },
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, adminOnly: false },
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, adminOnly: false },
{ href: "/admin/messages", label: "Board", icon: MessageSquare, adminOnly: false },
{ href: "/admin/moderation", label: "Moderation", icon: Shield, adminOnly: false },
{ href: "/admin/categories", label: "Categories", icon: Tag, adminOnly: false },
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
{ href: "/admin/relays", label: "Relays", icon: Radio, adminOnly: true },
{ href: "/admin/settings", label: "Settings", icon: Settings, adminOnly: true },
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, adminOnly: true },
// Each item is shown when the user holds any of its permissions. Items with no
// permissions are always visible. SuperAdmin sees everything via can().
const navItems: {
href: string;
label: string;
icon: typeof LayoutDashboard;
permissions?: string[];
}[] = [
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard },
{ href: "/admin/events", label: "Events", icon: Calendar, permissions: ["events.create", "events.edit", "events.delete"] },
{ href: "/admin/organizers", label: "Organizers", icon: Building2, permissions: ["organizers.manage"] },
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, permissions: ["gallery.upload", "gallery.delete"] },
{ href: "/admin/blog", label: "Blog", icon: FileText, permissions: ["blog.draft", "blog.publish", "blog.delete"] },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, permissions: ["faq.manage"] },
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, permissions: ["submissions.review"] },
{ href: "/admin/messages", label: "Board", icon: MessageSquare, permissions: ["board.manage"] },
{ href: "/admin/moderation", label: "Moderation", icon: Shield, permissions: ["moderation.act"] },
{ href: "/admin/categories", label: "Categories", icon: Tag, permissions: ["categories.manage"] },
{ href: "/admin/users", label: "Users", icon: Users, permissions: ["users.assign_role", "nip05.assign"] },
{ href: "/admin/roles", label: "Roles", icon: KeyRound, permissions: ["roles.edit_permissions"] },
{ href: "/admin/api-keys", label: "API Keys", icon: Key, permissions: ["api_keys.manage"] },
{ href: "/admin/relays", label: "Relays", icon: Radio, permissions: ["relays.manage"] },
{ href: "/admin/settings", label: "Settings", icon: Settings, permissions: ["settings.edit"] },
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, permissions: ["nostr_tools.use"] },
];
export function AdminSidebar() {
const pathname = usePathname();
const { user, logout, isAdmin } = useAuth();
const { user, logout, can } = useAuth();
const shortPubkey = user?.pubkey
? `${user.pubkey.slice(0, 8)}...${user.pubkey.slice(-8)}`
: "";
return (
<aside className="w-64 bg-surface-container-lowest min-h-screen p-6 flex flex-col shrink-0">
<div className="mb-8">
<aside className="w-64 bg-surface-container-lowest h-screen sticky top-0 p-6 flex flex-col shrink-0">
<div className="mb-8 shrink-0">
<Link href="/" className="text-primary-container font-bold text-xl">
BBE Admin
</Link>
@@ -64,23 +75,26 @@ export function AdminSidebar() {
</div>
) : (
<>
<div className="mb-6">
<div className="mb-6 shrink-0">
<p className="text-on-surface/70 text-sm font-mono truncate">{shortPubkey}</p>
<span
className={cn(
"inline-block mt-1 rounded-full px-3 py-1 text-xs font-bold",
user.role === "ADMIN"
"inline-block mt-1 rounded-full px-3 py-1 text-xs font-bold capitalize",
user.isSuperAdmin || user.role === "admin"
? "bg-primary-container/20 text-primary"
: "bg-secondary-container text-on-secondary-container"
)}
>
{user.role}
{user.isSuperAdmin ? "SuperAdmin" : user.role}
</span>
</div>
<nav className="flex-1 space-y-1">
<nav className="flex-1 overflow-y-auto min-h-0 space-y-1">
{navItems
.filter((item) => !item.adminOnly || isAdmin)
.filter(
(item) =>
!item.permissions || item.permissions.some((p) => can(p))
)
.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
@@ -102,7 +116,7 @@ export function AdminSidebar() {
})}
</nav>
<div className="mt-auto space-y-2 pt-6">
<div className="shrink-0 space-y-2 pt-6">
<button
onClick={logout}
className="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-on-surface/70 hover:text-on-surface hover:bg-surface-container w-full"
+62
View File
@@ -0,0 +1,62 @@
"use client";
import Image from "next/image";
import { useNostrProfile } from "@/hooks/useNostrProfile";
import type { NostrProfile } from "@/lib/nostr";
function computeInitials(profile: NostrProfile | null, fallback?: string): string {
const name = profile?.name || profile?.displayName;
if (name?.trim()) return name.trim().slice(0, 2).toUpperCase();
const fb = fallback?.trim();
if (fb) {
// For npub-style fallbacks skip the "npub1" prefix for nicer initials.
if (fb.startsWith("npub1") && fb.length >= 8) return fb.slice(5, 7).toUpperCase();
return fb.slice(0, 2).toUpperCase();
}
return "?";
}
export interface NostrAvatarProps {
pubkey: string | null | undefined;
/** Rendered size in pixels (square). */
size?: number;
/** Text used to derive initials when no Nostr name/picture is available. */
fallbackText?: string;
className?: string;
}
// Self-contained avatar: fetches the user's Nostr metadata from relays and
// renders their profile picture, falling back to initials.
export function NostrAvatar({
pubkey,
size = 56,
fallbackText,
className = "",
}: NostrAvatarProps) {
const { profile, loading } = useNostrProfile(pubkey);
const displayName = profile?.name || profile?.displayName;
return (
<div
className={`shrink-0 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden text-on-surface ${className}`}
style={{ width: size, height: size }}
>
{loading ? (
<span className="text-on-surface/40 text-xs"></span>
) : profile?.picture ? (
<Image
src={profile.picture}
alt={displayName ? `Avatar: ${displayName}` : "Nostr profile picture"}
width={size}
height={size}
className="object-cover w-full h-full"
unoptimized
/>
) : (
<span className="font-semibold text-sm" aria-hidden>
{computeInitials(profile, fallbackText)}
</span>
)}
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight, ArrowLeft, ChevronRight } from "lucide-react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
export interface BlogPost {
id: string;
slug: string;
title: string;
excerpt?: string;
author?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
featured?: boolean;
}
export interface BlogCategory {
id: string;
name: string;
slug: string;
}
interface BlogIndexProps {
initialPosts: BlogPost[];
initialTotal: number;
categories: BlogCategory[];
limit: number;
}
function PostCardSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse">
<div className="p-6 space-y-4">
<div className="flex gap-2">
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
</div>
<div className="h-7 w-3/4 bg-surface-container-high rounded" />
<div className="space-y-2">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-2/3 bg-surface-container-high rounded" />
</div>
<div className="flex justify-between items-center pt-4">
<div className="h-4 w-32 bg-surface-container-high rounded" />
<div className="h-4 w-24 bg-surface-container-high rounded" />
</div>
</div>
</div>
);
}
export function BlogIndex({
initialPosts,
initialTotal,
categories,
limit,
}: BlogIndexProps) {
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
const [total, setTotal] = useState(initialTotal);
const [activeCategory, setActiveCategory] = useState<string>("all");
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isFirst = useRef(true);
useEffect(() => {
// The initial ("all", page 1) data is already rendered from the server.
if (isFirst.current) {
isFirst.current = false;
return;
}
let cancelled = false;
setLoading(true);
setError(null);
api
.getPosts({
category: activeCategory === "all" ? undefined : activeCategory,
page,
limit,
})
.then(({ posts: data, total: t }) => {
if (cancelled) return;
setPosts(data);
setTotal(t);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [activeCategory, page, limit]);
const totalPages = Math.ceil(total / limit);
const featured = posts.find((p) => p.featured);
const regularPosts = featured ? posts.filter((p) => p.id !== featured.id) : posts;
return (
<>
<div className="max-w-7xl mx-auto px-8 mb-12">
<div className="flex flex-wrap gap-3">
<button
onClick={() => {
setActiveCategory("all");
setPage(1);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === "all"
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => {
setActiveCategory(cat.slug);
setPage(1);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === cat.slug
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
{cat.name}
</button>
))}
</div>
</div>
<div className="max-w-7xl mx-auto px-8 pb-24">
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6 mb-8">
Failed to load posts: {error}
</div>
)}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
) : posts.length === 0 ? (
<div className="text-center py-24">
<p className="text-2xl font-bold text-on-surface-variant mb-2">
No posts yet
</p>
<p className="text-on-surface-variant/60">
Check back soon for curated Bitcoin content.
</p>
</div>
) : (
<>
{featured && page === 1 && (
<Link
href={`/blog/${featured.slug}`}
className="block bg-surface-container-low rounded-xl overflow-hidden mb-12 group hover:bg-surface-container-high transition-colors"
>
<div className="p-8 md:p-12">
<span className="inline-block px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full mb-6">
Featured
</span>
<h2 className="text-3xl md:text-4xl font-black tracking-tight mb-4 group-hover:text-primary transition-colors">
{featured.title}
</h2>
{featured.excerpt && (
<p className="text-on-surface-variant text-lg leading-relaxed max-w-2xl mb-6">
{featured.excerpt}
</p>
)}
<div className="flex items-center gap-4 text-sm text-on-surface-variant/60">
{featured.author && <span>{featured.author}</span>}
{featured.publishedAt && (
<span>{formatDate(featured.publishedAt)}</span>
)}
</div>
</div>
</Link>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{regularPosts.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
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"
>
{post.categories && post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((pc) => (
<span
key={pc.category.id}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{pc.category.name}
</span>
))}
</div>
)}
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
{post.title}
</h3>
{post.excerpt && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
{post.excerpt}
</p>
)}
<div className="flex items-center justify-between mt-auto pt-4 border-t border-zinc-800/60">
<div className="flex items-center gap-2 text-xs text-on-surface-variant/50">
{post.author && <span>{post.author}</span>}
{post.author && (post.publishedAt || post.createdAt) && <span>·</span>}
{(post.publishedAt || post.createdAt) && (
<span>{formatDate(post.publishedAt || post.createdAt!)}</span>
)}
</div>
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all">
Read <ArrowRight size={12} />
</span>
</div>
</Link>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 mt-16">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowLeft size={16} /> Previous
</button>
<span className="text-sm text-on-surface-variant">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
Next <ChevronRight size={16} />
</button>
</div>
)}
</>
)}
</div>
</>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface FaqAccordionItem {
id: string;
question: string;
answer: string;
}
/**
* Interactive accordion. Items are passed in from the server so the full
* question + answer text is present in the initial HTML (indexable), while the
* open/close behaviour is hydrated on the client.
*/
export function FaqAccordion({ items }: { items: FaqAccordionItem[] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div className="space-y-4">
{items.map((item, i) => {
const isOpen = openIndex === i;
return (
<div
key={item.id}
className="bg-surface-container-low rounded-xl overflow-hidden"
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
aria-expanded={isOpen}
className="w-full flex items-center justify-between p-6 text-left"
>
<span className="text-lg font-bold pr-4">{item.question}</span>
<ChevronDown
size={20}
className={cn(
"shrink-0 text-primary transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"grid transition-all duration-200",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<p className="px-6 pb-6 text-on-surface-variant leading-relaxed">
{item.answer}
</p>
</div>
</div>
</div>
);
})}
</div>
);
}
+12 -3
View File
@@ -3,10 +3,13 @@ interface JsonLdProps {
}
export function JsonLd({ data }: JsonLdProps) {
// Escape `<` so user-controlled titles/descriptions cannot break out of the
// <script> tag via `</script>` (JSON.stringify alone does not escape it).
const json = JSON.stringify(data).replace(/</g, "\\u003c");
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
dangerouslySetInnerHTML={{ __html: json }}
/>
);
}
@@ -14,18 +17,21 @@ export function JsonLd({ data }: JsonLdProps) {
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
export function OrganizationJsonLd() {
export function OrganizationJsonLd({ sameAs }: { sameAs?: string[] } = {}) {
const sameAsUrls =
sameAs && sameAs.length > 0 ? sameAs : ["https://t.me/belgianbitcoinembassy"];
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${siteUrl}/#organization`,
name: "Belgian Bitcoin Embassy",
url: siteUrl,
logo: `${siteUrl}/og-default.png`,
description:
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
sameAs: ["https://t.me/belgianbitcoinembassy"],
sameAs: sameAsUrls,
address: {
"@type": "PostalAddress",
addressLocality: "Antwerp",
@@ -103,6 +109,7 @@ interface EventJsonLdProps {
name: string;
description?: string;
startDate: string;
endDate?: string;
location?: string;
url: string;
imageUrl?: string;
@@ -114,6 +121,7 @@ export function EventJsonLd({
name,
description,
startDate,
endDate,
location,
url,
imageUrl,
@@ -130,6 +138,7 @@ export function EventJsonLd({
name,
description: description || `Bitcoin meetup: ${name}`,
startDate,
...(endDate ? { endDate } : {}),
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
eventStatus: "https://schema.org/EventScheduled",
...(location
+39 -1
View File
@@ -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"
+2 -1
View File
@@ -80,7 +80,8 @@ export function Navbar() {
}
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
const isStaff = user?.role === "ADMIN" || user?.role === "MODERATOR";
const isStaff =
!!user?.isSuperAdmin || (user?.permissions?.length ?? 0) > 0;
function handleLogout() {
setDropdownOpen(false);
+55 -6
View File
@@ -15,6 +15,8 @@ import {
export interface User {
pubkey: string;
role: string;
isSuperAdmin?: boolean;
permissions?: string[];
username?: string;
name?: string;
picture?: string;
@@ -32,17 +34,25 @@ interface AuthContextType {
logout: () => void;
isAdmin: boolean;
isModerator: boolean;
isSuperAdmin: boolean;
permissions: string[];
can: (permission: string) => boolean;
refreshAccess: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
login: async () => ({ pubkey: "", role: "USER" }),
loginWithBunker: async () => ({ pubkey: "", role: "USER" }),
loginWithConnectedSigner: async () => ({ pubkey: "", role: "USER" }),
login: async () => ({ pubkey: "", role: "guest" }),
loginWithBunker: async () => ({ pubkey: "", role: "guest" }),
loginWithConnectedSigner: async () => ({ pubkey: "", role: "guest" }),
logout: () => {},
isAdmin: false,
isModerator: false,
isSuperAdmin: false,
permissions: [],
can: () => false,
refreshAccess: async () => {},
});
export function useAuth() {
@@ -53,6 +63,28 @@ export function useAuthProvider(): AuthContextType {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refreshAccess = useCallback(async () => {
const token = localStorage.getItem("bbe_token");
if (!token) return;
try {
const me = await api.getMe();
setUser((prev) => {
if (!prev) return prev;
const next: User = {
...prev,
role: me.role,
isSuperAdmin: me.isSuperAdmin,
permissions: me.permissions,
username: me.username ?? prev.username,
};
localStorage.setItem("bbe_user", JSON.stringify(next));
return next;
});
} catch {
// Best-effort refresh. The stored snapshot remains usable.
}
}, []);
useEffect(() => {
const stored = localStorage.getItem("bbe_user");
const token = localStorage.getItem("bbe_token");
@@ -63,9 +95,12 @@ export function useAuthProvider(): AuthContextType {
localStorage.removeItem("bbe_user");
localStorage.removeItem("bbe_token");
}
// Refresh effective role and permissions live so a stale JWT snapshot does
// not drive what the user can see or do.
void refreshAccess();
}
setLoading(false);
}, []);
}, [refreshAccess]);
const completeAuth = useCallback(
async (
@@ -87,6 +122,8 @@ export function useAuthProvider(): AuthContextType {
const fullUser: User = {
...userData,
isSuperAdmin: userData.isSuperAdmin ?? false,
permissions: userData.permissions ?? [],
name: profile.name,
displayName: profile.displayName,
picture: profile.picture,
@@ -138,6 +175,13 @@ export function useAuthProvider(): AuthContextType {
setUser(null);
}, []);
const permissions = user?.permissions ?? [];
const isSuperAdmin = user?.isSuperAdmin ?? false;
const can = useCallback(
(permission: string) => isSuperAdmin || permissions.includes(permission),
[isSuperAdmin, permissions]
);
return {
user,
loading,
@@ -145,7 +189,12 @@ export function useAuthProvider(): AuthContextType {
loginWithBunker,
loginWithConnectedSigner,
logout,
isAdmin: user?.role === "ADMIN",
isModerator: user?.role === "MODERATOR" || user?.role === "ADMIN",
isSuperAdmin,
permissions,
can,
refreshAccess,
// Compatibility shims for any remaining role-name checks.
isAdmin: isSuperAdmin || user?.role === "admin",
isModerator: isSuperAdmin || user?.role === "admin" || user?.role === "moderator",
};
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
import { loadNostrProfile, type NostrProfile } from "@/lib/nostr";
export interface UseNostrProfileResult {
profile: NostrProfile | null;
loading: boolean;
}
// Fetches a single user's Nostr kind:0 metadata from relays. Concurrent calls
// across components are batched into one relay query by the shared loader.
export function useNostrProfile(
pubkey: string | null | undefined
): UseNostrProfileResult {
const [profile, setProfile] = useState<NostrProfile | null>(null);
const [loading, setLoading] = useState<boolean>(!!pubkey);
useEffect(() => {
if (!pubkey) {
setProfile(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
loadNostrProfile(pubkey)
.then((p) => {
if (!cancelled) {
setProfile(p);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setProfile({});
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [pubkey]);
return { profile, loading };
}
+45 -5
View File
@@ -24,10 +24,27 @@ export const api = {
body: JSON.stringify({ pubkey }),
}),
verify: (pubkey: string, signedEvent: any) =>
request<{ token: string; user: { pubkey: string; role: string; username?: string } }>("/auth/verify", {
request<{
token: string;
user: {
pubkey: string;
role: string;
isSuperAdmin?: boolean;
permissions?: string[];
username?: string;
};
}>("/auth/verify", {
method: "POST",
body: JSON.stringify({ pubkey, signedEvent }),
}),
getMe: () =>
request<{
pubkey: string;
role: string;
isSuperAdmin: boolean;
permissions: string[];
username?: string;
}>("/auth/me"),
// Posts
getPosts: (params?: { category?: string; page?: number; limit?: number; all?: boolean }) => {
@@ -93,16 +110,39 @@ export const api = {
// Users
getUsers: () => request<any[]>("/users"),
promoteUser: (pubkey: string) =>
request<any>("/users/promote", { method: "POST", body: JSON.stringify({ pubkey }) }),
demoteUser: (pubkey: string) =>
request<any>("/users/demote", { method: "POST", body: JSON.stringify({ pubkey }) }),
createUser: (pubkey: string) =>
request<any>("/users", { method: "POST", body: JSON.stringify({ pubkey }) }),
setUserRole: (pubkey: string, role: string | null) =>
request<any>(`/users/${encodeURIComponent(pubkey)}/role`, {
method: "PUT",
body: JSON.stringify({ role }),
}),
updateUserUsername: (pubkey: string, username: string) =>
request<any>(`/users/${encodeURIComponent(pubkey)}`, {
method: "PATCH",
body: JSON.stringify({ username }),
}),
// Roles and permissions
getPermissionRegistry: () =>
request<{ permissions: { key: string; label: string; group: string }[]; roles: string[] }>(
"/admin/permissions"
),
getRolePermissions: () =>
request<{ roles: { role: string; permissions: string[] }[] }>("/admin/roles"),
updateRolePermissions: (role: string, permissions: string[]) =>
request<{ role: string; permissions: string[] }>(
`/admin/roles/${encodeURIComponent(role)}/permissions`,
{ method: "PUT", body: JSON.stringify({ permissions }) }
),
// API keys
getApiKeys: () => request<any[]>("/api-keys"),
createApiKey: (data: { name: string; permissions: string[] }) =>
request<any>("/api-keys", { method: "POST", body: JSON.stringify(data) }),
revokeApiKey: (id: string) =>
request<any>(`/api-keys/${encodeURIComponent(id)}`, { method: "DELETE" }),
// Categories
getCategories: () => request<any[]>("/categories"),
createCategory: (data: { name: string; slug: string }) =>
+52
View File
@@ -0,0 +1,52 @@
// Lightweight TTL cache backed by localStorage so resolved Nostr data (author
// profiles, long-form events) survives page reloads and navigation instead of
// being re-queried from relays every visit. No-ops on the server and degrades
// silently on quota/parse errors so it can never break rendering.
const PREFIX = "bbe:nostr:";
interface Entry<T> {
v: T;
t: number; // stored-at epoch ms
}
export function readCache<T>(key: string, ttlMs: number): T | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(PREFIX + key);
if (!raw) return null;
const entry = JSON.parse(raw) as Entry<T>;
if (Date.now() - entry.t > ttlMs) {
window.localStorage.removeItem(PREFIX + key);
return null;
}
return entry.v;
} catch {
return null;
}
}
export function writeCache<T>(key: string, value: T): void {
if (typeof window === "undefined") return;
const payload = JSON.stringify({ v: value, t: Date.now() } satisfies Entry<T>);
try {
window.localStorage.setItem(PREFIX + key, payload);
} catch {
// Quota exceeded (or similar): evict our namespace and retry once.
try {
pruneNamespace();
window.localStorage.setItem(PREFIX + key, payload);
} catch {
// Give up silently — caching is best-effort.
}
}
}
// Removes every entry written by this cache to recover space when localStorage
// is full. Only touches our own namespace.
function pruneNamespace(): void {
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const k = window.localStorage.key(i);
if (k && k.startsWith(PREFIX)) window.localStorage.removeItem(k);
}
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Plain-markdown mirrors of the legal pages, served at /privacy.md and /terms.md
* for llms.txt consumers.
*
* SOURCE OF TRUTH: these must be kept in sync with the rendered pages at
* app/privacy/page.tsx and app/terms/page.tsx. If you edit the legal copy there,
* update it here too (and bump "Last updated").
*/
export const PRIVACY_MARKDOWN = `# Privacy Policy
_Last updated: April 3, 2026_
## Who We Are
Belgian Bitcoin Embassy is a community initiative focused on Bitcoin education and meetups in Belgium. We aim to process the minimum data needed to run this website safely and reliably.
## What Data We Process
If you log in with Nostr, we process your public key, role, and optional username. We also process content needed to operate the site, such as posts, submissions, media metadata, and moderation records. Some Nostr-related data may be cached on our servers to improve performance.
## Why We Process Data
We process data to provide core site features, maintain account sessions, prevent abuse, moderate community interactions, and keep the service secure. Our legal bases are contract (or steps requested by you before using features) and legitimate interests (security, integrity, and service operation).
## Cookies and Local Storage
We currently do not use third-party analytics or advertising cookies. We do use browser local storage to keep your authentication session active. You can clear this data at any time by logging out or clearing browser storage.
## Recipients
When you interact through Nostr, your actions are published on the Nostr network, which is public by design. We may also use infrastructure providers to host and secure the website.
## Retention
We keep account and operational data only as long as needed for service operation, security, and moderation. Technical logs may be retained for a limited period. You can remove local browser data at any time.
## Your GDPR Rights
Depending on applicable law, you may have rights to access, rectify, erase, restrict, object to, or request portability of your personal data. You also have the right to lodge a complaint with the Belgian Data Protection Authority.
## International Transfers
If technical providers process data outside the EEA, we aim to rely on appropriate safeguards as required under GDPR.
## Children
This website is not directed at children under the age of 16.
## Policy Updates
We may update this Privacy Policy from time to time. Material changes are reflected by updating the date at the top of this page.
## Contact
For privacy-related questions, reach out to us via our [community channels](https://belgianbitcoinembassy.org/community.md).
`;
export const TERMS_MARKDOWN = `# Terms of Use
_Last updated: April 3, 2026_
## Acceptance and Changes
By accessing or using this website, you agree to these Terms of Use. We may update these terms from time to time, and continued use after updates means you accept the revised terms.
## Nature of the Service
This website provides general Bitcoin education and community information. Nothing on this website is financial, investment, legal, or tax advice. We do not make recommendations to buy, sell, or hold Bitcoin or any other crypto-asset. Content is general in nature and not tailored to your personal circumstances.
## Crypto Risk Warning
Crypto-assets are highly volatile and you can lose all of your money. Crypto-assets are not regulated in the same way as traditional financial products. Regulatory rules may change, and availability may differ by jurisdiction. Always do your own research and consult a qualified professional before making financial decisions.
## MiCA and Regulatory Position
Belgian Bitcoin Embassy presents this website as an educational platform and not as a crypto-asset service provider. If the nature of our activities changes, we may update these terms and related legal pages.
## Content and Third Parties
Some content is curated from the Nostr network. We do not claim ownership of third-party content. Local moderation may hide or limit content on this site, but does not change content on the Nostr network itself.
## User Conduct
Users interacting via Nostr (likes, comments) are expected to behave respectfully. The moderation team reserves the right to locally hide content or block pubkeys that violate community standards.
## Paid and Commercial Features
Certain features may involve Lightning payments, such as paid public board messages. Any such feature is optional and does not change the educational nature of the site.
## Affiliate and Sponsorship Transparency
As of the last updated date above, we do not earn referral fees from links on this website. If sponsored or affiliate content is added in the future, it will be clearly disclosed.
## Disclaimer and Liability
This platform is provided on an "as is" and "as available" basis without warranties of any kind. To the maximum extent permitted by law, Belgian Bitcoin Embassy is not liable for losses or damages resulting from your use of this site or reliance on its content.
## Governing Law
These terms are governed by Belgian law, without prejudice to mandatory consumer protections that apply in your jurisdiction.
## Contact
For terms-related questions, contact us through our [community channels](https://belgianbitcoinembassy.org/community.md).
`;
+309
View File
@@ -0,0 +1,309 @@
/**
* Server-side builders for the llms.txt file (llmstxt.org) and the plain-markdown
* page mirrors (`<path>.md`). Everything here is generated at request time from
* live data (settings, meetups, FAQs, posts) so it never goes stale.
*
* Mirrors deliberately omit nav, footer, and the legal disclaimer block an LLM
* reading these needs the actual content, not repeated chrome.
*/
import { apiUrl } from "./api-base";
import { formatMeetupCivilDateLong } from "./meetupEventTime";
import { fetchMeetupsLive, partitionMeetups, countUpcoming } from "./meetupsData";
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
// Live (uncached) so the mirrors always reflect the same backend state as the
// rendered pages — no ISR drift between /events, /events.md, and /llms.txt.
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
try {
const res = await fetch(apiUrl(path), { cache: "no-store" });
if (!res.ok) return fallback;
return (await res.json()) as T;
} catch {
return fallback;
}
}
function formatPostDate(value?: string): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
/** Social channels with their human description, in display order. */
const SOCIAL_CHANNELS: { key: string; name: string; description: string }[] = [
{
key: "telegram_link",
name: "Telegram",
description:
"Main Belgian chat group for daily discussion and local coordination.",
},
{
key: "nostr_link",
name: "Nostr",
description:
"Follow the BBE on the censorship-resistant social protocol.",
},
{
key: "x_link",
name: "X",
description: "Latest local announcements and event drops.",
},
{
key: "youtube_link",
name: "YouTube",
description: "Past talks, educational content, and meetup recordings.",
},
{
key: "discord_link",
name: "Discord",
description:
"Technical discussions, node running, and project collaboration.",
},
{
key: "linkedin_link",
name: "LinkedIn",
description: "The Belgian Bitcoin professional network.",
},
];
function configuredChannels(settings: Record<string, string>) {
return SOCIAL_CHANNELS.map((c) => ({ ...c, url: settings[c.key]?.trim() }))
.filter((c): c is typeof c & { url: string } =>
!!c.url && /^https?:\/\//i.test(c.url),
);
}
// ---------------------------------------------------------------------------
// llms.txt
// ---------------------------------------------------------------------------
export async function buildLlmsTxt(): Promise<string> {
const [settings, meetups] = await Promise.all([
fetchJson<Record<string, string>>("/settings/public", {}),
fetchMeetupsLive(),
]);
const upcomingCount = countUpcoming(meetups);
const channels = configuredChannels(settings);
const channelNames = channels.map((c) => c.name).join(", ");
const communityDescription = channelNames
? `How to connect on ${channelNames}`
: "How to connect with the community";
// Always state the count (including zero) so it stays machine-parseable and
// verifiably consistent with /events and /events.md.
const upcomingLine =
upcomingCount > 0
? `There ${upcomingCount === 1 ? "is" : "are"} currently ${upcomingCount} upcoming meetup${
upcomingCount === 1 ? "" : "s"
} scheduled.`
: "There are currently 0 upcoming meetups scheduled; the community meets monthly.";
return `# Belgian Bitcoin Embassy
> A sovereign, non-commercial community organizing monthly Bitcoin meetups in Antwerp. Education, technical discussion, and adoption. Not a company.
Belgian Bitcoin Embassy is a volunteer-run network, not a business. Content here covers meetups, FAQs about the community, and curated Bitcoin/Nostr commentary. ${upcomingLine}
## Pages
- [About](${SITE_URL}/index.html.md): Who we are and what we do
- [Events](${SITE_URL}/events.md): Upcoming and past Bitcoin meetups in Belgium
- [FAQ](${SITE_URL}/faq.md): Common questions about the community and how to get involved
- [Blog](${SITE_URL}/blog.md): Curated Bitcoin and Nostr content
- [Community](${SITE_URL}/community.md): ${communityDescription}
## Optional
- [Privacy](${SITE_URL}/privacy.md)
- [Terms](${SITE_URL}/terms.md)
- [Contact](${SITE_URL}/contact.md)
`;
}
// ---------------------------------------------------------------------------
// Page mirrors
// ---------------------------------------------------------------------------
export async function buildHomeMarkdown(): Promise<string> {
const meetups = await fetchMeetupsLive();
const next = partitionMeetups(meetups).upcoming[0];
let nextSection = "";
if (next) {
const when = formatMeetupCivilDateLong(next.date);
const bits = [when, next.time, next.location].filter(Boolean).join(" · ");
nextSection = `
## Next Meetup
**${next.title}** ${bits}
See all events: ${SITE_URL}/events.md`;
}
return `# Belgian Bitcoin Embassy
> A sovereign, non-commercial community organizing monthly Bitcoin meetups in Antwerp. Education, technical discussion, and adoption. Not a company.
## The Mission
"Fix the money, fix the world."
We help people in Belgium understand and adopt Bitcoin through education, meetups, and community. We are not a company, but a sovereign network of individuals building a sounder future.${nextSection}
## More
- Events: ${SITE_URL}/events.md
- FAQ: ${SITE_URL}/faq.md
- Blog: ${SITE_URL}/blog.md
- Community: ${SITE_URL}/community.md
`;
}
export async function buildEventsMarkdown(): Promise<string> {
const meetups = await fetchMeetupsLive();
const { upcoming, past } = partitionMeetups(meetups);
const renderMeetup = (m: any): string => {
const when = formatMeetupCivilDateLong(m.date);
const meta = [when, m.time, m.location].filter(Boolean).join(" · ");
const organizer = m.organizer?.name || "Belgian Bitcoin Embassy";
const lines = [`### ${m.title}`, "", `${meta}`, "", `Organized by ${organizer}.`];
if (m.description) lines.push("", m.description.trim());
lines.push("", `Details: ${SITE_URL}/events/${m.id}`);
return lines.join("\n");
};
const sections: string[] = [
"# Events",
"",
"Past and upcoming Bitcoin meetups in Belgium, organized by the Belgian Bitcoin Embassy.",
];
sections.push("", "## Upcoming");
sections.push(
"",
upcoming.length
? upcoming.map(renderMeetup).join("\n\n")
: "No upcoming events are currently scheduled. Check back soon.",
);
if (past.length) {
sections.push("", "## Past Events", "", past.map(renderMeetup).join("\n\n"));
}
return sections.join("\n") + "\n";
}
export async function buildFaqMarkdown(): Promise<string> {
const faqs = await fetchJson<any[]>("/faqs?all=true", []);
const list = Array.isArray(faqs) ? faqs : [];
const header = `# Frequently Asked Questions
Everything you need to know about the Belgian Bitcoin Embassy.`;
if (!list.length) {
return `${header}\n\nNo FAQs are available yet.\n`;
}
const body = list
.map((f) => `## ${f.question}\n\n${(f.answer || "").trim()}`)
.join("\n\n");
return `${header}\n\n${body}\n`;
}
export async function buildBlogMarkdown(): Promise<string> {
const data = await fetchJson<{ posts: any[]; total: number }>(
"/posts?limit=100",
{ posts: [], total: 0 },
);
const posts = Array.isArray(data?.posts) ? data.posts : [];
const header = `# Blog
Curated Bitcoin and Nostr content from the Belgian Bitcoin Embassy.`;
if (!posts.length) {
return `${header}\n\nNo posts have been published yet.\n`;
}
const body = posts
.map((p) => {
const date = formatPostDate(p.publishedAt || p.createdAt);
const meta = [p.author, date].filter(Boolean).join(" · ");
const lines = [`## ${p.title}`];
if (meta) lines.push("", `_${meta}_`);
if (p.excerpt) lines.push("", p.excerpt.trim());
lines.push("", `Read: ${SITE_URL}/blog/${p.slug}`);
return lines.join("\n");
})
.join("\n\n");
return `${header}\n\n${body}\n`;
}
export async function buildCommunityMarkdown(): Promise<string> {
const settings = await fetchJson<Record<string, string>>(
"/settings/public",
{},
);
const channels = configuredChannels(settings);
const header = `# Community
Connect with local Belgian Bitcoiners, builders, and educators across every platform.`;
if (!channels.length) {
return `${header}\n\nCommunity channel links are being set up. Check back soon.\n`;
}
const body = channels
.map((c) => `- [${c.name}](${c.url}): ${c.description}`)
.join("\n");
return `${header}\n\n## Channels\n\n${body}\n`;
}
export async function buildContactMarkdown(): Promise<string> {
const settings = await fetchJson<Record<string, string>>(
"/settings/public",
{},
);
const channels = configuredChannels(settings);
const header = `# Contact
The best way to reach us is through our community channels. We are a decentralized community there is no central office or email inbox.`;
const lines: string[] = [header, "", "## Channels"];
if (channels.length) {
lines.push(
"",
...channels.map((c) => `- [${c.name}](${c.url}): ${c.description}`),
);
} else {
lines.push("", "Community channel links are being set up. Check back soon.");
}
lines.push(
"",
"## Meetups",
"",
`The best way to connect is in person. Come to our monthly meetup — see upcoming events at ${SITE_URL}/events.md`,
);
return lines.join("\n") + "\n";
}
+21
View File
@@ -52,6 +52,27 @@ export function getMeetupStartUtc(dateStr: string, timeStr: string): Date {
return new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0));
}
/**
* Returns the event end instant in UTC when the time string carries a range
* (e.g. "18:00 - 21:00"), otherwise null. Used for schema.org Event.endDate.
*/
export function getMeetupEndUtc(dateStr: string, timeStr: string): Date | null {
const key = normalizeMeetupDateKey(dateStr);
if (!key) return null;
const parts = key.split("-").map(Number);
const year = parts[0];
const month = parts[1];
const day = parts[2];
if (!year || !month || !day) return null;
const timeParts = (timeStr?.trim() || "").split(/\s*[-]\s*/);
if (timeParts.length < 2 || !timeParts[1]?.trim()) return null;
const { h: endH, m: endM } = parseLocalTime(timeParts[1]);
const utcEndH = endH - BRUSSELS_OFFSET_HOURS;
return new Date(Date.UTC(year, month - 1, day, utcEndH, endM, 0));
}
const UTC_CAL_OPTS = { timeZone: "UTC" } as const;
/**
+71
View File
@@ -0,0 +1,71 @@
/**
* Single source of truth for meetup data used by the public /events page, the
* /events.md mirror, and the /llms.txt summary line. Centralizing the fetch +
* upcoming/past partition guarantees those three can never disagree on the count.
*
* Fetched with `no-store` so every render reflects the live backend this is
* what keeps crawlers (which don't run JS) and llms.txt consumers from seeing a
* stale or build-time-empty list.
*/
import { apiUrl } from "./api-base";
import { getMeetupStartUtc } from "./meetupEventTime";
export interface Meetup {
id: string;
title: string;
date: string;
time?: string;
location?: string;
description?: string;
status?: string;
organizer?: { name?: string; slug?: string } | null;
[key: string]: unknown;
}
/** Fetch all publicly-visible meetups from the backend, live (uncached). */
export async function fetchMeetupsLive(): Promise<Meetup[]> {
try {
const res = await fetch(apiUrl("/meetups"), { cache: "no-store" });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as Meetup[]) : [];
} catch {
return [];
}
}
export interface PartitionedMeetups {
upcoming: Meetup[];
past: Meetup[];
}
/**
* Split meetups into upcoming (start >= now, soonest first) and past (start <
* now, most recent first). Meetups with an unparseable date are dropped.
*/
export function partitionMeetups(
meetups: Meetup[],
now: Date = new Date(),
): PartitionedMeetups {
const upcoming: Meetup[] = [];
const past: Meetup[] = [];
for (const m of meetups) {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) continue;
if (start >= now) upcoming.push(m);
else past.push(m);
}
const startMs = (m: Meetup) =>
getMeetupStartUtc(m.date, m.time || "00:00").getTime();
upcoming.sort((a, b) => startMs(a) - startMs(b));
past.sort((a, b) => startMs(b) - startMs(a));
return { upcoming, past };
}
/** Number of upcoming meetups — the single value llms.txt and events.md share. */
export function countUpcoming(meetups: Meetup[], now: Date = new Date()): number {
return partitionMeetups(meetups, now).upcoming.length;
}
+383 -57
View File
@@ -1,4 +1,38 @@
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
import { nip19 } from "nostr-tools";
import { readCache, writeCache } from "./browserCache";
// Persistent (localStorage) cache lifetimes. Longer than the in-memory TTLs:
// these survive reloads, where the whole point is to avoid re-querying relays.
// Profiles and immutable events change rarely; addressable (replaceable)
// long-form events use a shorter window so edits are picked up reasonably soon.
const PROFILE_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours
const EVENT_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours (events by id are immutable)
const LONGFORM_PERSIST_TTL = 30 * 60 * 1000; // 30 minutes (replaceable)
// Relays return events keyed by hex pubkeys and only accept hex in `authors`
// filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize.
export function toHexPubkey(pubkey: string | null | undefined): string | null {
if (!pubkey) return null;
const trimmed = pubkey.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === "npub") return decoded.data as string;
if (decoded.type === "nprofile") return (decoded.data as { pubkey: string }).pubkey;
} catch {
// fall through
}
return null;
}
// Relays that specialize in (or broadly aggregate) kind:0 profile metadata.
// Included alongside the site relays so profiles are found even when a user's
// metadata was never published to the site's configured relay set.
const PROFILE_METADATA_RELAYS = [
"wss://purplepag.es",
"wss://relay.nostr.band",
];
declare global {
interface Window {
@@ -48,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;
@@ -62,6 +110,72 @@ const FALLBACK_RELAYS = [
"wss://relay.nostr.band",
];
// Only secure WebSocket (wss://) relays may be dialed: the site is served over
// HTTPS, so ws:// endpoints (e.g. ws://umbrel.local) are blocked as mixed
// content and must never be connected to. Relay hints reach us from untrusted
// sources (naddr hints, NIP-65 lists, user/extension relay configs), so filter
// at every connection point.
export function isSecureRelay(url: unknown): url is string {
return typeof url === "string" && /^wss:\/\//i.test(url.trim());
}
export function filterSecureRelays(urls: Iterable<string>): string[] {
const out: string[] = [];
for (const url of urls) {
if (isSecureRelay(url)) out.push(url.trim());
}
return [...new Set(out)];
}
// How long a single relay query may run before we give up on it. Relays go
// dead or stall mid-subscription without ever sending EOSE, which is what makes
// blog loads occasionally hang; bounding every query keeps the page responsive.
const RELAY_MAX_WAIT = 5000; // ms, passed to nostr-tools as `maxWait`
const RELAY_HARD_TIMEOUT = 8000; // ms, absolute ceiling incl. connection setup
// A single shared read pool for the whole app. Reusing one pool keeps relay
// websockets warm and—crucially—avoids opening several simultaneous
// connections to the same relays (author profile + article body + NIP-65 all at
// once on a blog open), which relays throttle/drop and which caused the
// intermittent "couldn't fetch" failure on first load. Never closed: the pool
// manages and reuses its own connections for the SPA's lifetime.
let _readPool: any = null;
async function getReadPool(): Promise<any> {
if (!_readPool) {
const { SimplePool } = await import("nostr-tools/pool");
_readPool = new SimplePool();
}
return _readPool;
}
// Resolves to `fallback` if `promise` hasn't settled within `ms`. Used as a
// hard ceiling around relay queries so a connection that never opens (or never
// closes) can't block rendering.
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(fallback), ms);
promise.then(
(v) => { clearTimeout(timer); resolve(v); },
() => { clearTimeout(timer); resolve(fallback); },
);
});
}
// A single bounded query across `relays` (which are dialed in parallel by the
// pool). Returns null on timeout, empty relay set, or error.
async function getEventBounded(
pool: { get: (relays: string[], filter: any, params?: { maxWait?: number }) => Promise<any> },
relays: string[],
filter: any,
): Promise<any | null> {
if (relays.length === 0) return null;
return withTimeout(
pool.get(relays, filter, { maxWait: RELAY_MAX_WAIT }),
RELAY_HARD_TIMEOUT,
null,
);
}
let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null;
const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes
@@ -73,9 +187,10 @@ export async function getSiteRelays(): Promise<string[]> {
const res = await fetch("/api/relays/public");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (Array.isArray(data.relays) && data.relays.length > 0) {
_siteRelaysCache = { relays: data.relays, fetchedAt: Date.now() };
return data.relays;
const secure = Array.isArray(data.relays) ? filterSecureRelays(data.relays) : [];
if (secure.length > 0) {
_siteRelaysCache = { relays: secure, fetchedAt: Date.now() };
return secure;
}
} catch {}
return FALLBACK_RELAYS;
@@ -94,12 +209,11 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
const cached = _nip65Cache.get(pubkey);
if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data;
const { SimplePool } = await import("nostr-tools/pool");
const siteRelays = await getSiteRelays();
const pool = new SimplePool();
const pool = await getReadPool();
try {
const event = await pool.get(siteRelays, {
const event = await getEventBounded(pool, siteRelays, {
kinds: [10002],
authors: [pubkey],
});
@@ -113,6 +227,7 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
for (const tag of event.tags) {
if (tag[0] !== "r" || !tag[1]) continue;
const url = tag[1];
if (!isSecureRelay(url)) continue;
const marker = tag[2];
result.all.push(url);
if (marker === "write") {
@@ -129,8 +244,6 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
return result;
} catch {
return { write: [], read: [], all: [] };
} finally {
pool.close(siteRelays);
}
}
@@ -150,8 +263,9 @@ export async function getUserStoredRelays(): Promise<{ url: string; read: boolea
if (!res.ok) return [];
const data = await res.json();
if (Array.isArray(data)) {
_userRelaysCache = { relays: data, fetchedAt: Date.now() };
return data;
const secure = data.filter((r) => isSecureRelay(r?.url));
_userRelaysCache = { relays: secure, fetchedAt: Date.now() };
return secure;
}
} catch {}
return [];
@@ -192,7 +306,7 @@ export async function publishEvent(signedEvent: any): Promise<void> {
}
} catch {}
const relayUrls = [...allRelays];
const relayUrls = filterSecureRelays(allRelays);
const pool = new SimplePool();
try {
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
@@ -205,57 +319,233 @@ export async function fetchNostrProfile(
pubkey: string,
relayUrls?: string[]
): Promise<NostrProfile> {
const { SimplePool } = await import("nostr-tools/pool");
const allRelays = new Set<string>(relayUrls || await getSiteRelays());
const hex = toHexPubkey(pubkey);
if (!hex) return {};
const cached = readProfileCache(hex, Date.now());
if (cached) return cached;
const filter = { kinds: [0], authors: [hex] };
// Overlap the NIP-65 lookup with the first query rather than waiting on it.
const nip65Promise = fetchNip65RelayList(hex).catch(
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
);
const pool = await getReadPool();
const tried = new Set<string>();
try {
const nip65 = await fetchNip65RelayList(pubkey);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
// Phase 1: provided relays (or site relays) + profile aggregators.
const phase1 = filterSecureRelays([
...(relayUrls || await getSiteRelays()),
...PROFILE_METADATA_RELAYS,
]);
phase1.forEach((u) => tried.add(u));
let event = await getEventBounded(pool, phase1, filter);
const urls = [...allRelays];
const pool = new SimplePool();
try {
const event = await pool.get(urls, {
kinds: [0],
authors: [pubkey],
});
// Phase 2: author's NIP-65 write relays if not found yet.
if (!event?.content) {
const nip65 = await nip65Promise;
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
}
if (!event?.content) return {};
const meta = JSON.parse(event.content);
return {
name: meta.name || meta.display_name,
displayName: meta.display_name,
picture: meta.picture,
about: meta.about,
nip05: meta.nip05,
};
const profile = parseProfileContent(event.content);
writeProfileCache(hex, profile, Date.now());
return profile;
} catch {
return {};
} finally {
pool.close(urls);
}
}
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
const { SimplePool } = await import("nostr-tools/pool");
const siteRelays = await getSiteRelays();
const pool = new SimplePool();
function parseProfileContent(content: string): NostrProfile {
const meta = JSON.parse(content);
return {
name: meta.name || meta.display_name,
displayName: meta.display_name,
picture: meta.picture,
about: meta.about,
nip05: meta.nip05,
};
}
const _profileCache = new Map<string, { profile: NostrProfile; fetchedAt: number; empty: boolean }>();
const PROFILE_TTL = 5 * 60 * 1000; // 5 minutes for resolved profiles
const PROFILE_EMPTY_TTL = 30 * 1000; // retry misses sooner
function isProfileEmpty(p: NostrProfile): boolean {
return !p.name && !p.displayName && !p.picture && !p.about && !p.nip05;
}
function readProfileCache(pubkey: string, now: number): NostrProfile | null {
const cached = _profileCache.get(pubkey);
if (cached) {
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
if (now - cached.fetchedAt < ttl) return cached.profile;
}
// Fall back to the persistent browser cache (survives reloads/navigation).
// Only resolved profiles are persisted, so a hit here is always non-empty.
const persisted = readCache<NostrProfile>(`profile:${pubkey}`, PROFILE_PERSIST_TTL);
if (persisted) {
_profileCache.set(pubkey, { profile: persisted, fetchedAt: now, empty: false });
return persisted;
}
return null;
}
function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void {
const empty = isProfileEmpty(profile);
_profileCache.set(pubkey, { profile, fetchedAt: now, empty });
// Persist resolved profiles only; skip empties so a reload can retry the miss.
if (!empty) writeCache(`profile:${pubkey}`, profile);
}
// Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a
// single SimplePool and one query, instead of opening a pool (plus a NIP-65
// lookup pool) per pubkey. Querying many profiles individually opens dozens of
// simultaneous websocket connections to the same relays, which get
// throttled/dropped and return empty results. Pubkeys are normalized to hex
// (relays reject npub/nprofile in `authors`).
export async function fetchNostrProfiles(
pubkeys: string[],
relayUrls?: string[]
): Promise<Record<string, NostrProfile>> {
const result: Record<string, NostrProfile> = {};
const now = Date.now();
// Map each requested key to its hex form. Skip cached and un-decodable keys.
const hexByKey = new Map<string, string>();
for (const pk of pubkeys) {
const cached = readProfileCache(pk, now);
if (cached) {
result[pk] = cached;
continue;
}
const hex = toHexPubkey(pk);
if (!hex) {
result[pk] = {};
continue;
}
hexByKey.set(pk, hex);
}
if (hexByKey.size === 0) return result;
const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays();
const urls = filterSecureRelays([...base, ...PROFILE_METADATA_RELAYS]);
const pool = await getReadPool();
try {
const event = await pool.get(siteRelays, { ids: [eventId] });
const authors = [...new Set(hexByKey.values())];
const events = await pool.querySync(
urls,
{ kinds: [0], authors },
{ maxWait: 6000 }
);
// Keep only the most recent kind:0 event per hex author.
const latest = new Map<string, { created_at: number; content: string }>();
for (const event of events) {
const prev = latest.get(event.pubkey);
if (!prev || event.created_at > prev.created_at) {
latest.set(event.pubkey, { created_at: event.created_at, content: event.content });
}
}
for (const [key, hex] of hexByKey) {
const ev = latest.get(hex);
let profile: NostrProfile = {};
if (ev?.content) {
try {
profile = parseProfileContent(ev.content);
} catch {
profile = {};
}
}
result[key] = profile;
writeProfileCache(key, profile, now);
}
return result;
} catch {
for (const key of hexByKey.keys()) {
if (!(key in result)) result[key] = {};
}
return result;
}
}
// DataLoader-style batching for single-pubkey requests. Component instances
// (e.g. <NostrAvatar />) each ask for one pubkey; this coalesces all requests
// made within a short window into a single batched relay query.
let _batchQueue = new Set<string>();
let _batchResolvers = new Map<string, Array<(p: NostrProfile) => void>>();
let _batchTimer: ReturnType<typeof setTimeout> | null = null;
const BATCH_WINDOW_MS = 60;
async function flushProfileBatch(): Promise<void> {
const pubkeys = [..._batchQueue];
const resolvers = _batchResolvers;
_batchQueue = new Set();
_batchResolvers = new Map();
_batchTimer = null;
let profiles: Record<string, NostrProfile> = {};
try {
profiles = await fetchNostrProfiles(pubkeys);
} catch {
profiles = {};
}
for (const pk of pubkeys) {
const profile = profiles[pk] ?? {};
resolvers.get(pk)?.forEach((resolve) => resolve(profile));
}
}
// Resolve a single pubkey's profile, batching concurrent calls and reusing the
// shared profile cache.
export function loadNostrProfile(pubkey: string): Promise<NostrProfile> {
const cached = readProfileCache(pubkey, Date.now());
if (cached) {
return Promise.resolve(cached);
}
return new Promise((resolve) => {
const existing = _batchResolvers.get(pubkey);
if (existing) {
existing.push(resolve);
} else {
_batchResolvers.set(pubkey, [resolve]);
}
_batchQueue.add(pubkey);
if (_batchTimer === null) {
_batchTimer = setTimeout(() => void flushProfileBatch(), BATCH_WINDOW_MS);
}
});
}
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
const cacheKey = `event:${eventId}`;
const cached = readCache<any>(cacheKey, EVENT_PERSIST_TTL);
if (cached) return cached;
const siteRelays = await getSiteRelays();
const pool = await getReadPool();
try {
const event = await getEventBounded(pool, siteRelays, { ids: [eventId] });
if (event) writeCache(cacheKey, event);
return event || null;
} catch {
return null;
} finally {
pool.close(siteRelays);
}
}
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
const { SimplePool } = await import("nostr-tools/pool");
const cacheKey = `longform:${naddrStr}`;
const cached = readCache<any>(cacheKey, LONGFORM_PERSIST_TTL);
if (cached) return cached;
const { nip19 } = await import("nostr-tools");
let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] };
@@ -268,29 +558,65 @@ export async function fetchLongformFromRelays(naddrStr: string): Promise<any | n
}
const siteRelays = await getSiteRelays();
const naddrRelays = decoded.relays || [];
const allRelays = new Set<string>([...naddrRelays, ...siteRelays]);
try {
const nip65 = await fetchNip65RelayList(decoded.pubkey);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
const relayUrls = [...allRelays];
const naddrRelays = filterSecureRelays(decoded.relays || []);
const filter = {
kinds: [decoded.kind],
authors: [decoded.pubkey],
"#d": [decoded.identifier],
};
const pool = new SimplePool();
// Kick off the author's NIP-65 lookup immediately so it overlaps the first
// query instead of adding a serial round-trip before it.
const nip65Promise = fetchNip65RelayList(decoded.pubkey).catch(
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
);
const pool = await getReadPool();
const tried = new Set<string>();
// Phase 1: naddr relay hints + site relays, all dialed in parallel. The
// hints usually point at the author's own relay, so this is the fast path.
const phase1 = filterSecureRelays([...naddrRelays, ...siteRelays]);
phase1.forEach((u) => tried.add(u));
let event = await getEventBounded(pool, phase1, filter);
// Phase 2: fall back to the author's NIP-65 write relays only if needed.
if (!event) {
const nip65 = await nip65Promise;
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
}
if (event) writeCache(cacheKey, event);
return event || null;
}
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
// event id to its underlying Nostr event by querying relays. Lets the blog page
// render a long-form note straight from a shared link even when it was never
// indexed by the backend. Returns null if it can't be decoded or found.
export async function resolveEventFromRelays(identifier: string): Promise<any | null> {
const trimmed = identifier.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return fetchEventFromRelays(trimmed.toLowerCase());
}
let decoded;
try {
const event = await pool.get(relayUrls, filter);
return event || null;
decoded = nip19.decode(trimmed);
} catch {
return null;
} finally {
pool.close(relayUrls);
}
switch (decoded.type) {
case "naddr":
return fetchLongformFromRelays(trimmed);
case "nevent":
return fetchEventFromRelays((decoded.data as { id: string }).id);
case "note":
return fetchEventFromRelays(decoded.data as string);
default:
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { apiUrl } from "./api-base";
/**
* Server-side fetch of the public site settings (social links, titles, ).
* Cached/revalidated so it doesn't hit the backend on every render.
*/
export async function fetchPublicSettings(): Promise<Record<string, string>> {
try {
const res = await fetch(apiUrl("/settings/public"), {
next: { revalidate: 3600 },
});
if (!res.ok) return {};
return (await res.json()) as Record<string, string>;
} catch {
return {};
}
}
/** Setting keys that hold a public social/profile URL, in display order. */
const SOCIAL_SETTING_KEYS = [
"telegram_link",
"nostr_link",
"x_link",
"youtube_link",
"discord_link",
"linkedin_link",
] as const;
/**
* Build the list of real social URLs for schema.org `sameAs` from settings.
* Only includes entries that are actually configured (non-empty, http(s)).
*/
export function socialUrlsFromSettings(
settings: Record<string, string>,
): string[] {
return SOCIAL_SETTING_KEYS.map((key) => settings[key]?.trim())
.filter((url): url is string => !!url && /^https?:\/\//i.test(url));
}
+4
View File
@@ -1,5 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Isolate dev and prod build output. `next start` (production) reads the
// default `.next`; `next dev` is pointed at `.next-dev` via NEXT_DIST_DIR so a
// stray dev run can never overwrite the live production manifests.
distDir: process.env.NEXT_DIST_DIR || '.next',
experimental: {
serverComponentsExternalPackages: ['sharp'],
},
+3 -2
View File
@@ -2,10 +2,11 @@
"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"
"lint": "next lint",
"check:consistency": "node scripts/check-consistency.mjs"
},
"dependencies": {
"class-variance-authority": "^0.7.0",
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Consistency check: confirms the number of upcoming meetups reported by the
* live /events page, the /events.md mirror, and the /llms.txt summary all match.
*
* Catches the exact "confidently wrong" drift this check exists to prevent:
* a mirror or summary going stale relative to the rendered page.
*
* Usage:
* node scripts/check-consistency.mjs [baseUrl]
* BASE_URL=https://belgianbitcoinembassy.org node scripts/check-consistency.mjs
*
* Exits 0 if all three agree, 1 on any mismatch or fetch failure.
*/
const baseUrl = (
process.argv[2] ||
process.env.BASE_URL ||
"http://localhost:3000"
).replace(/\/$/, "");
async function getText(path) {
const url = `${baseUrl}${path}`;
const res = await fetch(url, {
headers: { "User-Agent": "bbe-consistency-check" },
});
if (!res.ok) {
throw new Error(`GET ${url} -> ${res.status}`);
}
return res.text();
}
/** /llms.txt: "There are currently N upcoming meetup(s) scheduled." */
function parseLlmsTxt(text) {
const m = text.match(/currently\s+(\d+)\s+upcoming meetup/i);
if (!m) throw new Error("llms.txt: could not find upcoming-meetup count line");
return Number(m[1]);
}
/** /events.md: count "### " headings within the "## Upcoming" section. */
function parseEventsMd(text) {
const lines = text.split("\n");
let inUpcoming = false;
let count = 0;
let sawUpcoming = false;
for (const line of lines) {
if (/^##\s+Upcoming\b/.test(line)) {
inUpcoming = true;
sawUpcoming = true;
continue;
}
if (/^##\s+/.test(line) && inUpcoming) inUpcoming = false; // next section
if (inUpcoming && /^###\s+/.test(line)) count += 1;
}
if (!sawUpcoming) throw new Error("events.md: no '## Upcoming' section found");
return count;
}
/** /events: data-upcoming-count attribute rendered into the HTML. */
function parseEventsHtml(text) {
const m = text.match(/data-upcoming-count="(\d+)"/);
if (!m) throw new Error("events: data-upcoming-count attribute not found in HTML");
return Number(m[1]);
}
async function main() {
const [llmsTxt, eventsMd, eventsHtml] = await Promise.all([
getText("/llms.txt"),
getText("/events.md"),
getText("/events"),
]);
const counts = {
"/llms.txt": parseLlmsTxt(llmsTxt),
"/events.md": parseEventsMd(eventsMd),
"/events": parseEventsHtml(eventsHtml),
};
const values = Object.values(counts);
const allMatch = values.every((v) => v === values[0]);
console.log(`Consistency check against ${baseUrl}`);
for (const [name, value] of Object.entries(counts)) {
console.log(` ${name.padEnd(12)} upcoming = ${value}`);
}
if (!allMatch) {
console.error(
`\n✗ MISMATCH: upcoming-meetup counts disagree (${values.join(", ")}).`,
);
process.exit(1);
}
console.log(`\n✓ All sources agree: ${values[0]} upcoming meetup(s).`);
}
main().catch((err) => {
console.error(`✗ Consistency check failed: ${err.message}`);
process.exit(1);
});
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Guard against serving a corrupted / dev-contaminated Next.js production build.
#
# Runs as an ExecStartPre for bbe-frontend.service. A stray `next dev` against
# the production checkout overwrites .next/build-manifest.json (and friends) with
# development artifacts, which makes every request-time route 500. Rather than
# let the server boot and silently serve errors for days, we refuse to start and
# log a clear, actionable message.
#
# Exit codes: 0 = build looks like a valid production build, non-zero otherwise.
set -euo pipefail
# Resolve the frontend dir relative to this script so it works regardless of cwd.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
NEXT_DIR="${FRONTEND_DIR}/.next"
fail() {
echo "verify-prod-build: FATAL: $1" >&2
echo "verify-prod-build: run 'npm run build' (or scripts/deploy-frontend.sh) to produce a clean production build." >&2
exit 1
}
[ -d "${NEXT_DIR}" ] || fail "no .next directory at ${NEXT_DIR}"
[ -f "${NEXT_DIR}/BUILD_ID" ] || fail ".next/BUILD_ID is missing (incomplete or wiped build)"
[ -f "${NEXT_DIR}/build-manifest.json" ] || fail ".next/build-manifest.json is missing"
[ -f "${NEXT_DIR}/app-build-manifest.json" ] || fail ".next/app-build-manifest.json is missing"
[ -f "${NEXT_DIR}/prerender-manifest.json" ] || fail ".next/prerender-manifest.json is missing (dev builds omit it)"
# A dev run leaves .next/static/development/ behind; production never has it.
if [ -d "${NEXT_DIR}/static/development" ]; then
fail ".next/static/development exists — this .next was contaminated by 'next dev'"
fi
# Development manifests reference static/development/* chunks; production ones don't.
if grep -q "static/development" "${NEXT_DIR}/build-manifest.json" 2>/dev/null; then
fail "build-manifest.json references static/development — dev build detected"
fi
echo "verify-prod-build: OK (BUILD_ID=$(cat "${NEXT_DIR}/BUILD_ID"))"
exit 0
+8 -1
View File
@@ -16,6 +16,13 @@
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next-dev/types/**/*.ts",
".next-build/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+85
View File
@@ -0,0 +1,85 @@
# Ops: keeping the site up
These files harden the deploy/run path so a stray `next dev` or a half-finished
build can no longer take the public site down (root cause of the `/events` 500).
## What's here
| Path | Purpose |
|------|---------|
| `frontend/scripts/verify-prod-build.sh` | Fails fast if `.next` is missing or dev-contaminated. Wired as `ExecStartPre`. |
| `scripts/deploy-frontend.sh` | Atomic build-verify-swap-restart deploy. Use this to deploy the frontend. |
| `scripts/healthcheck.sh` | Watchdog: probes `/`, `/events`, `/blog`, `/api/health`; restarts frontend after repeated failures. |
| `ops/systemd/bbe-frontend.service.d/verify-build.conf` | Drop-in adding the build guard to `bbe-frontend.service`. |
| `ops/systemd/bbe-site-healthcheck.{service,timer}` | Runs the watchdog every 5 minutes. |
## Layered defenses
1. **Isolation**`next dev` now writes to `.next-dev` (`NEXT_DIST_DIR` in
`frontend/package.json` + `distDir` in `frontend/next.config.js`), so it can
never overwrite the production `.next`.
2. **Fail-fast boot** — the `ExecStartPre` guard refuses to start on a bad build.
3. **Atomic deploys**`deploy-frontend.sh` builds into `.next-build`, verifies,
then renames into place; the running server never sees a partial `.next`.
4. **Self-healing** — the watchdog restarts a wedged frontend within minutes.
5. **Graceful UI**`app/error.tsx` / `app/global-error.tsx` render a retry page
instead of a bare 500 if a request-time route ever throws.
## Install (requires sudo)
One-shot installer (preferred):
```bash
cd /home/bbe/BelgianBitcoinEmbassy
sudo ops/install-systemd.sh
sudo systemctl restart bbe-backend # after backend code changes
```
Or manually:
```bash
cd /home/bbe/BelgianBitcoinEmbassy
# 1. Build guard (ExecStartPre)
sudo mkdir -p /etc/systemd/system/bbe-frontend.service.d
sudo cp ops/systemd/bbe-frontend.service.d/verify-build.conf \
/etc/systemd/system/bbe-frontend.service.d/verify-build.conf
# 2. Health watchdog
sudo cp ops/systemd/bbe-site-healthcheck.service /etc/systemd/system/
sudo cp ops/systemd/bbe-site-healthcheck.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl restart bbe-frontend
sudo systemctl enable --now bbe-site-healthcheck.timer
```
Verify:
```bash
systemctl status bbe-frontend --no-pager
systemctl list-timers bbe-site-healthcheck --no-pager
journalctl -u bbe-healthcheck -n 20 --no-pager
```
## Deploying the frontend from now on
Do **not** run `npm run build` directly in the live tree and then restart. Use:
```bash
scripts/deploy-frontend.sh
```
It refuses to build when free memory is low (`MIN_FREE_MB`, default 600), builds
into `.next-build`, verifies, atomically swaps to `.next`, and restarts the
service. The previous build is kept at `.next-prev` for a quick manual rollback.
## Note on the watchdog restarting the service
`healthcheck.sh` runs `systemctl restart bbe-frontend`. The systemd service above
runs as root, so this works out of the box. If you ever run the script as the
`bbe` user instead, grant a narrow sudoers rule:
```
bbe ALL=(root) NOPASSWD: /usr/bin/systemctl restart bbe-frontend
```
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
#
# Install the production hardening units (build guard + health watchdog).
# Requires sudo. Safe to re-run (idempotent).
#
# sudo ops/install-systemd.sh
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ "$(id -u)" -ne 0 ]; then
echo "install-systemd: re-run as root, e.g.:" >&2
echo " sudo $0" >&2
exit 1
fi
mkdir -p /etc/systemd/system/bbe-frontend.service.d
cp "${REPO_DIR}/ops/systemd/bbe-frontend.service.d/verify-build.conf" \
/etc/systemd/system/bbe-frontend.service.d/verify-build.conf
cp "${REPO_DIR}/ops/systemd/bbe-site-healthcheck.service" /etc/systemd/system/
cp "${REPO_DIR}/ops/systemd/bbe-site-healthcheck.timer" /etc/systemd/system/
systemctl daemon-reload
systemctl restart bbe-frontend
systemctl enable --now bbe-site-healthcheck.timer
echo "install-systemd: OK"
systemctl status bbe-frontend --no-pager -n 5 || true
systemctl list-timers bbe-site-healthcheck --no-pager || true
@@ -0,0 +1,15 @@
# Drop-in override for bbe-frontend.service.
#
# Refuses to (re)start the frontend when the .next production build is missing or
# has been contaminated by a `next dev` run, so we fail loudly at boot instead of
# silently serving 500s on every request-time route (e.g. /events).
#
# Install:
# sudo mkdir -p /etc/systemd/system/bbe-frontend.service.d
# sudo cp ops/systemd/bbe-frontend.service.d/verify-build.conf \
# /etc/systemd/system/bbe-frontend.service.d/verify-build.conf
# sudo systemctl daemon-reload
# sudo systemctl restart bbe-frontend
[Service]
ExecStartPre=/home/bbe/BelgianBitcoinEmbassy/frontend/scripts/verify-prod-build.sh
+18
View File
@@ -0,0 +1,18 @@
# Oneshot health probe for the BBE site, driven by bbe-site-healthcheck.timer.
#
# Runs as root so it can `systemctl restart bbe-frontend` when the site is down.
#
# Install:
# sudo cp ops/systemd/bbe-site-healthcheck.service /etc/systemd/system/
# sudo cp ops/systemd/bbe-site-healthcheck.timer /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now bbe-site-healthcheck.timer
[Unit]
Description=Belgian Bitcoin Embassy — site health watchdog
After=network.target bbe-frontend.service
[Service]
Type=oneshot
ExecStart=/home/bbe/BelgianBitcoinEmbassy/scripts/healthcheck.sh
SyslogIdentifier=bbe-healthcheck

Some files were not shown because too many files have changed in this diff Show More