commit 76210db03d81b60c5aa2e8616c7538aa74133ef0 Author: Michilis Date: Wed Apr 1 02:46:53 2026 +0000 first commit Made-with: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..34b1fd6 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Admin pubkeys (comma-separated hex pubkeys) +ADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2 + +# Nostr relays (comma-separated) +RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band + +# Database +DATABASE_URL="file:./dev.db" + +# JWT +JWT_SECRET=change-me-to-a-random-secret-in-production + +# Backend +BACKEND_PORT=4000 +FRONTEND_URL=http://localhost:3000 + +# Media storage +MEDIA_STORAGE_PATH=./storage/media + +# Frontend (public) +NEXT_PUBLIC_API_URL=http://localhost:4000/api +NEXT_PUBLIC_SITE_URL=https://belgianbitcoinembassy.org +NEXT_PUBLIC_SITE_TITLE=Belgian Bitcoin Embassy +NEXT_PUBLIC_SITE_TAGLINE=Belgium's Monthly Bitcoin Meetup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8dfc0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules/ + +# Next.js +frontend/.next/ +frontend/out/ + +# Backend build +backend/dist/ + +# Environment (keep .env.example tracked) +.env +.env.* +!.env.example +**/.env.local + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Logs & debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# TypeScript +*.tsbuildinfo + +# Test / coverage +coverage/ + +# Local SQLite databases +*.db + +# Misc +.turbo + +deploy/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..676e8e5 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Belgian Bitcoin Embassy + +A Nostr-powered community website for Belgium's monthly Bitcoin meetup. + +## Tech Stack + +- **Frontend**: Next.js 14 (App Router), TypeScript, Tailwind CSS, Shadcn/ui +- **Backend**: Express.js, TypeScript, Prisma ORM +- **Database**: SQLite (default), PostgreSQL supported +- **Auth**: Nostr login (NIP-07), JWT sessions + +## Quick Start + +### 1. Clone and install + +```bash +# Install backend dependencies +cd backend +npm install + +# Install frontend dependencies +cd ../frontend +npm install +``` + +### 2. Configure environment + +```bash +# From project root +cp .env.example backend/.env +cp .env.example frontend/.env.local +``` + +Edit `backend/.env` with your admin pubkeys and a secure JWT secret. + +### 3. Set up database + +```bash +cd backend +npx prisma generate +npx prisma db push +npx prisma db seed +``` + +### 4. Run development servers + +```bash +# Terminal 1: Backend +cd backend +npm run dev + +# Terminal 2: Frontend +cd frontend +npm run dev +``` + +- Frontend: http://localhost:3000 +- Backend API: http://localhost:4000/api + +## Project Structure + +``` +/frontend Next.js application + /app App Router pages + /components React components + /lib Utilities and API client + /hooks Custom React hooks + +/backend Express API server + /src Source code + /api Route handlers + /services Business logic + /middleware Auth middleware + /prisma Database schema and migrations + +/context Design specs (reference only) +``` + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | /api/auth/challenge | Get auth challenge | +| POST | /api/auth/verify | Verify Nostr signature | +| GET | /api/posts | List blog posts | +| GET | /api/posts/:slug | Get post by slug | +| POST | /api/posts/import | Import Nostr post | +| PATCH | /api/posts/:id | Update post | +| GET | /api/meetups | List meetups | +| POST | /api/meetups | Create meetup | +| PATCH | /api/meetups/:id | Update meetup | +| POST | /api/moderation/hide | Hide content | +| POST | /api/moderation/block | Block pubkey | +| GET | /api/users | List users | +| POST | /api/users/promote | Promote user | +| GET | /api/categories | List categories | +| POST | /api/categories | Create category | + +## Roles + +- **Admin**: Full access. Defined by pubkeys in `.env` +- **Moderator**: Content moderation. Assigned by admins via dashboard. + +## License + +MIT diff --git a/backend/config/blocked-usernames.txt b/backend/config/blocked-usernames.txt new file mode 100644 index 0000000..8ad243e --- /dev/null +++ b/backend/config/blocked-usernames.txt @@ -0,0 +1,99 @@ +admin +administrator +root +superuser +support +help +helpdesk +contact +info +noreply +no-reply +postmaster +webmaster +hostmaster +abuse +security +privacy +legal +press +media +marketing +nostr +bitcoin +btc +lightning +lnbc +embassy +belgianbitcoinembassy +bbe +api +system +daemon +service +server +www +mail +email +ftp +smtp +imap +pop +pop3 +mx +ns +dns +cdn +static +assets +img +images +video +videos +files +uploads +download +downloads +backup +dev +staging +test +testing +demo +example +sample +null +undefined +true +false +me +you +we +they +user +users +account +accounts +profile +profiles +login +logout +signin +signup +register +password +reset +verify +auth +oauth +callback +redirect +feed +rss +atom +sitemap +robots +favicon +wellknown +_ +__ diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..497a009 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2441 @@ +{ + "name": "bbe-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bbe-backend", + "version": "1.0.0", + "dependencies": { + "@prisma/client": "^6.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.1", + "multer": "^2.1.1", + "nostr-tools": "^2.10.0", + "slugify": "^1.6.8", + "ulid": "^3.0.2", + "uuid": "^11.0.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/helmet": "^0.0.48", + "@types/jsonwebtoken": "^9.0.7", + "@types/morgan": "^1.9.10", + "@types/multer": "^2.1.0", + "@types/uuid": "^10.0.0", + "prisma": "^6.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", + "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", + "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", + "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", + "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.2", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", + "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", + "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2" + } + }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/helmet": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.48.tgz", + "integrity": "sha512-C7MpnvSDrunS1q2Oy1VWCY7CDWHozqSnM8P4tFeRTuzwqni+PYOjEredwcqWG+kLpYcgLsgcY3orHB54gbx2Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nostr-tools": { + "version": "2.23.3", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.3.tgz", + "integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/prisma": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", + "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.2", + "@prisma/engines": "6.19.2" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/slugify": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.8.tgz", + "integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ulid": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/ulid/-/ulid-3.0.2.tgz", + "integrity": "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==", + "license": "MIT", + "bin": { + "ulid": "dist/cli.js" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..f22feb0 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,41 @@ +{ + "name": "bbe-backend", + "version": "1.0.0", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:push": "prisma db push", + "db:seed": "prisma db seed", + "db:studio": "prisma studio" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, + "dependencies": { + "@prisma/client": "^6.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.1", + "multer": "^2.1.1", + "nostr-tools": "^2.10.0", + "slugify": "^1.6.8", + "ulid": "^3.0.2", + "uuid": "^11.0.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/helmet": "^0.0.48", + "@types/jsonwebtoken": "^9.0.7", + "@types/morgan": "^1.9.10", + "@types/multer": "^2.1.0", + "@types/uuid": "^10.0.0", + "prisma": "^6.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } +} diff --git a/backend/prisma/migrations/20260331051150_add_user_username/migration.sql b/backend/prisma/migrations/20260331051150_add_user_username/migration.sql new file mode 100644 index 0000000..96a31cc --- /dev/null +++ b/backend/prisma/migrations/20260331051150_add_user_username/migration.sql @@ -0,0 +1,174 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "pubkey" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'USER', + "displayName" TEXT, + "username" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Meetup" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "date" TEXT NOT NULL, + "time" TEXT NOT NULL, + "location" TEXT NOT NULL, + "link" TEXT, + "imageId" TEXT, + "status" TEXT NOT NULL DEFAULT 'UPCOMING', + "featured" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Media" ( + "id" TEXT NOT NULL PRIMARY KEY, + "slug" TEXT NOT NULL, + "type" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "originalFilename" TEXT NOT NULL, + "uploadedBy" TEXT NOT NULL, + "title" TEXT, + "description" TEXT, + "altText" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Post" ( + "id" TEXT NOT NULL PRIMARY KEY, + "nostrEventId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "content" TEXT NOT NULL, + "excerpt" TEXT, + "authorPubkey" TEXT NOT NULL, + "authorName" TEXT, + "featured" BOOLEAN NOT NULL DEFAULT false, + "visible" BOOLEAN NOT NULL DEFAULT true, + "publishedAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Category" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "PostCategory" ( + "postId" TEXT NOT NULL, + "categoryId" TEXT NOT NULL, + + PRIMARY KEY ("postId", "categoryId"), + CONSTRAINT "PostCategory_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "PostCategory_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "HiddenContent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "nostrEventId" TEXT NOT NULL, + "reason" TEXT, + "hiddenBy" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "BlockedPubkey" ( + "id" TEXT NOT NULL PRIMARY KEY, + "pubkey" TEXT NOT NULL, + "reason" TEXT, + "blockedBy" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Relay" ( + "id" TEXT NOT NULL PRIMARY KEY, + "url" TEXT NOT NULL, + "priority" INTEGER NOT NULL DEFAULT 0, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Setting" ( + "id" TEXT NOT NULL PRIMARY KEY, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "NostrEventCache" ( + "id" TEXT NOT NULL PRIMARY KEY, + "eventId" TEXT NOT NULL, + "kind" INTEGER NOT NULL, + "pubkey" TEXT NOT NULL, + "content" TEXT NOT NULL, + "tags" TEXT NOT NULL, + "createdAt" INTEGER NOT NULL, + "cachedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Submission" ( + "id" TEXT NOT NULL PRIMARY KEY, + "eventId" TEXT, + "naddr" TEXT, + "title" TEXT NOT NULL, + "authorPubkey" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "reviewedBy" TEXT, + "reviewNote" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Faq" ( + "id" TEXT NOT NULL PRIMARY KEY, + "question" TEXT NOT NULL, + "answer" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "showOnHomepage" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "Post_nostrEventId_key" ON "Post"("nostrEventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Post_slug_key" ON "Post"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Relay_url_key" ON "Relay"("url"); + +-- CreateIndex +CREATE UNIQUE INDEX "Setting_key_key" ON "Setting"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "NostrEventCache_eventId_key" ON "NostrEventCache"("eventId"); diff --git a/backend/prisma/migrations/20260331053518_add_meetup_visibility/migration.sql b/backend/prisma/migrations/20260331053518_add_meetup_visibility/migration.sql new file mode 100644 index 0000000..82347bc --- /dev/null +++ b/backend/prisma/migrations/20260331053518_add_meetup_visibility/migration.sql @@ -0,0 +1,23 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Meetup" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "date" TEXT NOT NULL, + "time" TEXT NOT NULL, + "location" TEXT NOT NULL, + "link" TEXT, + "imageId" TEXT, + "status" TEXT NOT NULL DEFAULT 'UPCOMING', + "featured" BOOLEAN NOT NULL DEFAULT false, + "visibility" TEXT NOT NULL DEFAULT 'PUBLIC', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_Meetup" ("createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt") SELECT "createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt" FROM "Meetup"; +DROP TABLE "Meetup"; +ALTER TABLE "new_Meetup" RENAME TO "Meetup"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/backend/prisma/migrations/20260331061812_add_user_username/migration.sql b/backend/prisma/migrations/20260331061812_add_user_username/migration.sql new file mode 100644 index 0000000..ee7578d --- /dev/null +++ b/backend/prisma/migrations/20260331061812_add_user_username/migration.sql @@ -0,0 +1,23 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Meetup" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "date" TEXT NOT NULL, + "time" TEXT NOT NULL, + "location" TEXT NOT NULL, + "link" TEXT, + "imageId" TEXT, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "featured" BOOLEAN NOT NULL DEFAULT false, + "visibility" TEXT NOT NULL DEFAULT 'PUBLIC', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_Meetup" ("createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt", "visibility") SELECT "createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt", "visibility" FROM "Meetup"; +DROP TABLE "Meetup"; +ALTER TABLE "new_Meetup" RENAME TO "Meetup"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..ac84ef2 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,149 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + pubkey String @unique + role String @default("USER") // USER, MODERATOR, ADMIN + displayName String? + username String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Meetup { + id String @id @default(uuid()) + title String + description String + date String + time String + location String + link String? + imageId String? + status String @default("DRAFT") // DRAFT, PUBLISHED, CANCELLED (Upcoming/Past derived from date) + featured Boolean @default(false) + visibility String @default("PUBLIC") // PUBLIC, HIDDEN + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Media { + id String @id // ULID, not auto-generated + slug String + type String // "image" | "video" + mimeType String + size Int + originalFilename String + uploadedBy String + title String? + description String? + altText String? + createdAt DateTime @default(now()) +} + +model Post { + id String @id @default(uuid()) + nostrEventId String @unique + title String + slug String @unique + content String + excerpt String? + authorPubkey String + authorName String? + featured Boolean @default(false) + visible Boolean @default(true) + publishedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + categories PostCategory[] +} + +model Category { + id String @id @default(uuid()) + name String + slug String @unique + sortOrder Int @default(0) + createdAt DateTime @default(now()) + posts PostCategory[] +} + +model PostCategory { + postId String + categoryId String + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + + @@id([postId, categoryId]) +} + +model HiddenContent { + id String @id @default(uuid()) + nostrEventId String + reason String? + hiddenBy String + createdAt DateTime @default(now()) +} + +model BlockedPubkey { + id String @id @default(uuid()) + pubkey String + reason String? + blockedBy String + createdAt DateTime @default(now()) +} + +model Relay { + id String @id @default(uuid()) + url String @unique + priority Int @default(0) + active Boolean @default(true) + createdAt DateTime @default(now()) +} + +model Setting { + id String @id @default(uuid()) + key String @unique + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model NostrEventCache { + id String @id @default(uuid()) + eventId String @unique + kind Int + pubkey String + content String + tags String // JSON string + createdAt Int // event timestamp + cachedAt DateTime @default(now()) +} + +model Submission { + id String @id @default(uuid()) + eventId String? + naddr String? + title String + authorPubkey String + status String @default("PENDING") // PENDING, APPROVED, REJECTED + reviewedBy String? + reviewNote String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Faq { + id String @id @default(uuid()) + question String + answer String + order Int @default(0) + showOnHomepage Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..f8d4662 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,85 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const relays = [ + { url: 'wss://relay.damus.io', priority: 1 }, + { url: 'wss://nos.lol', priority: 2 }, + { url: 'wss://relay.nostr.band', priority: 3 }, + ]; + + for (const relay of relays) { + await prisma.relay.upsert({ + where: { url: relay.url }, + update: {}, + create: relay, + }); + } + + const settings = [ + { key: 'site_title', value: 'Belgian Bitcoin Embassy' }, + { key: 'site_tagline', value: 'Your gateway to Bitcoin in Belgium' }, + { key: 'telegram_link', value: 'https://t.me/belgianbitcoinembassy' }, + { key: 'nostr_link', value: '' }, + { key: 'x_link', value: '' }, + { key: 'youtube_link', value: '' }, + { key: 'discord_link', value: '' }, + { key: 'linkedin_link', value: '' }, + ]; + + for (const setting of settings) { + await prisma.setting.upsert({ + where: { key: setting.key }, + update: {}, + create: setting, + }); + } + + const categories = [ + { name: 'Bitcoin', slug: 'bitcoin', sortOrder: 1 }, + { name: 'Lightning', slug: 'lightning', sortOrder: 2 }, + { name: 'Privacy', slug: 'privacy', sortOrder: 3 }, + { name: 'Education', slug: 'education', sortOrder: 4 }, + { name: 'Community', slug: 'community', sortOrder: 5 }, + ]; + + for (const category of categories) { + await prisma.category.upsert({ + where: { slug: category.slug }, + update: {}, + create: category, + }); + } + + const existingMeetup = await prisma.meetup.findFirst({ + where: { title: 'Monthly Bitcoin Meetup' }, + }); + + if (!existingMeetup) { + await prisma.meetup.create({ + data: { + title: 'Monthly Bitcoin Meetup', + description: + 'Join us for our monthly Bitcoin meetup! We discuss the latest developments, share knowledge, and connect with fellow Bitcoiners in Belgium.', + date: '2025-02-15', + time: '19:00', + location: 'Brussels, Belgium', + link: 'https://meetup.com/example', + status: 'UPCOMING', + featured: true, + }, + }); + } + + console.log('Seed completed successfully.'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts new file mode 100644 index 0000000..8a1b7cd --- /dev/null +++ b/backend/src/api/auth.ts @@ -0,0 +1,53 @@ +import { Router, Request, Response } from 'express'; +import { authService } from '../services/auth'; +import { prisma } from '../db/prisma'; + +const router = Router(); + +router.post('/challenge', async (req: Request, res: Response) => { + try { + const { pubkey } = req.body; + if (!pubkey || typeof pubkey !== 'string') { + res.status(400).json({ error: 'pubkey is required' }); + return; + } + + const challenge = authService.createChallenge(pubkey); + res.json({ challenge }); + } catch (err) { + console.error('Challenge error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.post('/verify', async (req: Request, res: Response) => { + try { + const { pubkey, signedEvent } = req.body; + if (!pubkey || !signedEvent) { + res.status(400).json({ error: 'pubkey and signedEvent are required' }); + return; + } + + const valid = authService.verifySignature(pubkey, signedEvent); + if (!valid) { + res.status(401).json({ error: 'Invalid signature or expired challenge' }); + return; + } + + const role = await authService.getRole(pubkey); + + const dbUser = await prisma.user.upsert({ + where: { pubkey }, + update: { role }, + create: { pubkey, role }, + }); + + const token = authService.generateToken(pubkey, role); + res.json({ token, user: { pubkey, role, username: dbUser.username ?? undefined } }); + } catch (err) { + console.error('Verify error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/backend/src/api/calendar.ts b/backend/src/api/calendar.ts new file mode 100644 index 0000000..d4080a3 --- /dev/null +++ b/backend/src/api/calendar.ts @@ -0,0 +1,163 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; + +const router = Router(); + +function escapeIcs(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n') + .replace(/\r/g, ''); +} + +// ICS lines must be folded at 75 octets (RFC 5545 §3.1) +function fold(line: string): string { + const MAX = 75; + if (line.length <= MAX) return line; + let out = ''; + let pos = 0; + while (pos < line.length) { + if (pos === 0) { + out += line.slice(0, MAX); + pos = MAX; + } else { + out += '\r\n ' + line.slice(pos, pos + MAX - 1); + pos += MAX - 1; + } + } + return out; +} + +function toIcsDate(d: Date): string { + const p = (n: number) => String(n).padStart(2, '0'); + return ( + `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` + + `T${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z` + ); +} + +// Parse "HH:MM", "H:MM am/pm", "Hpm" etc. +function parseLocalTime(t: string): { h: number; m: number } { + const clean = t.trim(); + const m24 = clean.match(/^(\d{1,2}):(\d{2})$/); + if (m24) return { h: parseInt(m24[1]), m: parseInt(m24[2]) }; + + const mAp = clean.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i); + if (mAp) { + let h = parseInt(mAp[1]); + const m = mAp[2] ? parseInt(mAp[2]) : 0; + if (mAp[3].toLowerCase() === 'pm' && h !== 12) h += 12; + if (mAp[3].toLowerCase() === 'am' && h === 12) h = 0; + return { h, m }; + } + return { h: 18, m: 0 }; +} + +// Brussels is UTC+1 (CET) / UTC+2 (CEST). Use +1 as conservative default. +const BRUSSELS_OFFSET_HOURS = 1; + +function parseEventDates( + dateStr: string, + timeStr: string +): { start: Date; end: Date } { + const [year, month, day] = dateStr.split('-').map(Number); + const parts = timeStr.split(/\s*[-–]\s*/); + const { h: startH, m: startM } = parseLocalTime(parts[0]); + + // Convert local Brussels time to UTC + const utcStartH = startH - BRUSSELS_OFFSET_HOURS; + const start = new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0)); + + let end: Date; + if (parts[1]) { + const { h: endH, m: endM } = parseLocalTime(parts[1]); + const utcEndH = endH - BRUSSELS_OFFSET_HOURS; + end = new Date(Date.UTC(year, month - 1, day, utcEndH, endM, 0)); + if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000); + } else { + end = new Date(start.getTime() + 2 * 60 * 60 * 1000); + } + + return { start, end }; +} + +router.get('/ics', async (_req: Request, res: Response) => { + try { + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + const cutoff = sevenDaysAgo.toISOString().slice(0, 10); + + const meetups = await prisma.meetup.findMany({ + where: { date: { gte: cutoff } }, + orderBy: { date: 'asc' }, + }); + + const siteUrl = (process.env.FRONTEND_URL || 'https://belgianbitcoinembassy.org').replace( + /\/$/, + '' + ); + const now = new Date(); + + const lines: string[] = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Belgian Bitcoin Embassy//Events//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + fold('X-WR-CALNAME:Belgian Bitcoin Embassy Events'), + fold('X-WR-CALDESC:Upcoming meetups and events by the Belgian Bitcoin Embassy'), + 'X-WR-TIMEZONE:Europe/Brussels', + ]; + + for (const meetup of meetups) { + try { + const { start, end } = parseEventDates(meetup.date, meetup.time); + const eventUrl = meetup.link || `${siteUrl}/events/${meetup.id}`; + + lines.push('BEGIN:VEVENT'); + lines.push(fold(`UID:${meetup.id}@belgianbitcoinembassy.org`)); + lines.push(`DTSTAMP:${toIcsDate(now)}`); + lines.push(`DTSTART:${toIcsDate(start)}`); + lines.push(`DTEND:${toIcsDate(end)}`); + lines.push(fold(`SUMMARY:${escapeIcs(meetup.title)}`)); + if (meetup.description) { + lines.push(fold(`DESCRIPTION:${escapeIcs(meetup.description)}`)); + } + if (meetup.location) { + lines.push(fold(`LOCATION:${escapeIcs(meetup.location)}`)); + } + lines.push(fold(`URL:${eventUrl}`)); + lines.push( + 'ORGANIZER;CN=Belgian Bitcoin Embassy:mailto:info@belgianbitcoinembassy.org' + ); + // 15-minute reminder alarm + lines.push('BEGIN:VALARM'); + lines.push('TRIGGER:-PT15M'); + lines.push('ACTION:DISPLAY'); + lines.push(fold(`DESCRIPTION:Reminder: ${escapeIcs(meetup.title)}`)); + lines.push('END:VALARM'); + lines.push('END:VEVENT'); + } catch { + // Skip events with unparseable dates + } + } + + lines.push('END:VCALENDAR'); + + const icsBody = lines.join('\r\n') + '\r\n'; + + res.set({ + 'Content-Type': 'text/calendar; charset=utf-8', + 'Content-Disposition': 'inline; filename="bbe-events.ics"', + 'Cache-Control': 'public, max-age=300', + }); + res.send(icsBody); + } catch (err) { + console.error('Calendar ICS error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/backend/src/api/categories.ts b/backend/src/api/categories.ts new file mode 100644 index 0000000..7b7811b --- /dev/null +++ b/backend/src/api/categories.ts @@ -0,0 +1,103 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; + +const router = Router(); + +router.get('/', async (_req: Request, res: Response) => { + try { + const categories = await prisma.category.findMany({ + orderBy: { sortOrder: 'asc' }, + }); + res.json(categories); + } catch (err) { + console.error('List categories error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.post( + '/', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const { name, slug, sortOrder } = req.body; + if (!name || !slug) { + res.status(400).json({ error: 'name and slug are required' }); + return; + } + + const category = await prisma.category.create({ + data: { + name, + slug, + sortOrder: sortOrder || 0, + }, + }); + + res.status(201).json(category); + } catch (err) { + console.error('Create category error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.patch( + '/:id', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const category = await prisma.category.findUnique({ + where: { id: req.params.id as string }, + }); + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + const { name, slug, sortOrder } = req.body; + const updateData: any = {}; + if (name !== undefined) updateData.name = name; + if (slug !== undefined) updateData.slug = slug; + if (sortOrder !== undefined) updateData.sortOrder = sortOrder; + + const updated = await prisma.category.update({ + where: { id: req.params.id as string }, + data: updateData, + }); + + res.json(updated); + } catch (err) { + console.error('Update category error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.delete( + '/:id', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const category = await prisma.category.findUnique({ + where: { id: req.params.id as string }, + }); + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + await prisma.category.delete({ where: { id: req.params.id as string } }); + res.json({ success: true }); + } catch (err) { + console.error('Delete category error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/faqs.ts b/backend/src/api/faqs.ts new file mode 100644 index 0000000..8b96e5b --- /dev/null +++ b/backend/src/api/faqs.ts @@ -0,0 +1,155 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; + +const router = Router(); + +// Public: get FAQs (homepage-visible only by default; pass ?all=true for all) +router.get('/', async (req: Request, res: Response) => { + try { + const showAll = req.query.all === 'true'; + const faqs = await prisma.faq.findMany({ + where: showAll ? undefined : { showOnHomepage: true }, + orderBy: { order: 'asc' }, + }); + res.json(faqs); + } catch (err) { + console.error('List public FAQs error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Admin: get all FAQs regardless of visibility +router.get( + '/all', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (_req: Request, res: Response) => { + try { + const faqs = await prisma.faq.findMany({ + orderBy: { order: 'asc' }, + }); + res.json(faqs); + } catch (err) { + console.error('List all FAQs error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +// Admin: create FAQ +router.post( + '/', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const { question, answer, showOnHomepage } = req.body; + + if (!question || !answer) { + res.status(400).json({ error: 'question and answer are required' }); + return; + } + + const maxOrder = await prisma.faq.aggregate({ _max: { order: true } }); + const nextOrder = (maxOrder._max.order ?? -1) + 1; + + const faq = await prisma.faq.create({ + data: { + question, + answer, + order: nextOrder, + showOnHomepage: showOnHomepage !== undefined ? showOnHomepage : true, + }, + }); + + res.status(201).json(faq); + } catch (err) { + console.error('Create FAQ error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +// Admin: update FAQ +router.patch( + '/:id', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } }); + if (!faq) { + res.status(404).json({ error: 'FAQ not found' }); + return; + } + + const { question, answer, showOnHomepage } = req.body; + const updateData: any = {}; + if (question !== undefined) updateData.question = question; + if (answer !== undefined) updateData.answer = answer; + if (showOnHomepage !== undefined) updateData.showOnHomepage = showOnHomepage; + + const updated = await prisma.faq.update({ + where: { id: req.params.id as string }, + data: updateData, + }); + + res.json(updated); + } catch (err) { + console.error('Update FAQ error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +// Admin: delete FAQ +router.delete( + '/:id', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } }); + if (!faq) { + res.status(404).json({ error: 'FAQ not found' }); + return; + } + + await prisma.faq.delete({ where: { id: req.params.id as string } }); + res.json({ success: true }); + } catch (err) { + console.error('Delete FAQ error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +// Admin: reorder FAQs — accepts array of { id, order } +router.post( + '/reorder', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const { items } = req.body as { items: { id: string; order: number }[] }; + if (!Array.isArray(items)) { + res.status(400).json({ error: 'items array is required' }); + return; + } + + await Promise.all( + items.map(({ id, order }) => + prisma.faq.update({ where: { id }, data: { order } }) + ) + ); + + res.json({ success: true }); + } catch (err) { + console.error('Reorder FAQs error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/media.ts b/backend/src/api/media.ts new file mode 100644 index 0000000..6021adc --- /dev/null +++ b/backend/src/api/media.ts @@ -0,0 +1,217 @@ +import { Router, Request, Response } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { ulid } from 'ulid'; +import slugify from 'slugify'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; + +const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH + || path.resolve(__dirname, '../../../storage/media'); + +function ensureStorageDir() { + fs.mkdirSync(STORAGE_PATH, { recursive: true }); +} + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 100 * 1024 * 1024 }, // 100MB +}); + +const IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; +const VIDEO_MIMES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime']; +const ALLOWED_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES]; + +function getMediaType(mimeType: string): 'image' | 'video' | null { + if (IMAGE_MIMES.includes(mimeType)) return 'image'; + if (VIDEO_MIMES.includes(mimeType)) return 'video'; + return null; +} + +function makeSlug(filename: string): string { + const name = path.parse(filename).name; + return slugify(name, { lower: true, strict: true }) || 'media'; +} + +const router = Router(); + +router.post( + '/upload', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + upload.single('file'), + async (req: Request, res: Response) => { + try { + const file = req.file; + if (!file) { + res.status(400).json({ error: 'No file provided' }); + return; + } + + if (!ALLOWED_MIMES.includes(file.mimetype)) { + res.status(400).json({ error: `Unsupported file type: ${file.mimetype}` }); + return; + } + + const mediaType = getMediaType(file.mimetype); + if (!mediaType) { + res.status(400).json({ error: 'Could not determine media type' }); + return; + } + + const id = ulid(); + 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, + type: mediaType, + 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, + }, + }); + + res.status(201).json({ + id: media.id, + slug: media.slug, + url: `/media/${media.id}`, + }); + } catch (err) { + console.error('Upload media error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.get('/', async (_req: Request, res: Response) => { + try { + const media = await prisma.media.findMany({ + orderBy: { createdAt: 'desc' }, + }); + + const result = media.map((m) => ({ + ...m, + url: `/media/${m.id}`, + })); + + res.json(result); + } catch (err) { + console.error('List media error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.get('/:id', async (req: Request, res: Response) => { + try { + const media = await prisma.media.findUnique({ + where: { id: req.params.id as string }, + }); + + if (!media) { + res.status(404).json({ error: 'Media not found' }); + return; + } + + res.json({ ...media, url: `/media/${media.id}` }); + } catch (err) { + console.error('Get media error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.patch( + '/:id', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const media = await prisma.media.findUnique({ + where: { id: req.params.id as string }, + }); + + if (!media) { + res.status(404).json({ error: 'Media not found' }); + return; + } + + const { title, description, altText } = req.body; + const updateData: any = {}; + + if (title !== undefined) { + updateData.title = title || null; + updateData.slug = title ? makeSlug(title) : media.slug; + } + if (description !== undefined) updateData.description = description || null; + if (altText !== undefined) updateData.altText = altText || null; + + const updated = await prisma.media.update({ + where: { id: media.id }, + data: updateData, + }); + + res.json({ ...updated, url: `/media/${updated.id}` }); + } catch (err) { + console.error('Update media error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.delete( + '/:id', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const media = await prisma.media.findUnique({ + where: { id: req.params.id as string }, + }); + + if (!media) { + res.status(404).json({ error: 'Media not found' }); + return; + } + + const filePath = path.join(STORAGE_PATH, media.id); + const metaPath = path.join(STORAGE_PATH, `${media.id}.json`); + + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath); + + // Clean up any cached resized versions + const cachePath = path.join(STORAGE_PATH, 'cache'); + if (fs.existsSync(cachePath)) { + const cached = fs.readdirSync(cachePath) + .filter((f) => f.startsWith(media.id)); + for (const f of cached) { + fs.unlinkSync(path.join(cachePath, f)); + } + } + + await prisma.media.delete({ where: { id: media.id } }); + + res.json({ success: true }); + } catch (err) { + console.error('Delete media error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/meetups.ts b/backend/src/api/meetups.ts new file mode 100644 index 0000000..20ad9ab --- /dev/null +++ b/backend/src/api/meetups.ts @@ -0,0 +1,253 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; + +const router = Router(); + +function incrementTitle(title: string): string { + const match = title.match(/^(.*#)(\d+)(.*)$/); + if (match) { + const num = parseInt(match[2], 10); + return `${match[1]}${num + 1}${match[3]}`; + } + return `${title} (copy)`; +} + +router.get('/', async (req: Request, res: Response) => { + try { + const status = req.query.status as string | undefined; + const admin = req.query.admin === 'true'; + const where: any = {}; + if (status) where.status = status; + if (!admin) where.visibility = 'PUBLIC'; + + const meetups = await prisma.meetup.findMany({ + where, + orderBy: { date: 'asc' }, + }); + + res.json(meetups); + } catch (err) { + console.error('List meetups error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.get('/:id', async (req: Request, res: Response) => { + try { + const meetup = await prisma.meetup.findUnique({ + where: { id: req.params.id as string }, + }); + + if (!meetup) { + res.status(404).json({ error: 'Meetup not found' }); + return; + } + + res.json(meetup); + } catch (err) { + console.error('Get meetup error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.post( + '/', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const { title, description, date, time, location, link, status, featured, imageId, visibility } = + req.body; + + if (!title || !description || !date || !time || !location) { + res + .status(400) + .json({ error: 'title, description, date, time, and location are required' }); + return; + } + + const meetup = await prisma.meetup.create({ + data: { + title, + description, + date, + time, + location, + link: link || null, + imageId: imageId || null, + status: status || 'DRAFT', + featured: featured || false, + visibility: visibility || 'PUBLIC', + }, + }); + + res.status(201).json(meetup); + } catch (err) { + console.error('Create meetup error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/bulk', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const { action, ids } = req.body as { action: string; ids: string[] }; + + if (!action || !Array.isArray(ids) || ids.length === 0) { + res.status(400).json({ error: 'action and ids are required' }); + return; + } + + if (action === 'delete') { + await prisma.meetup.deleteMany({ where: { id: { in: ids } } }); + res.json({ success: true, affected: ids.length }); + return; + } + + if (action === 'publish') { + await prisma.meetup.updateMany({ + where: { id: { in: ids } }, + data: { status: 'PUBLISHED' }, + }); + res.json({ success: true, affected: ids.length }); + return; + } + + if (action === 'duplicate') { + const originals = await prisma.meetup.findMany({ where: { id: { in: ids } } }); + const created = await Promise.all( + originals.map((m) => + prisma.meetup.create({ + data: { + title: incrementTitle(m.title), + description: m.description, + date: '', + time: '', + location: m.location, + link: m.link || null, + imageId: m.imageId || null, + status: 'DRAFT', + featured: false, + visibility: 'PUBLIC', + }, + }) + ) + ); + res.json(created); + return; + } + + res.status(400).json({ error: 'Unknown action' }); + } catch (err) { + console.error('Bulk meetup error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/:id/duplicate', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const original = await prisma.meetup.findUnique({ where: { id: req.params.id as string } }); + if (!original) { + res.status(404).json({ error: 'Meetup not found' }); + return; + } + + const duplicate = await prisma.meetup.create({ + data: { + title: incrementTitle(original.title), + description: original.description, + date: '', + time: '', + location: original.location, + link: original.link || null, + imageId: original.imageId || null, + status: 'DRAFT', + featured: false, + visibility: 'PUBLIC', + }, + }); + + res.status(201).json(duplicate); + } catch (err) { + console.error('Duplicate meetup error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.patch( + '/:id', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const meetup = await prisma.meetup.findUnique({ + where: { id: req.params.id as string }, + }); + if (!meetup) { + res.status(404).json({ error: 'Meetup not found' }); + return; + } + + const { title, description, date, time, location, link, status, featured, imageId, visibility } = + req.body; + + const updateData: any = {}; + if (title !== undefined) updateData.title = title; + if (description !== undefined) updateData.description = description; + if (date !== undefined) updateData.date = date; + if (time !== undefined) updateData.time = time; + if (location !== undefined) updateData.location = location; + if (link !== undefined) updateData.link = link; + if (status !== undefined) updateData.status = status; + if (featured !== undefined) updateData.featured = featured; + if (imageId !== undefined) updateData.imageId = imageId; + if (visibility !== undefined) updateData.visibility = visibility; + + const updated = await prisma.meetup.update({ + where: { id: req.params.id as string }, + data: updateData, + }); + + res.json(updated); + } catch (err) { + console.error('Update meetup error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.delete( + '/:id', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const meetup = await prisma.meetup.findUnique({ + where: { id: req.params.id as string }, + }); + if (!meetup) { + res.status(404).json({ error: 'Meetup not found' }); + return; + } + + await prisma.meetup.delete({ where: { id: req.params.id as string } }); + res.json({ success: true }); + } catch (err) { + console.error('Delete meetup error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/moderation.ts b/backend/src/api/moderation.ts new file mode 100644 index 0000000..4b68244 --- /dev/null +++ b/backend/src/api/moderation.ts @@ -0,0 +1,143 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; + +const router = Router(); + +router.get( + '/hidden', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (_req: Request, res: Response) => { + try { + const hidden = await prisma.hiddenContent.findMany({ + orderBy: { createdAt: 'desc' }, + }); + res.json(hidden); + } catch (err) { + console.error('List hidden content error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/hide', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const { nostrEventId, reason } = req.body; + if (!nostrEventId) { + res.status(400).json({ error: 'nostrEventId is required' }); + return; + } + + const hidden = await prisma.hiddenContent.create({ + data: { + nostrEventId, + reason: reason || null, + hiddenBy: req.user!.pubkey, + }, + }); + + res.status(201).json(hidden); + } catch (err) { + console.error('Hide content error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.delete( + '/unhide/:id', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const item = await prisma.hiddenContent.findUnique({ + where: { id: req.params.id as string }, + }); + if (!item) { + res.status(404).json({ error: 'Hidden content not found' }); + return; + } + + await prisma.hiddenContent.delete({ where: { id: req.params.id as string } }); + res.json({ success: true }); + } catch (err) { + console.error('Unhide content error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.get( + '/blocked', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (_req: Request, res: Response) => { + try { + const blocked = await prisma.blockedPubkey.findMany({ + orderBy: { createdAt: 'desc' }, + }); + res.json(blocked); + } catch (err) { + console.error('List blocked pubkeys error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/block', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const { pubkey, reason } = req.body; + if (!pubkey) { + res.status(400).json({ error: 'pubkey is required' }); + return; + } + + const blocked = await prisma.blockedPubkey.create({ + data: { + pubkey, + reason: reason || null, + blockedBy: req.user!.pubkey, + }, + }); + + res.status(201).json(blocked); + } catch (err) { + console.error('Block pubkey error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.delete( + '/unblock/:id', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const item = await prisma.blockedPubkey.findUnique({ + where: { id: req.params.id as string }, + }); + if (!item) { + res.status(404).json({ error: 'Blocked pubkey not found' }); + return; + } + + await prisma.blockedPubkey.delete({ where: { id: req.params.id as string } }); + res.json({ success: true }); + } catch (err) { + console.error('Unblock pubkey error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/nip05.ts b/backend/src/api/nip05.ts new file mode 100644 index 0000000..af5618f --- /dev/null +++ b/backend/src/api/nip05.ts @@ -0,0 +1,32 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; + +const router = Router(); + +router.get('/', async (req: Request, res: Response) => { + try { + const nameFilter = req.query.name as string | undefined; + + const where = nameFilter + ? { username: nameFilter.toLowerCase() } + : { username: { not: null } }; + + const users = await prisma.user.findMany({ where: where as any }); + + const names: Record = {}; + for (const user of users) { + if (user.username) { + names[user.username] = user.pubkey; + } + } + + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Content-Type', 'application/json'); + res.json({ names }); + } catch (err) { + console.error('NIP-05 error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/backend/src/api/nostr.ts b/backend/src/api/nostr.ts new file mode 100644 index 0000000..a30053d --- /dev/null +++ b/backend/src/api/nostr.ts @@ -0,0 +1,88 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; +import { nostrService } from '../services/nostr'; + +const router = Router(); + +router.post( + '/fetch', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const { eventId, naddr } = req.body; + if (!eventId && !naddr) { + res.status(400).json({ error: 'eventId or naddr is required' }); + return; + } + + let event = null; + if (naddr) { + event = await nostrService.fetchLongformEvent(naddr); + } else if (eventId) { + event = await nostrService.fetchEvent(eventId); + } + + if (!event) { + res.status(404).json({ error: 'Event not found on relays' }); + return; + } + + res.json(event); + } catch (err) { + console.error('Fetch event error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/cache/refresh', + requireAuth, + requireRole(['ADMIN']), + async (_req: Request, res: Response) => { + try { + const cachedEvents = await prisma.nostrEventCache.findMany(); + let refreshed = 0; + + for (const cached of cachedEvents) { + const event = await nostrService.fetchEvent(cached.eventId, true); + if (event) refreshed++; + } + + res.json({ refreshed, total: cachedEvents.length }); + } catch (err) { + console.error('Cache refresh error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.get( + '/debug/:eventId', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const cached = await prisma.nostrEventCache.findUnique({ + where: { eventId: req.params.eventId as string }, + }); + + if (!cached) { + res.status(404).json({ error: 'Event not found in cache' }); + return; + } + + res.json({ + ...cached, + tags: JSON.parse(cached.tags), + }); + } catch (err) { + console.error('Debug event error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/posts.ts b/backend/src/api/posts.ts new file mode 100644 index 0000000..8e738d8 --- /dev/null +++ b/backend/src/api/posts.ts @@ -0,0 +1,243 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; +import { nostrService } from '../services/nostr'; + +const router = Router(); + +router.get('/', async (req: Request, res: Response) => { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const category = req.query.category as string | undefined; + const skip = (page - 1) * limit; + + const where: any = { visible: true }; + if (category) { + where.categories = { + some: { category: { slug: category } }, + }; + } + + const [posts, total] = await Promise.all([ + prisma.post.findMany({ + where, + include: { + categories: { include: { category: true } }, + }, + orderBy: { publishedAt: 'desc' }, + skip, + take: limit, + }), + prisma.post.count({ where }), + ]); + + res.json({ + posts, + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + }); + } catch (err) { + console.error('List posts error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.get('/:slug', async (req: Request, res: Response) => { + try { + const post = await prisma.post.findUnique({ + where: { slug: req.params.slug as string }, + include: { + categories: { include: { category: true } }, + }, + }); + + if (!post) { + res.status(404).json({ error: 'Post not found' }); + return; + } + + res.json(post); + } catch (err) { + console.error('Get post error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.post( + '/import', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const { eventId, naddr } = req.body; + if (!eventId && !naddr) { + res.status(400).json({ error: 'eventId or naddr is required' }); + return; + } + + let event: any = null; + if (naddr) { + event = await nostrService.fetchLongformEvent(naddr); + } else if (eventId) { + event = await nostrService.fetchEvent(eventId); + } + + if (!event) { + res.status(404).json({ error: 'Event not found on relays' }); + return; + } + + const titleTag = event.tags?.find((t: string[]) => t[0] === 'title'); + const title = titleTag?.[1] || 'Untitled'; + + const slugBase = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const slug = `${slugBase}-${event.id.slice(0, 8)}`; + + const excerpt = event.content.slice(0, 200).replace(/[#*_\n]/g, '').trim(); + + const post = await prisma.post.upsert({ + where: { nostrEventId: event.id }, + update: { + title, + content: event.content, + excerpt, + }, + create: { + nostrEventId: event.id, + title, + slug, + content: event.content, + excerpt, + authorPubkey: event.pubkey, + publishedAt: new Date(event.created_at * 1000), + }, + }); + + res.json(post); + } catch (err) { + console.error('Import post error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.patch( + '/:id', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const { title, slug, excerpt, featured, visible, categories } = req.body; + + const post = await prisma.post.findUnique({ where: { id: req.params.id as string } }); + if (!post) { + res.status(404).json({ error: 'Post not found' }); + return; + } + + const updateData: any = {}; + if (title !== undefined) updateData.title = title; + if (slug !== undefined) updateData.slug = slug; + if (excerpt !== undefined) updateData.excerpt = excerpt; + if (featured !== undefined) updateData.featured = featured; + if (visible !== undefined) updateData.visible = visible; + + const updated = await prisma.post.update({ + where: { id: req.params.id as string }, + data: updateData, + }); + + if (categories && Array.isArray(categories)) { + await prisma.postCategory.deleteMany({ + where: { postId: post.id }, + }); + await prisma.postCategory.createMany({ + data: categories.map((categoryId: string) => ({ + postId: post.id, + categoryId, + })), + }); + } + + const result = await prisma.post.findUnique({ + where: { id: updated.id }, + include: { categories: { include: { category: true } } }, + }); + + res.json(result); + } catch (err) { + console.error('Update post error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +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) { + res.status(404).json({ error: 'Post not found' }); + return; + } + + const reactions = await nostrService.fetchReactions(post.nostrEventId); + res.json({ count: reactions.length, reactions }); + } catch (err) { + console.error('Get reactions error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +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) { + res.status(404).json({ error: 'Post not found' }); + return; + } + + const [replies, hiddenContent, blockedPubkeys] = await Promise.all([ + nostrService.fetchReplies(post.nostrEventId), + prisma.hiddenContent.findMany({ select: { nostrEventId: true } }), + prisma.blockedPubkey.findMany({ select: { pubkey: true } }), + ]); + + const hiddenIds = new Set(hiddenContent.map((h) => h.nostrEventId)); + const blockedKeys = new Set(blockedPubkeys.map((b) => b.pubkey)); + + const filtered = replies.filter( + (r) => !hiddenIds.has(r.id) && !blockedKeys.has(r.pubkey) + ); + + res.json({ count: filtered.length, replies: filtered }); + } catch (err) { + console.error('Get replies error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.delete( + '/:id', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const post = await prisma.post.findUnique({ where: { id: req.params.id as string } }); + if (!post) { + res.status(404).json({ error: 'Post not found' }); + return; + } + + await prisma.post.delete({ where: { id: req.params.id as string } }); + res.json({ success: true }); + } catch (err) { + console.error('Delete post error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/relays.ts b/backend/src/api/relays.ts new file mode 100644 index 0000000..ad89488 --- /dev/null +++ b/backend/src/api/relays.ts @@ -0,0 +1,141 @@ +import { Router, Request, Response } from 'express'; +import { SimplePool } from 'nostr-tools'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; + +const router = Router(); + +router.get( + '/', + requireAuth, + requireRole(['ADMIN']), + async (_req: Request, res: Response) => { + try { + const relays = await prisma.relay.findMany({ + orderBy: { priority: 'asc' }, + }); + res.json(relays); + } catch (err) { + console.error('List relays error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const { url, priority } = req.body; + if (!url) { + res.status(400).json({ error: 'url is required' }); + return; + } + + const relay = await prisma.relay.create({ + data: { + url, + priority: priority || 0, + }, + }); + + res.status(201).json(relay); + } catch (err) { + console.error('Create relay error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.patch( + '/:id', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const relay = await prisma.relay.findUnique({ + where: { id: req.params.id as string }, + }); + if (!relay) { + res.status(404).json({ error: 'Relay not found' }); + return; + } + + const { url, priority, active } = req.body; + const updateData: any = {}; + if (url !== undefined) updateData.url = url; + if (priority !== undefined) updateData.priority = priority; + if (active !== undefined) updateData.active = active; + + const updated = await prisma.relay.update({ + where: { id: req.params.id as string }, + data: updateData, + }); + + res.json(updated); + } catch (err) { + console.error('Update relay error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.delete( + '/:id', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const relay = await prisma.relay.findUnique({ + where: { id: req.params.id as string }, + }); + if (!relay) { + res.status(404).json({ error: 'Relay not found' }); + return; + } + + await prisma.relay.delete({ where: { id: req.params.id as string } }); + res.json({ success: true }); + } catch (err) { + console.error('Delete relay error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/:id/test', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const relay = await prisma.relay.findUnique({ + where: { id: req.params.id as string }, + }); + if (!relay) { + res.status(404).json({ error: 'Relay not found' }); + return; + } + + const pool = new SimplePool(); + const startTime = Date.now(); + + try { + await pool.get([relay.url], { kinds: [1], limit: 1 }); + const latency = Date.now() - startTime; + res.json({ success: true, latency, url: relay.url }); + } catch { + res.json({ success: false, error: 'Connection failed', url: relay.url }); + } finally { + pool.close([relay.url]); + } + } catch (err) { + console.error('Test relay error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/settings.ts b/backend/src/api/settings.ts new file mode 100644 index 0000000..d5db4f8 --- /dev/null +++ b/backend/src/api/settings.ts @@ -0,0 +1,79 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; + +const router = Router(); + +const PUBLIC_SETTINGS = [ + 'site_title', + 'site_tagline', + 'telegram_link', + 'nostr_link', + 'x_link', + 'youtube_link', + 'discord_link', + 'linkedin_link', +]; + +router.get( + '/', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const settings = await prisma.setting.findMany(); + const result: Record = {}; + for (const s of settings) { + result[s.key] = s.value; + } + res.json(result); + } catch (err) { + console.error('List settings error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.get('/public', async (_req: Request, res: Response) => { + try { + const settings = await prisma.setting.findMany({ + where: { key: { in: PUBLIC_SETTINGS } }, + }); + const result: Record = {}; + for (const s of settings) { + result[s.key] = s.value; + } + res.json(result); + } catch (err) { + console.error('Public settings error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.patch( + '/', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const { key, value } = req.body; + if (!key || value === undefined) { + res.status(400).json({ error: 'key and value are required' }); + return; + } + + const setting = await prisma.setting.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }); + + res.json(setting); + } catch (err) { + console.error('Update setting error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/submissions.ts b/backend/src/api/submissions.ts new file mode 100644 index 0000000..158afd9 --- /dev/null +++ b/backend/src/api/submissions.ts @@ -0,0 +1,114 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; + +const router = Router(); + +router.post( + '/', + requireAuth, + async (req: Request, res: Response) => { + try { + const { eventId, naddr, title } = req.body; + if (!title || (!eventId && !naddr)) { + res.status(400).json({ error: 'title and either eventId or naddr are required' }); + return; + } + + const submission = await prisma.submission.create({ + data: { + eventId: eventId || null, + naddr: naddr || null, + title, + authorPubkey: req.user!.pubkey, + }, + }); + + res.status(201).json(submission); + } catch (err) { + console.error('Create submission error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.get( + '/mine', + requireAuth, + async (req: Request, res: Response) => { + try { + const submissions = await prisma.submission.findMany({ + where: { authorPubkey: req.user!.pubkey }, + orderBy: { createdAt: 'desc' }, + }); + + res.json(submissions); + } catch (err) { + console.error('List own submissions error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.get( + '/', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const status = req.query.status as string | undefined; + const where: any = {}; + if (status) where.status = status; + + const submissions = await prisma.submission.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + + res.json(submissions); + } catch (err) { + console.error('List submissions error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.patch( + '/:id', + requireAuth, + requireRole(['ADMIN', 'MODERATOR']), + async (req: Request, res: Response) => { + try { + const { status, reviewNote } = req.body; + if (!status || !['APPROVED', 'REJECTED'].includes(status)) { + res.status(400).json({ error: 'status must be APPROVED or REJECTED' }); + return; + } + + const submission = await prisma.submission.findUnique({ + where: { id: req.params.id as string }, + }); + + if (!submission) { + res.status(404).json({ error: 'Submission not found' }); + return; + } + + const updated = await prisma.submission.update({ + where: { id: req.params.id as string }, + data: { + status, + reviewedBy: req.user!.pubkey, + reviewNote: reviewNote || null, + }, + }); + + res.json(updated); + } catch (err) { + console.error('Review submission error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/api/users.ts b/backend/src/api/users.ts new file mode 100644 index 0000000..6197675 --- /dev/null +++ b/backend/src/api/users.ts @@ -0,0 +1,177 @@ +import { Router, Request, Response } from 'express'; +import { prisma } from '../db/prisma'; +import { requireAuth, requireRole } from '../middleware/auth'; +import fs from 'fs'; +import path from 'path'; + +const BLOCKED_USERNAMES_PATH = path.resolve(__dirname, '../../config/blocked-usernames.txt'); + +function getBlockedUsernames(): Set { + try { + const content = fs.readFileSync(BLOCKED_USERNAMES_PATH, 'utf-8'); + return new Set( + content + .split('\n') + .map((l) => l.trim().toLowerCase()) + .filter(Boolean) + ); + } catch { + return new Set(); + } +} + +const USERNAME_REGEX = /^[a-z0-9._-]+$/i; + +function validateUsername(username: string): 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'; + return null; +} + +const router = Router(); + +router.get( + '/', + requireAuth, + requireRole(['ADMIN']), + async (_req: Request, res: Response) => { + try { + const users = await prisma.user.findMany({ + orderBy: { createdAt: 'desc' }, + }); + res.json(users); + } catch (err) { + console.error('List users error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/promote', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const { pubkey } = req.body; + if (!pubkey) { + res.status(400).json({ error: 'pubkey is required' }); + return; + } + + const user = await prisma.user.upsert({ + where: { pubkey }, + update: { role: 'MODERATOR' }, + create: { pubkey, role: 'MODERATOR' }, + }); + + res.json(user); + } catch (err) { + console.error('Promote user error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.post( + '/demote', + requireAuth, + requireRole(['ADMIN']), + async (req: Request, res: Response) => { + try { + const { pubkey } = req.body; + if (!pubkey) { + res.status(400).json({ error: 'pubkey is required' }); + return; + } + + const user = await prisma.user.upsert({ + where: { pubkey }, + update: { role: 'USER' }, + create: { pubkey, role: 'USER' }, + }); + + res.json(user); + } catch (err) { + console.error('Demote user error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.get( + '/me/username-check', + requireAuth, + async (req: Request, res: Response) => { + try { + const username = (req.query.username as string || '').trim().toLowerCase(); + const error = validateUsername(username); + if (error) { + res.json({ available: false, reason: error }); + return; + } + + const existing = await prisma.user.findFirst({ + where: { + username: { equals: username }, + NOT: { pubkey: req.user!.pubkey }, + }, + }); + + if (existing) { + res.json({ available: false, reason: 'Username is already taken' }); + return; + } + + res.json({ available: true }); + } catch (err) { + console.error('Username check error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.patch( + '/me', + requireAuth, + async (req: Request, res: Response) => { + try { + const { username } = req.body; + const normalized = (username as string || '').trim().toLowerCase(); + + const error = validateUsername(normalized); + if (error) { + res.status(400).json({ error }); + return; + } + + const existing = await prisma.user.findFirst({ + where: { + username: { equals: normalized }, + NOT: { pubkey: req.user!.pubkey }, + }, + }); + + if (existing) { + res.status(409).json({ error: 'Username is already taken' }); + return; + } + + const user = await prisma.user.upsert({ + where: { pubkey: req.user!.pubkey }, + update: { username: normalized }, + create: { pubkey: req.user!.pubkey, username: normalized }, + }); + + res.json(user); + } catch (err) { + console.error('Update profile error:', err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/db/prisma.ts b/backend/src/db/prisma.ts new file mode 100644 index 0000000..ee2f97d --- /dev/null +++ b/backend/src/db/prisma.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from '@prisma/client'; + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; + +export const prisma = globalForPrisma.prisma || new PrismaClient(); + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma; +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..fb59ecf --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,71 @@ +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; + +import authRouter from './api/auth'; +import postsRouter from './api/posts'; +import meetupsRouter from './api/meetups'; +import moderationRouter from './api/moderation'; +import usersRouter from './api/users'; +import categoriesRouter from './api/categories'; +import relaysRouter from './api/relays'; +import settingsRouter from './api/settings'; +import nostrRouter from './api/nostr'; +import submissionsRouter from './api/submissions'; +import mediaRouter from './api/media'; +import faqsRouter from './api/faqs'; +import calendarRouter from './api/calendar'; +import nip05Router from './api/nip05'; + +const app = express(); +const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10); +const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; + +// Trust the first proxy (nginx) so req.ip returns the real client IP +app.set('trust proxy', 1); + +app.use(helmet()); +app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); +app.use(cors({ origin: FRONTEND_URL, credentials: true })); +app.use(express.json()); + +app.use('/api/auth', authRouter); +app.use('/api/posts', postsRouter); +app.use('/api/meetups', meetupsRouter); +app.use('/api/moderation', moderationRouter); +app.use('/api/users', usersRouter); +app.use('/api/categories', categoriesRouter); +app.use('/api/relays', relaysRouter); +app.use('/api/settings', settingsRouter); +app.use('/api/nostr', nostrRouter); +app.use('/api/submissions', submissionsRouter); +app.use('/api/media', mediaRouter); +app.use('/api/faqs', faqsRouter); +app.use('/api/calendar', calendarRouter); +app.use('/api/nip05', nip05Router); + +app.get('/api/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +const server = app.listen(PORT, () => { + console.log(`Backend running on http://localhost:${PORT}`); +}); + +const shutdown = () => { + console.log('Shutting down gracefully…'); + server.close(() => { + console.log('Server closed.'); + process.exit(0); + }); + // Force exit if connections don't drain within 10 seconds + setTimeout(() => process.exit(1), 10_000).unref(); +}; + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..016989e --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,48 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'; + +export interface AuthPayload { + pubkey: string; + role: string; +} + +declare global { + namespace Express { + interface Request { + user?: AuthPayload; + } + } +} + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + const header = req.headers.authorization; + if (!header || !header.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid authorization header' }); + return; + } + + const token = header.slice(7); + try { + const payload = jwt.verify(token, JWT_SECRET) as AuthPayload; + req.user = payload; + next(); + } catch { + res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +export function requireRole(roles: string[]) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.user) { + res.status(401).json({ error: 'Not authenticated' }); + return; + } + if (!roles.includes(req.user.role)) { + res.status(403).json({ error: 'Insufficient permissions' }); + return; + } + next(); + }; +} diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts new file mode 100644 index 0000000..7cee99c --- /dev/null +++ b/backend/src/services/auth.ts @@ -0,0 +1,90 @@ +import jwt from 'jsonwebtoken'; +import { v4 as uuidv4 } from 'uuid'; +import { verifyEvent, type VerifiedEvent, nip19 } from 'nostr-tools'; +import { prisma } from '../db/prisma'; + +const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'; +const CHALLENGE_TTL_MS = 5 * 60 * 1000; + +interface StoredChallenge { + challenge: string; + expiresAt: number; +} + +const challenges = new Map(); + +// Periodically clean up expired challenges +setInterval(() => { + const now = Date.now(); + for (const [key, value] of challenges) { + if (value.expiresAt < now) { + challenges.delete(key); + } + } +}, 60_000); + +export const authService = { + createChallenge(pubkey: string): string { + const challenge = uuidv4(); + challenges.set(pubkey, { + challenge, + expiresAt: Date.now() + CHALLENGE_TTL_MS, + }); + return challenge; + }, + + verifySignature(pubkey: string, signedEvent: VerifiedEvent): boolean { + const stored = challenges.get(pubkey); + if (!stored) return false; + if (stored.expiresAt < Date.now()) { + challenges.delete(pubkey); + return false; + } + + // Verify the event signature + if (!verifyEvent(signedEvent)) return false; + + // Kind 22242 is the NIP-42 auth kind + if (signedEvent.kind !== 22242) return false; + if (signedEvent.pubkey !== pubkey) return false; + + // Check that the challenge tag matches + const challengeTag = signedEvent.tags.find( + (t) => t[0] === 'challenge' + ); + if (!challengeTag || challengeTag[1] !== stored.challenge) return false; + + challenges.delete(pubkey); + return true; + }, + + generateToken(pubkey: string, role: string): string { + return jwt.sign({ pubkey, role }, JWT_SECRET, { expiresIn: '7d' }); + }, + + async getRole(pubkey: string): Promise { + 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; + }); + + if (adminPubkeys.includes(pubkey)) return 'ADMIN'; + + const user = await prisma.user.findUnique({ where: { pubkey } }); + if (user?.role === 'MODERATOR') return 'MODERATOR'; + if (user?.role === 'ADMIN') return 'ADMIN'; + + return 'USER'; + }, +}; diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts new file mode 100644 index 0000000..3907cef --- /dev/null +++ b/backend/src/services/nostr.ts @@ -0,0 +1,146 @@ +import { SimplePool, nip19 } from 'nostr-tools'; +import { prisma } from '../db/prisma'; + +const pool = new SimplePool(); + +async function getRelayUrls(): Promise { + const relays = await prisma.relay.findMany({ + where: { active: true }, + orderBy: { priority: 'asc' }, + }); + return relays.map((r) => r.url); +} + +export const nostrService = { + async fetchEvent(eventId: string, skipCache = false) { + if (!skipCache) { + const cached = await prisma.nostrEventCache.findUnique({ + where: { eventId }, + }); + if (cached) { + return { + id: cached.eventId, + kind: cached.kind, + pubkey: cached.pubkey, + content: cached.content, + tags: JSON.parse(cached.tags), + created_at: cached.createdAt, + }; + } + } + + const relays = await getRelayUrls(); + if (relays.length === 0) return null; + + try { + const event = await pool.get(relays, { ids: [eventId] }); + if (!event) return null; + + await prisma.nostrEventCache.upsert({ + where: { eventId: event.id }, + update: { + content: event.content, + tags: JSON.stringify(event.tags), + }, + create: { + eventId: event.id, + kind: event.kind, + pubkey: event.pubkey, + content: event.content, + tags: JSON.stringify(event.tags), + createdAt: event.created_at, + }, + }); + + return event; + } catch (err) { + console.error('Failed to fetch event:', err); + return null; + } + }, + + async fetchLongformEvent(naddrStr: string) { + let decoded: nip19.AddressPointer; + try { + const result = nip19.decode(naddrStr); + if (result.type !== 'naddr') return null; + decoded = result.data; + } catch { + return null; + } + + const relays = decoded.relays?.length + ? decoded.relays + : await getRelayUrls(); + if (relays.length === 0) return null; + + try { + const event = await pool.get(relays, { + kinds: [decoded.kind], + authors: [decoded.pubkey], + '#d': [decoded.identifier], + }); + if (!event) return null; + + await prisma.nostrEventCache.upsert({ + where: { eventId: event.id }, + update: { + content: event.content, + tags: JSON.stringify(event.tags), + }, + create: { + eventId: event.id, + kind: event.kind, + pubkey: event.pubkey, + content: event.content, + tags: JSON.stringify(event.tags), + createdAt: event.created_at, + }, + }); + + return event; + } catch (err) { + console.error('Failed to fetch longform event:', err); + return null; + } + }, + + async fetchReactions(eventId: string) { + const relays = await getRelayUrls(); + if (relays.length === 0) return []; + + try { + const events = await pool.querySync(relays, { + kinds: [7], + '#e': [eventId], + }); + return events; + } catch (err) { + console.error('Failed to fetch reactions:', err); + return []; + } + }, + + async fetchReplies(eventId: string) { + const relays = await getRelayUrls(); + if (relays.length === 0) return []; + + try { + const events = await pool.querySync(relays, { + kinds: [1], + '#e': [eventId], + }); + return events; + } catch (err) { + console.error('Failed to fetch replies:', err); + return []; + } + }, + + async getRelays() { + return prisma.relay.findMany({ + where: { active: true }, + orderBy: { priority: 'asc' }, + }); + }, +}; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..1951778 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/context/design.md b/context/design.md new file mode 100644 index 0000000..c992191 --- /dev/null +++ b/context/design.md @@ -0,0 +1,78 @@ +# Design System Specification: The Sovereign Editorial + +## 1. Overview & Creative North Star +The "Sovereign Editorial" is the creative North Star for this design system. It moves away from the chaotic, high-frequency aesthetic of "crypto-hype" and instead leans into the quiet authority of a diplomatic institution. This is not a "trading platform"; it is an Embassy. + +The system breaks the standard "web template" look by utilizing **Intentional Asymmetry** and **Tonal Depth**. We prioritize a high-end, editorial feel through expansive whitespace (using the `20` and `24` spacing tokens), overlapping elements that break the grid, and a typography scale that treats information as a curated exhibition rather than a data dump. + +--- + +## 2. Colors: Tonal Depth & The "No-Line" Rule +The palette is rooted in a "Rich Black" foundation, using `surface` (`#131313`) as the canvas. + +### The "No-Line" Rule +**Explicit Instruction:** 1px solid borders for sectioning are strictly prohibited. +Structure must be defined solely through: +- **Background Shifts:** Placing a `surface-container-low` section against a `surface` background. +- **Negative Space:** Using large gaps from the Spacing Scale (e.g., `12` or `16`) to imply boundaries. + +### Surface Hierarchy & Nesting +Treat the UI as a series of physical layers, like stacked sheets of obsidian glass. +- **Base Layer:** `surface` (`#131313`) +- **Secondary Sectioning:** `surface-container-low` (`#1c1b1b`) +- **Interactive/Floating Cards:** `surface-container-high` (`#2a2a2a`) +- **The "Glass & Gradient" Rule:** For primary CTAs and hero highlights, use a subtle linear gradient from `primary` (`#ffb874`) to `primary-container` (`#f7931a`). Floating navigation or "pinned" elements must use `surface-bright` with a 15px backdrop-blur to create a premium glassmorphism effect. + +--- + +## 3. Typography: Authority Through Scale +We use **Inter** (as the modern web equivalent to San Francisco) to provide a neutral, trustworthy foundation. The hierarchy is designed to feel like a high-end broadsheet. + +- **Display (The Statement):** Use `display-lg` (3.5rem) for hero statements. Tighten letter-spacing to `-0.02em` for a "premium print" look. +- **Headline (The Narrative):** `headline-lg` (2rem) and `headline-md` (1.75rem) provide the educational structure. +- **Body (The Education):** `body-lg` (1rem) is the workhorse. We prioritize a line-height of 1.6 to ensure readability for long-form educational content. +- **Labels (The Metadata):** `label-md` (0.75rem) should be used in uppercase with `0.05em` letter spacing for a technical, diplomatic feel. + +--- + +## 4. Elevation & Depth: Tonal Layering +Traditional drop shadows are replaced by **Ambient Occlusion** and **Material Stacking**. + +- **The Layering Principle:** To lift a card, do not add a shadow. Instead, place a `surface-container-lowest` (`#0e0e0e`) element inside a `surface-container` (`#201f1f`) section. The shift in value creates a natural perception of depth. +- **Ambient Shadows:** When a "floating" modal is necessary, use a blur of `40px` with an 8% opacity of the `on-surface` color. It should feel like a soft glow rather than a harsh shadow. +- **The "Ghost Border" Fallback:** If accessibility requires a stroke (e.g., input fields), use `outline-variant` at 15% opacity. Never use 100% opaque borders. + +--- + +## 5. Components + +### Buttons +- **Primary:** Gradient background (`primary` to `primary_container`), `on-primary` text. Shape: `md` (0.75rem) corner radius. +- **Secondary:** `surface-container-highest` background with `on-surface` text. No border. +- **Tertiary:** Text-only using `primary_fixed_dim`, strictly for low-priority actions. + +### Cards & Lists +- **The Divider Ban:** Dividers are forbidden. Separate list items using `spacing-4` (1.4rem) of vertical whitespace or by alternating background colors between `surface-container-low` and `surface-container-lowest`. +- **Cards:** Always use `rounded-lg` (1rem). Content should have a minimum internal padding of `spacing-6` (2rem). + +### Input Fields +- **State:** Background should be `surface-container-highest`. +- **Focus:** Transition the "Ghost Border" from 15% to 40% opacity of the `primary` color. Do not use heavy glow effects. + +### Signature Components for the Embassy +- **The "Knowledge Card":** A large-format card using `surface-container-low` with an asymmetrical layout—typography pushed to the left, and a subtle, desaturated Bitcoin icon overlapping the right edge at 5% opacity. +- **The "Trust Indicator":** A persistent, glassmorphic "Status" bar at the bottom of mobile screens, providing real-time network reassurance without cluttering the main content. + +--- + +## 6. Do’s and Don’ts + +### Do: +- **Use "Aggressive" Whitespace:** If a section feels "almost right," double the padding. +- **Embrace Asymmetry:** Align headings to the left while keeping body text centered in a narrower column to create visual interest. +- **Mobile-First Layering:** On mobile, stack containers vertically, using `surface-container-lowest` to "ground" the footer. + +### Don’t: +- **Don’t use "Crypto-Green/Red":** Use `error` (`#ffb4ab`) sparingly for critical warnings only. Educational growth is signaled by Bitcoin Orange, not "Stock Market Green." +- **Don’t use Dividers:** If you need a line to separate content, you have failed to use the Spacing Scale correctly. +- **Don’t Over-Animate:** Transitions should be "Snappy & Subtle" (200ms ease-out). Avoid bouncing or heavy staggered entrances. \ No newline at end of file diff --git a/context/homepage.html b/context/homepage.html new file mode 100644 index 0000000..f87ccdd --- /dev/null +++ b/context/homepage.html @@ -0,0 +1,296 @@ + + + + + +Belgian Bitcoin Embassy | Monthly Meetup + + + + + + + + + + +
+ +
+
+
+Brussels, Belgium +

+ Belgium's Monthly
Bitcoin Meetup +

+

+ A sovereign space for education, technical discussion, and community. No hype, just signal. Join us at the Embassy. +

+ +
+
+
+
+Mar +15 +
+
+

Next Gathering

+

+location_on Brussels, BE • 19:00 +

+
+
+ +
+
+currency_bitcoin +
+
+
+ +
+
+ +
+
+
+
+
+account_balance +
+

Money without banks

+

Operate outside the legacy financial system with peer-to-peer digital sound money.

+
+
+
+all_inclusive +
+

Scarcity: 21 million

+

A mathematical certainty of fixed supply. No inflation, no dilution, ever.

+
+
+
+key +
+

Self-custody

+

True ownership. Your keys, your bitcoin. No counterparty risk, absolute freedom.

+
+
+
+
+ +
+
+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. +

+
+
+
+ +
+
+
+
+

Community Moments

+

The Belgian Bitcoin scene in action.

+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+ +
+ +
+
+

Start your Bitcoin journey today

+

The best time to learn was 10 years ago. The second best time is today. Join the community.

+
+ + + +
+
+ +
+hub +
+
+
+ +
+
+
Belgian Bitcoin Embassy
+ +

© Belgian Bitcoin Embassy. No counterparty risk.

+
+
+ \ No newline at end of file diff --git a/context/join community.html b/context/join community.html new file mode 100644 index 0000000..8926185 --- /dev/null +++ b/context/join community.html @@ -0,0 +1,283 @@ + + + + + + BBE - Join the Community (Compact) + + + + + +
+ +
+

Join the Embassy

+

Connect with local Belgian Bitcoiners, builders, and educators.

+
+ + +
+ + + \ No newline at end of file diff --git a/context/overview.md b/context/overview.md new file mode 100644 index 0000000..5270f8d --- /dev/null +++ b/context/overview.md @@ -0,0 +1,398 @@ +# Belgian Bitcoin Embassy Website + +## 1. Overview + +The Belgian Bitcoin Embassy (BBE) website is a community-driven, Nostr-powered platform centered around a monthly Bitcoin meetup in Belgium. + +It is not a corporate site or institutional platform. It is a lightweight, curated Nostr client that: + +- showcases the next meetup +- connects users to the community +- displays curated Bitcoin content +- allows users to interact via Nostr (likes, comments) + +The platform combines a simple public website with a role-based admin/moderation system. + +--- + +## 2. Core Goals + +The website must: + +- clearly present the next monthly meetup +- grow the Belgian Bitcoin community +- aggregate and curate Nostr content +- allow social interaction via Nostr +- remain simple, fast, and easy to maintain + +--- + +## 3. Tech Direction + +Frontend: +- Next.js (App Router recommended) +- Component-based architecture +- Tailwind CSS (based on design system) + +Backend: +- Lightweight API (Node.js / Go) +- Nostr integration layer +- Caching layer for events and posts + +Auth: +- Nostr extension login (NIP-07 or signer) + +--- + +## 4. Public Website Structure + +### Routes + +- `/` → Onepage homepage +- `/blog` → Blog overview +- `/blog/[slug]` → Blog post page +- `/admin` → Dashboard (role-based) + +--- + +## 5. Homepage (Onepage) + +### 5.1 Hero + +- Headline: Biggest Bitcoin community in Belgium +- Subtext: Monthly meetup + community +- CTA: + - Join Meetup + - Join Telegram + - Follow on Nostr + +--- + +### 5.2 Next Meetup + +Critical section. + +Fields: +- title +- date +- time +- city +- venue +- description +- link + +Actions: +- Attend / RSVP + +--- + +### 5.3 About + +Short explanation: +- what BBE is +- community-driven +- beginner friendly + +--- + +### 5.4 Why Join + +- meet local Bitcoiners +- learn Bitcoin +- discover events +- connect with community + +--- + +### 5.5 Community Links + +Platforms: +- Telegram +- Nostr +- X +- YouTube +- Discord +- LinkedIn + +Each includes: +- icon +- short description +- link + +--- + +### 5.6 Blog Preview + +- featured posts +- latest posts +- categories + +--- + +### 5.7 FAQ + +- beginner friendly? +- do I need bitcoin? +- cost? +- location? + +--- + +### 5.8 Final CTA + +- join meetup +- follow community + +--- + +## 6. Blog System (Nostr-Based) + +### Source + +- Nostr longform events +- imported manually by admins + +### Flow + +1. Admin inputs event id or naddr +2. Backend fetches from relays +3. Event is parsed and cached +4. Admin edits metadata +5. Post is published + +--- + +### Blog Page `/blog` + +- list of posts +- category filters +- featured post + +--- + +### Blog Post `/blog/[slug]` + +- title +- content +- categories +- author +- Nostr interactions: + - likes + - comments + +--- + +## 7. Nostr Interaction Layer + +Users can: + +- login with Nostr +- like posts (reactions) +- comment (replies) +- interact with events + +Backend: + +- fetch events from relays +- cache data +- apply local moderation + +Important: +- no deletion on Nostr +- moderation is local only + +--- + +## 8. Roles & Auth + +### Auth + +- Nostr login (extension) +- signature verification +- session/JWT + +### Roles + +Defined by pubkeys. + +Admins: +- set in `.env` + +Moderators: +- assigned by admins + +--- + +## 9. Admin Dashboard + +Route: `/admin` + +Admins have full control. + +### Tabs + +#### 9.1 Overview +- meetup summary +- latest posts +- quick actions + +#### 9.2 Events +- create/edit meetups +- mark upcoming/past +- manage event content +- moderate comments + +#### 9.3 Blog +- import Nostr posts +- edit metadata +- assign categories +- publish/unpublish +- feature posts + +#### 9.4 Moderation +- view comments +- filter by post/event +- hide content +- block pubkeys (local) + +#### 9.5 Users +- list users (pubkeys) +- promote to moderator +- remove moderator +- block users + +#### 9.6 Categories +- create/edit/delete +- reorder + +#### 9.7 Relays +- add/remove relays +- set priority +- test connectivity + +#### 9.8 Settings +- site title +- tagline +- social links +- feature toggles + +#### 9.9 Nostr Tools +- manual fetch +- cache refresh +- debug events + +--- + +## 10. Moderator Dashboard + +Moderators have content-only control. + +### Tabs + +#### 10.1 Moderation +- comment stream +- hide spam +- filter content + +#### 10.2 Events +- view meetups +- moderate comments +- minor edits + +#### 10.3 Blog +- edit metadata +- assign categories +- publish/unpublish + +#### 10.4 Reports (optional) +- flagged content +- moderation actions + +--- + +## 11. Data Models + +### Meetup +- title +- description +- date +- location +- link +- status + +### Blog Post +- nostr_event_id +- title +- slug +- content +- excerpt +- categories +- featured +- visible + +### Category +- name +- slug + +### User +- pubkey +- role + +--- + +## 12. Component Structure + +Public: +- HeroSection +- NextMeetupCard +- AboutSection +- CommunityLinks +- BlogPreview +- FAQSection + +Admin: +- AdminSidebar +- MeetupEditor +- PostManager +- CategoryManager +- SettingsPanel + +--- + +## 13. Design Principles + +- dark theme +- Bitcoin orange accent +- large whitespace +- no borders (use spacing) +- layered surfaces +- minimal animation + +--- + +## 14. MVP Scope + +Build first: + +- homepage +- blog +- blog post page +- Nostr login +- admin dashboard +- moderator dashboard +- meetup system +- Nostr blog import + +--- + +## 15. Key Principle + +This is not a CMS. + +This is a curated Nostr client for a Bitcoin meetup community. + +Keep it simple, fast, and focused on: +- meetup +- community +- content + diff --git a/context/pages.md b/context/pages.md new file mode 100644 index 0000000..dc92c47 --- /dev/null +++ b/context/pages.md @@ -0,0 +1,773 @@ +# Belgian Bitcoin Embassy Website + +## 1. Overview + +The Belgian Bitcoin Embassy (BBE) website is a community-driven, Nostr-powered platform centered around a monthly Bitcoin meetup in Belgium. + +It is not a corporate site or institutional platform. It is a lightweight, curated Nostr client that: + +- showcases the next meetup +- connects users to the community +- displays curated Bitcoin content +- allows users to interact via Nostr (likes, comments) + +The platform combines a simple public website with a role-based admin/moderation system. + +--- + +## 2. Core Goals + +The website must: + +- clearly present the next monthly meetup +- grow the Belgian Bitcoin community +- aggregate and curate Nostr content +- allow social interaction via Nostr +- remain simple, fast, and easy to maintain + +--- + +## 3. Tech Direction + +Frontend: +- Next.js (App Router recommended) +- Component-based architecture +- Tailwind CSS (based on design system) + +Backend: +- Lightweight API (Node.js / Go) +- Nostr integration layer +- Caching layer for events and posts + +Auth: +- Nostr extension login (NIP-07 or signer) + +--- + +## 4. Public Website Structure + +### Routes + +- `/` → Onepage homepage +- `/blog` → Blog overview +- `/blog/[slug]` → Blog post page +- `/admin` → Dashboard (role-based) + +--- + +## 5. Homepage (Onepage) + +### 5.1 Hero + +- Headline: Biggest Bitcoin community in Belgium +- Subtext: Monthly meetup + community +- CTA: + - Join Meetup + - Join Telegram + - Follow on Nostr + +--- + +### 5.2 Next Meetup + +Critical section. + +Fields: +- title +- date +- time +- city +- venue +- description +- link + +Actions: +- Attend / RSVP + +--- + +### 5.3 About + +Short explanation: +- what BBE is +- community-driven +- beginner friendly + +--- + +### 5.4 Why Join + +- meet local Bitcoiners +- learn Bitcoin +- discover events +- connect with community + +--- + +### 5.5 Community Links + +Platforms: +- Telegram +- Nostr +- X +- YouTube +- Discord +- LinkedIn + +Each includes: +- icon +- short description +- link + +--- + +### 5.6 Blog Preview + +- featured posts +- latest posts +- categories + +--- + +### 5.7 FAQ + +- beginner friendly? +- do I need bitcoin? +- cost? +- location? + +--- + +### 5.8 Final CTA + +- join meetup +- follow community + +--- + +## 6. Blog System (Nostr-Based) + +### Source + +- Nostr longform events +- imported manually by admins + +### Flow + +1. Admin inputs event id or naddr +2. Backend fetches from relays +3. Event is parsed and cached +4. Admin edits metadata +5. Post is published + +--- + +### Blog Page `/blog` + +- list of posts +- category filters +- featured post + +--- + +### Blog Post `/blog/[slug]` + +- title +- content +- categories +- author +- Nostr interactions: + - likes + - comments + +--- + +## 7. Nostr Interaction Layer + +Users can: + +- login with Nostr +- like posts (reactions) +- comment (replies) +- interact with events + +Backend: + +- fetch events from relays +- cache data +- apply local moderation + +Important: +- no deletion on Nostr +- moderation is local only + +--- + +## 8. Roles & Auth + +### Auth + +- Nostr login (extension) +- signature verification +- session/JWT + +### Roles + +Defined by pubkeys. + +Admins: +- set in `.env` + +Moderators: +- assigned by admins + +--- + +## 9. Admin Dashboard + +Route: `/admin` + +Admins have full control. + +### Tabs + +#### 9.1 Overview +- meetup summary +- latest posts +- quick actions + +#### 9.2 Events +- create/edit meetups +- mark upcoming/past +- manage event content +- moderate comments + +#### 9.3 Blog +- import Nostr posts +- edit metadata +- assign categories +- publish/unpublish +- feature posts + +#### 9.4 Moderation +- view comments +- filter by post/event +- hide content +- block pubkeys (local) + +#### 9.5 Users +- list users (pubkeys) +- promote to moderator +- remove moderator +- block users + +#### 9.6 Categories +- create/edit/delete +- reorder + +#### 9.7 Relays +- add/remove relays +- set priority +- test connectivity + +#### 9.8 Settings +- site title +- tagline +- social links +- feature toggles + +#### 9.9 Nostr Tools +- manual fetch +- cache refresh +- debug events + +--- + +## 10. Moderator Dashboard + +Moderators have content-only control. + +### Tabs + +#### 10.1 Moderation +- comment stream +- hide spam +- filter content + +#### 10.2 Events +- view meetups +- moderate comments +- minor edits + +#### 10.3 Blog +- edit metadata +- assign categories +- publish/unpublish + +#### 10.4 Reports (optional) +- flagged content +- moderation actions + +--- + +## 11. Data Models + +### Meetup +- title +- description +- date +- location +- link +- status + +### Blog Post +- nostr_event_id +- title +- slug +- content +- excerpt +- categories +- featured +- visible + +### Category +- name +- slug + +### User +- pubkey +- role + +--- + +## 12. Component Structure + +Public: +- HeroSection +- NextMeetupCard +- AboutSection +- CommunityLinks +- BlogPreview +- FAQSection + +Admin: +- AdminSidebar +- MeetupEditor +- PostManager +- CategoryManager +- SettingsPanel + +--- + +## 13. Design Principles + +- dark theme +- Bitcoin orange accent +- large whitespace +- no borders (use spacing) +- layered surfaces +- minimal animation + +--- + +## 14. MVP Scope + +Build first: + +- homepage +- blog +- blog post page +- Nostr login +- admin dashboard +- moderator dashboard +- meetup system +- Nostr blog import + +--- + +## 15. Key Principle + +This is not a CMS. + +This is a curated Nostr client for a Bitcoin meetup community. + +Keep it simple, fast, and focused on: +- meetup +- community +- content + + +--- + +# pages.md + +## 1. Routing Overview + +Public routes: +- `/` → Homepage (onepage) +- `/blog` → Blog listing +- `/blog/[slug]` → Blog detail + +Auth / Dashboard routes: +- `/admin` → Dashboard entry (role-based) +- `/admin/overview` +- `/admin/events` +- `/admin/blog` +- `/admin/moderation` +- `/admin/users` (admin only) +- `/admin/categories` +- `/admin/relays` (admin only) +- `/admin/settings` (admin only) +- `/admin/nostr` (admin only tools) + +System routes (optional): +- `/api/*` → Backend endpoints +- `/health` → Health check + +All `/admin/*` routes require Nostr authentication. + +--- + +## 2. Layouts + +### 2.1 Public Layout + +Used by `/` and `/blog*` + +Structure: +- Top Navigation +- Page Content +- Footer + +Top Navigation: +- Logo (BBE) +- Links (scroll or anchor): + - Meetup + - About + - Community + - Blog +- CTA button: Join Meetup + +Footer: +- Logo +- Links: Privacy, Terms, Contact +- Social links + +--- + +### 2.2 Admin Layout + +Used by `/admin/*` + +Structure: +- Sidebar (left) +- Content area (right) +- Top bar (optional) + +Sidebar: +- Overview +- Events +- Blog +- Moderation +- Categories +- (Admins only) + - Users + - Relays + - Settings + - Nostr Tools + +Role-based rendering: +- Moderator sees limited menu +- Admin sees full menu + +--- + +## 3. Homepage `/` + +Single page composed of sections. + +### Sections (top to bottom) + +#### 3.1 HeroSection + +Content: +- headline +- subtext +- CTA buttons: + - Join Meetup + - Join Telegram + - Follow on Nostr + +#### 3.2 NextMeetupSection + +Data source: Meetup API + +Content: +- date +- time +- city +- venue +- description +- CTA: Attend Meetup + +#### 3.3 AboutSection + +Static/admin-editable content. + +#### 3.4 WhyJoinSection + +Static list of benefits. + +#### 3.5 CommunityLinksSection + +Dynamic from settings: +- Telegram +- Nostr +- X +- YouTube +- Discord +- LinkedIn + +#### 3.6 BlogPreviewSection + +Data source: Blog API + +Content: +- featured post +- latest posts + +#### 3.7 FAQSection + +Static/admin-editable. + +#### 3.8 FinalCTASection + +- Join Meetup +- Follow Community + +--- + +## 4. Blog Listing `/blog` + +### Layout + +- Header (title + description) +- Category filter +- Post grid/list + +### Features + +- filter by category +- highlight featured post +- pagination or infinite scroll + +### Data + +From cached Nostr posts. + +--- + +## 5. Blog Detail `/blog/[slug]` + +### Layout + +- Title +- Metadata (date, author) +- Content +- Categories +- Interaction section +- Related posts + +### Interaction + +If user logged in (Nostr): +- Like button +- Comment input + +Display: +- likes count +- comments (Nostr replies) + +--- + +## 6. Admin Entry `/admin` + +### Behavior + +- if not logged in → show Nostr login screen +- if logged in → redirect to `/admin/overview` + +### Login Screen + +- button: Login with Nostr extension +- explanation text + +--- + +## 7. Admin Pages + +### 7.1 `/admin/overview` + +Dashboard summary: +- next meetup +- recent posts +- recent activity +- quick actions + +--- + +### 7.2 `/admin/events` + +Meetup management. + +Views: +- list of meetups +- create/edit form + +Fields: +- title +- description +- date/time +- location +- link + +Actions: +- create +- edit +- delete +- mark featured + +Moderation: +- view comments +- hide comments + +--- + +### 7.3 `/admin/blog` + +Blog management. + +Views: +- list of posts +- import tool + +Import flow: +- paste event id or naddr +- fetch preview +- confirm import + +Post editing: +- title +- excerpt +- slug +- categories +- featured toggle +- visibility toggle + +--- + +### 7.4 `/admin/moderation` + +Moderation center. + +Views: +- comment stream +- filters: + - by post + - by event + - by user + +Actions: +- hide comment +- mark spam +- block pubkey (local) + +--- + +### 7.5 `/admin/users` (admin only) + +User management. + +Views: +- list of pubkeys + +Actions: +- promote to moderator +- remove moderator +- block user + +--- + +### 7.6 `/admin/categories` + +Category management. + +Actions: +- create +- edit +- delete +- reorder + +--- + +### 7.7 `/admin/relays` (admin only) + +Relay configuration. + +Actions: +- add relay +- remove relay +- set priority +- test connection + +--- + +### 7.8 `/admin/settings` (admin only) + +Global settings. + +Fields: +- site title +- tagline +- social links +- feature toggles + +--- + +### 7.9 `/admin/nostr` (admin only) + +Advanced tools. + +Features: +- manual event fetch +- cache refresh +- debug viewer + +--- + +## 8. Access Control + +- `/admin/*` requires Nostr auth +- roles checked server-side +- UI adapts based on role + +--- + +## 9. Error Pages + +- `/404` +- `/500` + +Simple, minimal, same design style. + +--- + +## 10. Key Principles + +- keep routes minimal +- keep pages focused +- no unnecessary nesting +- everything role-based + +The structure must stay simple and predictable for developers. + diff --git a/frontend/app/.well-known/nostr.json/route.ts b/frontend/app/.well-known/nostr.json/route.ts new file mode 100644 index 0000000..8313aa4 --- /dev/null +++ b/frontend/app/.well-known/nostr.json/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'; + +export async function GET(req: NextRequest) { + const name = req.nextUrl.searchParams.get('name'); + const upstream = new URL(`${API_URL}/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', + }, + }); +} diff --git a/frontend/app/admin/blog/page.tsx b/frontend/app/admin/blog/page.tsx new file mode 100644 index 0000000..e29710a --- /dev/null +++ b/frontend/app/admin/blog/page.tsx @@ -0,0 +1,337 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { + Pencil, + Trash2, + X, + Download, + Star, + EyeOff, +} from "lucide-react"; + +export default function BlogPage() { + const [posts, setPosts] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [importOpen, setImportOpen] = useState(false); + const [importInput, setImportInput] = useState(""); + const [importPreview, setImportPreview] = useState(null); + const [importing, setImporting] = useState(false); + const [fetching, setFetching] = useState(false); + const [editingPost, setEditingPost] = useState(null); + const [editForm, setEditForm] = useState({ + title: "", + slug: "", + excerpt: "", + categories: [] as string[], + featured: false, + visible: true, + }); + + const loadData = async () => { + try { + const [p, c] = await Promise.all([ + api.getPosts({ all: true }), + api.getCategories(), + ]); + setPosts(p.posts || []); + setCategories(c); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, []); + + const handleFetchPreview = async () => { + if (!importInput.trim()) return; + setFetching(true); + setError(""); + try { + const isNaddr = importInput.startsWith("naddr"); + const data = await api.fetchNostrEvent( + isNaddr ? { naddr: importInput } : { eventId: importInput } + ); + setImportPreview(data); + } catch (err: any) { + setError(err.message); + setImportPreview(null); + } finally { + setFetching(false); + } + }; + + const handleImport = async () => { + if (!importInput.trim()) return; + setImporting(true); + setError(""); + try { + const isNaddr = importInput.startsWith("naddr"); + await api.importPost( + isNaddr ? { naddr: importInput } : { eventId: importInput } + ); + setImportInput(""); + setImportPreview(null); + setImportOpen(false); + await loadData(); + } catch (err: any) { + setError(err.message); + } finally { + setImporting(false); + } + }; + + const openEdit = (post: any) => { + setEditingPost(post); + setEditForm({ + title: post.title || "", + slug: post.slug || "", + excerpt: post.excerpt || "", + categories: post.categories?.map((c: any) => c.id || c) || [], + featured: post.featured || false, + visible: post.visible !== false, + }); + }; + + const handleSaveEdit = async () => { + if (!editingPost) return; + setError(""); + try { + await api.updatePost(editingPost.id, editForm); + setEditingPost(null); + await loadData(); + } catch (err: any) { + setError(err.message); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("Delete this post?")) return; + try { + await api.deletePost(id); + await loadData(); + } catch (err: any) { + setError(err.message); + } + }; + + const toggleCategory = (catId: string) => { + setEditForm((prev) => ({ + ...prev, + categories: prev.categories.includes(catId) + ? prev.categories.filter((c) => c !== catId) + : [...prev.categories, catId], + })); + }; + + if (loading) { + return ( +
+
Loading posts...
+
+ ); + } + + return ( +
+
+

Blog Management

+ +
+ + {error &&

{error}

} + + {importOpen && ( +
+
+

Import from Nostr

+ +
+
+ setImportInput(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" + /> + +
+ {importPreview && ( +
+

{importPreview.title || "Untitled"}

+

+ {importPreview.content?.slice(0, 300)}... +

+ +
+ )} +
+ )} + + {editingPost && ( +
+
+

Edit Post Metadata

+ +
+
+ setEditForm({ ...editForm, title: 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" + /> + setEditForm({ ...editForm, slug: 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" + /> +