From 2ddf6495fb19eb24e3bf6fe7fdbac0632440ece3 Mon Sep 17 00:00:00 2001 From: bbe Date: Thu, 2 Apr 2026 22:13:28 +0200 Subject: [PATCH] Ignore local storage; admin users NIP-05, media, events, footer updates - Add /storage/ and /backend/storage/ to .gitignore - Track meetup time helper, logo asset, and assorted frontend/backend fixes --- .env.example | 4 +- .gitignore | 4 + backend/src/api/media.ts | 6 +- backend/src/api/users.ts | 65 +++++++- frontend/app/admin/events/page.tsx | 13 +- frontend/app/admin/overview/page.tsx | 9 +- frontend/app/admin/users/page.tsx | 210 ++++++++++++++++++++++++-- frontend/app/events/page.tsx | 15 +- frontend/app/media/[id]/route.ts | 37 ++++- frontend/components/public/Footer.tsx | 20 ++- frontend/lib/api.ts | 7 +- frontend/lib/meetupEventTime.ts | 53 +++++++ frontend/public/bbe-logo.png | Bin 0 -> 222549 bytes 13 files changed, 405 insertions(+), 38 deletions(-) create mode 100644 frontend/lib/meetupEventTime.ts create mode 100644 frontend/public/bbe-logo.png diff --git a/.env.example b/.env.example index a3c8bf0..d9225f0 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,8 @@ JWT_SECRET=change-me-to-a-random-secret-in-production BACKEND_PORT=4000 FRONTEND_URL=http://localhost:3000 -# Media storage -MEDIA_STORAGE_PATH=./storage/media +# Media storage (path relative to repo root, or use an absolute path in production) +MEDIA_STORAGE_PATH=storage/media # Frontend (public) NEXT_PUBLIC_API_URL=http://localhost:4000/api diff --git a/.gitignore b/.gitignore index c8dfc0a..33ecced 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,8 @@ coverage/ # Misc .turbo +# Local uploads / media blobs (not versioned) +/storage/ +/backend/storage/ + deploy/ \ No newline at end of file diff --git a/backend/src/api/media.ts b/backend/src/api/media.ts index 6021adc..064729a 100644 --- a/backend/src/api/media.ts +++ b/backend/src/api/media.ts @@ -7,8 +7,12 @@ import slugify from 'slugify'; import { prisma } from '../db/prisma'; import { requireAuth, requireRole } from '../middleware/auth'; +const REPO_ROOT = path.resolve(__dirname, '../../..'); const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH - || path.resolve(__dirname, '../../../storage/media'); + ? path.isAbsolute(process.env.MEDIA_STORAGE_PATH) + ? process.env.MEDIA_STORAGE_PATH + : path.resolve(REPO_ROOT, process.env.MEDIA_STORAGE_PATH) + : path.resolve(REPO_ROOT, 'storage/media'); function ensureStorageDir() { fs.mkdirSync(STORAGE_PATH, { recursive: true }); diff --git a/backend/src/api/users.ts b/backend/src/api/users.ts index 6197675..a87c1cf 100644 --- a/backend/src/api/users.ts +++ b/backend/src/api/users.ts @@ -22,12 +22,17 @@ function getBlockedUsernames(): Set { const USERNAME_REGEX = /^[a-z0-9._-]+$/i; -function validateUsername(username: string): string | null { +function validateUsername( + username: string, + opts?: { allowReserved?: boolean } +): string | null { if (!username || username.trim().length === 0) return 'Username is required'; if (username.length > 50) return 'Username must be 50 characters or fewer'; if (!USERNAME_REGEX.test(username)) return 'Username may only contain letters, numbers, dots, hyphens, and underscores'; - const blocked = getBlockedUsernames(); - if (blocked.has(username.toLowerCase())) return 'This username is reserved'; + if (!opts?.allowReserved) { + const blocked = getBlockedUsernames(); + if (blocked.has(username.toLowerCase())) return 'This username is reserved'; + } return null; } @@ -174,4 +179,58 @@ router.patch( } ); +router.patch( + '/:pubkey', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const pubkeyRaw = req.params.pubkey; + const pubkey = + typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : ''; + if (!pubkey) { + res.status(400).json({ error: 'pubkey is required' }); + return; + } + + const { username } = req.body; + const normalized = (username as string || '').trim().toLowerCase(); + + const error = validateUsername(normalized, { allowReserved: true }); + if (error) { + res.status(400).json({ error }); + return; + } + + const target = await prisma.user.findUnique({ where: { pubkey } }); + if (!target) { + res.status(404).json({ error: 'User not found' }); + return; + } + + const existing = await prisma.user.findFirst({ + where: { + username: { equals: normalized }, + NOT: { pubkey }, + }, + }); + + if (existing) { + res.status(409).json({ error: 'Username is already taken' }); + return; + } + + const user = await prisma.user.update({ + where: { pubkey }, + data: { username: normalized }, + }); + + res.json(user); + } catch (err) { + console.error('Admin update user username error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + export default router; diff --git a/frontend/app/admin/events/page.tsx b/frontend/app/admin/events/page.tsx index 6b5a71f..6d7fa21 100644 --- a/frontend/app/admin/events/page.tsx +++ b/frontend/app/admin/events/page.tsx @@ -23,6 +23,7 @@ import { Check, } from "lucide-react"; import { MediaPickerModal } from "@/components/admin/MediaPickerModal"; +import { getMeetupStartUtc } from "@/lib/meetupEventTime"; interface Meetup { id: string; @@ -73,12 +74,14 @@ type EditableStatus = (typeof EDITABLE_STATUS_OPTIONS)[number]; // Display statuses (includes computed Upcoming/Past from PUBLISHED + date) type DisplayStatus = "DRAFT" | "UPCOMING" | "PAST" | "CANCELLED"; -function getDisplayStatus(meetup: { status: string; date: string }): DisplayStatus { +function getDisplayStatus(meetup: { status: string; date: string; time: string }): DisplayStatus { if (meetup.status === "CANCELLED") return "CANCELLED"; if (meetup.status === "DRAFT") return "DRAFT"; - // PUBLISHED (or legacy UPCOMING/PAST values) → derive from date - if (!meetup.date) return "DRAFT"; - return new Date(meetup.date) > new Date() ? "UPCOMING" : "PAST"; + // PUBLISHED (or legacy UPCOMING/PAST values) → derive from Brussels-local start (date + time) + if (!meetup.date?.trim()) return "DRAFT"; + const start = getMeetupStartUtc(meetup.date, meetup.time || "00:00"); + if (Number.isNaN(start.getTime())) return "DRAFT"; + return start > new Date() ? "UPCOMING" : "PAST"; } const STATUS_LABELS: Record = { @@ -158,7 +161,7 @@ function StatusDropdown({ meetup, onChange, }: { - meetup: { status: string; date: string }; + meetup: { status: string; date: string; time: string }; onChange: (v: string) => void; }) { const [open, setOpen] = useState(false); diff --git a/frontend/app/admin/overview/page.tsx b/frontend/app/admin/overview/page.tsx index c0a7cc1..aeb2e11 100644 --- a/frontend/app/admin/overview/page.tsx +++ b/frontend/app/admin/overview/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; import { api } from "@/lib/api"; +import { getMeetupStartUtc } from "@/lib/meetupEventTime"; import { formatDate } from "@/lib/utils"; import { Calendar, FileText, Tag, User, Plus, Download, FolderOpen } from "lucide-react"; import Link from "next/link"; @@ -54,9 +55,11 @@ export default function OverviewPage() { const shortPubkey = `${user.pubkey.slice(0, 8)}...${user.pubkey.slice(-8)}`; - const upcomingMeetup = meetups.find( - (m) => new Date(m.date) > new Date() - ); + const upcomingMeetup = meetups.find((m) => { + const start = getMeetupStartUtc(m.date, m.time || "00:00"); + if (Number.isNaN(start.getTime())) return false; + return start > new Date(); + }); if (loading) { return ( diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx index ed9580b..4d66867 100644 --- a/frontend/app/admin/users/page.tsx +++ b/frontend/app/admin/users/page.tsx @@ -1,22 +1,55 @@ "use client"; import { useEffect, useState } from "react"; +import Image from "next/image"; +import { nip19 } from "nostr-tools"; import { api } from "@/lib/api"; -import { cn } from "@/lib/utils"; -import { formatDate } from "@/lib/utils"; +import { cn, formatDate } from "@/lib/utils"; +import { fetchNostrProfile, type NostrProfile } from "@/lib/nostr"; import { ShieldCheck, ShieldOff, UserPlus } from "lucide-react"; +function hexToNpub(hex: string): string { + try { + return nip19.npubEncode(hex); + } catch { + return ""; + } +} + +function shortenNpub(npub: string): string { + if (npub.length <= 26) return npub; + 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 "?"; +} + export default function UsersPage() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [promotePubkey, setPromotePubkey] = useState(""); const [promoting, setPromoting] = useState(false); + const [hostname, setHostname] = useState(""); + const [usernameDrafts, setUsernameDrafts] = useState>({}); + const [savingPubkey, setSavingPubkey] = useState(null); + const [nostrByPubkey, setNostrByPubkey] = useState>({}); + const [nostrLoading, setNostrLoading] = useState(false); + const [copiedPubkey, setCopiedPubkey] = useState(null); const loadUsers = async () => { try { const data = await api.getUsers(); setUsers(data); + setUsernameDrafts( + Object.fromEntries( + data.map((u: { pubkey: string; username?: string | null }) => [u.pubkey, u.username ?? ""]) + ) + ); } catch (err: any) { setError(err.message); } finally { @@ -28,6 +61,48 @@ export default function UsersPage() { loadUsers(); }, []); + useEffect(() => { + 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; + try { + await navigator.clipboard.writeText(full); + setCopiedPubkey(pubkey); + window.setTimeout(() => setCopiedPubkey((p) => (p === pubkey ? null : p)), 2000); + } catch { + // ignore + } + }; + const handlePromote = async () => { if (!promotePubkey.trim()) return; setPromoting(true); @@ -64,6 +139,26 @@ export default function UsersPage() { } }; + const handleSaveUsername = async (pubkey: string, currentUsername: string | null | undefined) => { + const trimmed = (usernameDrafts[pubkey] ?? "").trim().toLowerCase(); + if (!trimmed) return; + setSavingPubkey(pubkey); + setError(""); + try { + await api.updateUserUsername(pubkey, trimmed); + await loadUsers(); + } catch (err: any) { + setError(err.message); + setUsernameDrafts((prev) => ({ ...prev, [pubkey]: currentUsername ?? "" })); + } finally { + setSavingPubkey(null); + } + }; + + const handleCancelUsername = (pubkey: string, stored: string | null | undefined) => { + setUsernameDrafts((prev) => ({ ...prev, [pubkey]: stored ?? "" })); + }; + if (loading) { return (
@@ -102,16 +197,109 @@ export default function UsersPage() { {users.length === 0 ? (

No users found.

) : ( - users.map((user) => ( + 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 (
-
-

- {user.pubkey?.slice(0, 12)}...{user.pubkey?.slice(-8)} -

-
+
+
+ {nostrLoading ? ( + + ) : profile?.picture ? ( + {nostrDisplay + ) : ( + + {profileInitials(profile, fullNpub)} + + )} +
+
+
+

+ {nostrLoading ? ( + + ) : nostrDisplay ? ( + nostrDisplay + ) : ( + No Nostr name + )} +

+ +
+
+

+ NIP-05 username +

+

+ Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard). +

+
+ + 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" + /> + + @{hostname || "…"} + + + +
+ {draft.trim() && ( +

+ {draft.trim().toLowerCase()}@{hostname || "…"} +

+ )} +
+
+
{user.role !== "ADMIN" && (
{user.role !== "MODERATOR" && ( @@ -154,7 +343,8 @@ export default function UsersPage() {
)}
- )) + ); + }) )}
diff --git a/frontend/app/events/page.tsx b/frontend/app/events/page.tsx index c66f570..f854d70 100644 --- a/frontend/app/events/page.tsx +++ b/frontend/app/events/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { MapPin, Clock, ArrowRight } from "lucide-react"; import { api } from "@/lib/api"; +import { getMeetupStartUtc } from "@/lib/meetupEventTime"; import { Navbar } from "@/components/public/Navbar"; import { Footer } from "@/components/public/Footer"; @@ -110,8 +111,18 @@ export default function EventsPage() { }, []); const now = new Date(); - const upcoming = meetups.filter((m) => new Date(m.date) >= now); - const past = meetups.filter((m) => new Date(m.date) < now).reverse(); + 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(); return ( <> diff --git a/frontend/app/media/[id]/route.ts b/frontend/app/media/[id]/route.ts index e05604a..4a325fe 100644 --- a/frontend/app/media/[id]/route.ts +++ b/frontend/app/media/[id]/route.ts @@ -3,16 +3,45 @@ import fs from 'fs'; import path from 'path'; import sharp from 'sharp'; +/** Walk up from cwd until we find backend + frontend package.json (monorepo root). */ +function findMonorepoRoot(start: string): string { + let dir = path.resolve(start); + for (let i = 0; i < 8; i++) { + if ( + fs.existsSync(path.join(dir, 'backend', 'package.json')) && + fs.existsSync(path.join(dir, 'frontend', 'package.json')) + ) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return path.resolve(start, '..'); +} + +/** + * Resolve relative MEDIA_STORAGE_PATH against monorepo root — matches + * `backend/src/api/media.ts` (REPO_ROOT + relative path). + */ +function resolveMediaStoragePathFromEnv(envPath: string): string { + if (path.isAbsolute(envPath)) { + return envPath; + } + return path.resolve(findMonorepoRoot(process.cwd()), envPath); +} + /** Resolve at request time so systemd / production env is visible (avoid build-time inlining). */ function getMediaStorageRoot(): string { const envPath = process.env['MEDIA_STORAGE_PATH']; if (envPath) { - return path.resolve(envPath); + return resolveMediaStoragePathFromEnv(envPath); } - const cwd = process.cwd(); + const root = findMonorepoRoot(process.cwd()); + // Prefer repo-root `storage/media` first — matches backend default in `backend/src/api/media.ts`. const candidates = [ - path.resolve(cwd, '../backend/storage/media'), - path.resolve(cwd, '../storage/media'), + path.join(root, 'storage', 'media'), + path.join(root, 'backend', 'storage', 'media'), ]; for (const dir of candidates) { if (fs.existsSync(dir)) { diff --git a/frontend/components/public/Footer.tsx b/frontend/components/public/Footer.tsx index ff67a18..788c1c8 100644 --- a/frontend/components/public/Footer.tsx +++ b/frontend/components/public/Footer.tsx @@ -10,26 +10,32 @@ const LINKS = [ export function Footer() { return ( -