From f07eddf29ef413b3f1b06b360436b42a3e3597f9 Mon Sep 17 00:00:00 2001 From: lashman Date: Thu, 19 Mar 2026 18:05:16 +0200 Subject: [PATCH] initial project setup Fastify + Prisma backend, React + Vite frontend, Docker deployment. Multi-board feedback platform with anonymous cookie auth, passkey upgrade path, ALTCHA spam protection, plugin system, and full privacy-first architecture. --- .env.example | 27 ++ Dockerfile | 36 +++ README.md | 57 ++++ docker-compose.yml | 41 +++ echoboard.plugins.ts | 8 + package.json | 27 ++ packages/api/package.json | 42 +++ packages/api/prisma/schema.prisma | 213 ++++++++++++++ packages/api/src/cli/create-admin.ts | 86 ++++++ packages/api/src/config.ts | 41 +++ packages/api/src/cron/index.ts | 60 ++++ packages/api/src/index.ts | 19 ++ packages/api/src/lib/budget.ts | 74 +++++ packages/api/src/middleware/auth.ts | 102 +++++++ packages/api/src/middleware/security.ts | 28 ++ packages/api/src/plugins/loader.ts | 32 +++ packages/api/src/plugins/types.ts | 17 ++ packages/api/src/routes/activity.ts | 49 ++++ packages/api/src/routes/admin/auth.ts | 42 +++ packages/api/src/routes/admin/boards.ts | 112 ++++++++ packages/api/src/routes/admin/categories.ts | 46 ++++ packages/api/src/routes/admin/posts.ts | 173 ++++++++++++ packages/api/src/routes/admin/stats.ts | 86 ++++++ packages/api/src/routes/boards.ts | 64 +++++ packages/api/src/routes/comments.ts | 150 ++++++++++ packages/api/src/routes/feed.ts | 69 +++++ packages/api/src/routes/identity.ts | 139 ++++++++++ packages/api/src/routes/passkey.ts | 247 +++++++++++++++++ packages/api/src/routes/posts.ts | 216 +++++++++++++++ packages/api/src/routes/privacy.ts | 50 ++++ packages/api/src/routes/push.ts | 91 ++++++ packages/api/src/routes/reactions.ts | 70 +++++ packages/api/src/routes/votes.ts | 138 ++++++++++ packages/api/src/server.ts | 93 +++++++ packages/api/src/services/altcha.ts | 21 ++ packages/api/src/services/encryption.ts | 30 ++ packages/api/src/services/push.ts | 71 +++++ packages/api/tsconfig.json | 8 + packages/web/index.html | 12 + packages/web/package.json | 28 ++ packages/web/src/App.tsx | 77 ++++++ packages/web/src/app.css | 161 +++++++++++ .../web/src/components/CommandPalette.tsx | 227 +++++++++++++++ packages/web/src/components/EmptyState.tsx | 75 +++++ .../web/src/components/IdentityBanner.tsx | 74 +++++ packages/web/src/components/MobileNav.tsx | 97 +++++++ packages/web/src/components/PasskeyModal.tsx | 186 +++++++++++++ packages/web/src/components/PostCard.tsx | 110 ++++++++ packages/web/src/components/PostForm.tsx | 183 ++++++++++++ packages/web/src/components/Sidebar.tsx | 246 +++++++++++++++++ packages/web/src/components/StatusBadge.tsx | 21 ++ packages/web/src/components/ThemeToggle.tsx | 37 +++ packages/web/src/components/Timeline.tsx | 165 +++++++++++ packages/web/src/components/VoteBudget.tsx | 59 ++++ packages/web/src/hooks/useAuth.ts | 82 ++++++ packages/web/src/hooks/useTheme.ts | 47 ++++ packages/web/src/lib/api.ts | 62 +++++ packages/web/src/lib/theme.ts | 43 +++ packages/web/src/main.tsx | 16 ++ packages/web/src/pages/ActivityFeed.tsx | 154 +++++++++++ packages/web/src/pages/BoardFeed.tsx | 194 +++++++++++++ packages/web/src/pages/BoardIndex.tsx | 142 ++++++++++ packages/web/src/pages/IdentitySettings.tsx | 205 ++++++++++++++ packages/web/src/pages/MySubmissions.tsx | 94 +++++++ packages/web/src/pages/PostDetail.tsx | 241 ++++++++++++++++ packages/web/src/pages/PrivacyPage.tsx | 163 +++++++++++ packages/web/src/pages/admin/AdminBoards.tsx | 260 ++++++++++++++++++ .../web/src/pages/admin/AdminDashboard.tsx | 135 +++++++++ packages/web/src/pages/admin/AdminLogin.tsx | 86 ++++++ packages/web/src/pages/admin/AdminPosts.tsx | 259 +++++++++++++++++ packages/web/src/vite-env.d.ts | 1 + packages/web/tsconfig.json | 16 ++ packages/web/vite.config.ts | 15 + plugins/gitea/package.json | 17 ++ plugins/gitea/src/index.ts | 143 ++++++++++ plugins/gitea/tsconfig.json | 8 + tsconfig.json | 15 + 77 files changed, 7031 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 echoboard.plugins.ts create mode 100644 package.json create mode 100644 packages/api/package.json create mode 100644 packages/api/prisma/schema.prisma create mode 100644 packages/api/src/cli/create-admin.ts create mode 100644 packages/api/src/config.ts create mode 100644 packages/api/src/cron/index.ts create mode 100644 packages/api/src/index.ts create mode 100644 packages/api/src/lib/budget.ts create mode 100644 packages/api/src/middleware/auth.ts create mode 100644 packages/api/src/middleware/security.ts create mode 100644 packages/api/src/plugins/loader.ts create mode 100644 packages/api/src/plugins/types.ts create mode 100644 packages/api/src/routes/activity.ts create mode 100644 packages/api/src/routes/admin/auth.ts create mode 100644 packages/api/src/routes/admin/boards.ts create mode 100644 packages/api/src/routes/admin/categories.ts create mode 100644 packages/api/src/routes/admin/posts.ts create mode 100644 packages/api/src/routes/admin/stats.ts create mode 100644 packages/api/src/routes/boards.ts create mode 100644 packages/api/src/routes/comments.ts create mode 100644 packages/api/src/routes/feed.ts create mode 100644 packages/api/src/routes/identity.ts create mode 100644 packages/api/src/routes/passkey.ts create mode 100644 packages/api/src/routes/posts.ts create mode 100644 packages/api/src/routes/privacy.ts create mode 100644 packages/api/src/routes/push.ts create mode 100644 packages/api/src/routes/reactions.ts create mode 100644 packages/api/src/routes/votes.ts create mode 100644 packages/api/src/server.ts create mode 100644 packages/api/src/services/altcha.ts create mode 100644 packages/api/src/services/encryption.ts create mode 100644 packages/api/src/services/push.ts create mode 100644 packages/api/tsconfig.json create mode 100644 packages/web/index.html create mode 100644 packages/web/package.json create mode 100644 packages/web/src/App.tsx create mode 100644 packages/web/src/app.css create mode 100644 packages/web/src/components/CommandPalette.tsx create mode 100644 packages/web/src/components/EmptyState.tsx create mode 100644 packages/web/src/components/IdentityBanner.tsx create mode 100644 packages/web/src/components/MobileNav.tsx create mode 100644 packages/web/src/components/PasskeyModal.tsx create mode 100644 packages/web/src/components/PostCard.tsx create mode 100644 packages/web/src/components/PostForm.tsx create mode 100644 packages/web/src/components/Sidebar.tsx create mode 100644 packages/web/src/components/StatusBadge.tsx create mode 100644 packages/web/src/components/ThemeToggle.tsx create mode 100644 packages/web/src/components/Timeline.tsx create mode 100644 packages/web/src/components/VoteBudget.tsx create mode 100644 packages/web/src/hooks/useAuth.ts create mode 100644 packages/web/src/hooks/useTheme.ts create mode 100644 packages/web/src/lib/api.ts create mode 100644 packages/web/src/lib/theme.ts create mode 100644 packages/web/src/main.tsx create mode 100644 packages/web/src/pages/ActivityFeed.tsx create mode 100644 packages/web/src/pages/BoardFeed.tsx create mode 100644 packages/web/src/pages/BoardIndex.tsx create mode 100644 packages/web/src/pages/IdentitySettings.tsx create mode 100644 packages/web/src/pages/MySubmissions.tsx create mode 100644 packages/web/src/pages/PostDetail.tsx create mode 100644 packages/web/src/pages/PrivacyPage.tsx create mode 100644 packages/web/src/pages/admin/AdminBoards.tsx create mode 100644 packages/web/src/pages/admin/AdminDashboard.tsx create mode 100644 packages/web/src/pages/admin/AdminLogin.tsx create mode 100644 packages/web/src/pages/admin/AdminPosts.tsx create mode 100644 packages/web/src/vite-env.d.ts create mode 100644 packages/web/tsconfig.json create mode 100644 packages/web/vite.config.ts create mode 100644 plugins/gitea/package.json create mode 100644 plugins/gitea/src/index.ts create mode 100644 plugins/gitea/tsconfig.json create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..292a813 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Database +DB_PASSWORD=change-me-to-a-random-string +DATABASE_URL=postgresql://echoboard:change-me-to-a-random-string@db:5432/echoboard + +# Encryption (generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") +APP_MASTER_KEY= +APP_BLIND_INDEX_KEY= + +# Auth secrets (generate each the same way as above) +TOKEN_SECRET= +JWT_SECRET= + +# ALTCHA spam protection +ALTCHA_HMAC_KEY= + +# WebAuthn / Passkey +WEBAUTHN_RP_NAME=Echoboard +WEBAUTHN_RP_ID=localhost +WEBAUTHN_ORIGIN=http://localhost:3000 + +# Web Push (generate with: npx web-push generate-vapid-keys) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_CONTACT=mailto:admin@example.com + +# Server +PORT=3000 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a87ddf4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +COPY packages/api/package.json packages/api/ +COPY packages/web/package.json packages/web/ + +RUN npm install + +COPY tsconfig.json ./ +COPY packages/api/ packages/api/ +COPY packages/web/ packages/web/ +COPY echoboard.plugins.ts ./ + +RUN npx prisma generate --schema=packages/api/prisma/schema.prisma +RUN npm run build:web +RUN npm run build:api + +FROM node:20-alpine + +WORKDIR /app + +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/packages/api/package.json packages/api/ +COPY --from=builder /app/packages/api/dist packages/api/dist/ +COPY --from=builder /app/packages/api/prisma packages/api/prisma/ +COPY --from=builder /app/packages/api/node_modules packages/api/node_modules/ +COPY --from=builder /app/packages/web/dist packages/web/dist/ +COPY --from=builder /app/node_modules node_modules/ +COPY --from=builder /app/echoboard.plugins.ts ./ + +ENV NODE_ENV=production +EXPOSE 3000 + +CMD ["sh", "-c", "npx prisma migrate deploy --schema=packages/api/prisma/schema.prisma && node packages/api/dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3343b0 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Echoboard + +A self-hosted feedback board where users submit feature requests and bug reports without creating an account. Anonymous by default, with optional passkey registration for persistence across devices. + +## Quick start + +```bash +git clone +cd echoboard +cp .env.example .env + +# Generate secrets +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# Paste output into APP_MASTER_KEY, APP_BLIND_INDEX_KEY, TOKEN_SECRET, JWT_SECRET, ALTCHA_HMAC_KEY + +# Generate VAPID keys for push notifications +npx web-push generate-vapid-keys +# Paste into VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY + +# Set WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN to your domain + +docker compose up -d +docker compose exec app npx echoboard create-admin --email you@example.com +``` + +## Development + +```bash +npm install +cp .env.example .env +# Fill in .env with dev values (WEBAUTHN_RP_ID=localhost, WEBAUTHN_ORIGIN=http://localhost:3000) + +npm run dev +``` + +This starts the API on port 3000 and the Vite dev server on port 5173 (with API proxy). + +## Architecture + +- **packages/api** - Fastify + Prisma backend +- **packages/web** - React + Vite frontend +- **plugins/** - Optional integrations (Gitea, GitHub, etc.) + +## Identity model + +Two tiers of user identity: + +1. **Anonymous cookie** (default) - zero friction, browser-generated token, single device only +2. **Passkey** (optional upgrade) - username + WebAuthn biometric, works across devices, no email needed + +## Plugin system + +Plugins live in `plugins/` and are registered in `echoboard.plugins.ts`. Each plugin is self-contained with its own routes, database tables, and UI components. Removing a plugin leaves zero trace in the core app. + +## License + +CC0-1.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..842af6e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3.8" + +services: + app: + build: . + ports: + - "${PORT:-3000}:3000" + environment: + DATABASE_URL: postgresql://echoboard:${DB_PASSWORD}@db:5432/echoboard + APP_MASTER_KEY: ${APP_MASTER_KEY} + APP_BLIND_INDEX_KEY: ${APP_BLIND_INDEX_KEY} + TOKEN_SECRET: ${TOKEN_SECRET} + JWT_SECRET: ${JWT_SECRET} + ALTCHA_HMAC_KEY: ${ALTCHA_HMAC_KEY} + WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Echoboard} + WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID} + WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} + VAPID_CONTACT: ${VAPID_CONTACT} + NODE_ENV: production + depends_on: + db: + condition: service_healthy + + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: echoboard + POSTGRES_USER: echoboard + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U echoboard"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + pgdata: diff --git a/echoboard.plugins.ts b/echoboard.plugins.ts new file mode 100644 index 0000000..04d72fa --- /dev/null +++ b/echoboard.plugins.ts @@ -0,0 +1,8 @@ +// Active plugins - import and add to the array to enable +// Remove from array and delete the plugin directory to disable + +// import { giteaPlugin } from './plugins/gitea/src/index.js'; + +export const plugins: unknown[] = [ + // giteaPlugin, +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..e6ebfd1 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "echoboard", + "version": "0.1.0", + "private": true, + "license": "CC0-1.0", + "workspaces": [ + "packages/api", + "packages/web", + "plugins/*" + ], + "scripts": { + "dev": "concurrently \"npm run dev:api\" \"npm run dev:web\"", + "dev:api": "npm run dev -w packages/api", + "dev:web": "npm run dev -w packages/web", + "build": "npm run build -w packages/api && npm run build -w packages/web", + "build:api": "npm run build -w packages/api", + "build:web": "npm run build -w packages/web", + "start": "npm run start -w packages/api", + "db:migrate": "npm run db:migrate -w packages/api", + "db:generate": "npm run db:generate -w packages/api", + "create-admin": "npm run create-admin -w packages/api" + }, + "devDependencies": { + "concurrently": "^9.1.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..37d8fe8 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,42 @@ +{ + "name": "@echoboard/api", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:migrate": "prisma migrate dev", + "db:generate": "prisma generate", + "db:deploy": "prisma migrate deploy", + "create-admin": "tsx src/cli/create-admin.ts" + }, + "dependencies": { + "@fastify/cookie": "^11.0.0", + "@fastify/cors": "^10.0.0", + "@fastify/rate-limit": "^10.0.0", + "@fastify/static": "^8.0.0", + "@prisma/client": "^6.0.0", + "@simplewebauthn/server": "^11.0.0", + "altcha-lib": "^0.5.0", + "bcrypt": "^5.1.0", + "fastify": "^5.0.0", + "fastify-plugin": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "node-cron": "^3.0.0", + "rss": "^1.2.0", + "web-push": "^3.6.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.0", + "@types/jsonwebtoken": "^9.0.0", + "@types/node": "^22.0.0", + "@types/web-push": "^3.6.0", + "prisma": "^6.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma new file mode 100644 index 0000000..c6da8ea --- /dev/null +++ b/packages/api/prisma/schema.prisma @@ -0,0 +1,213 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum AuthMethod { + COOKIE + PASSKEY +} + +enum PostType { + FEATURE_REQUEST + BUG_REPORT +} + +enum PostStatus { + OPEN + UNDER_REVIEW + PLANNED + IN_PROGRESS + DONE + DECLINED +} + +model Board { + id String @id @default(cuid()) + slug String @unique + name String + description String? + externalUrl String? + isArchived Boolean @default(false) + voteBudget Int @default(10) + voteBudgetReset String @default("monthly") + lastBudgetReset DateTime? + allowMultiVote Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + posts Post[] + activityEvents ActivityEvent[] + pushSubscriptions PushSubscription[] +} + +model User { + id String @id @default(cuid()) + authMethod AuthMethod @default(COOKIE) + tokenHash String? @unique + username String? + usernameIdx String? @unique + displayName String? + darkMode String @default("system") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + passkeys Passkey[] + posts Post[] + comments Comment[] + reactions Reaction[] + votes Vote[] + pushSubscriptions PushSubscription[] +} + +model Passkey { + id String @id @default(cuid()) + credentialId String + credentialIdIdx String @unique + credentialPublicKey Bytes + counter BigInt + credentialDeviceType String + credentialBackedUp Boolean + transports String? + userId String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model Post { + id String @id @default(cuid()) + type PostType + title String + description Json + status PostStatus @default(OPEN) + category String? + voteCount Int @default(0) + isPinned Boolean @default(false) + boardId String + authorId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + statusChanges StatusChange[] + comments Comment[] + votes Vote[] + adminResponses AdminResponse[] + activityEvents ActivityEvent[] + pushSubscriptions PushSubscription[] +} + +model StatusChange { + id String @id @default(cuid()) + postId String + fromStatus PostStatus + toStatus PostStatus + changedBy String + createdAt DateTime @default(now()) + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) +} + +model Comment { + id String @id @default(cuid()) + body String + postId String + authorId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + reactions Reaction[] +} + +model Reaction { + id String @id @default(cuid()) + emoji String + commentId String + userId String + createdAt DateTime @default(now()) + + comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([commentId, userId, emoji]) +} + +model Vote { + id String @id @default(cuid()) + weight Int @default(1) + postId String + voterId String + budgetPeriod String + createdAt DateTime @default(now()) + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + voter User @relation(fields: [voterId], references: [id], onDelete: Cascade) + + @@unique([postId, voterId]) +} + +model AdminUser { + id String @id @default(cuid()) + email String @unique + passwordHash String + createdAt DateTime @default(now()) + + responses AdminResponse[] +} + +model AdminResponse { + id String @id @default(cuid()) + body String + postId String + adminId String + createdAt DateTime @default(now()) + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + admin AdminUser @relation(fields: [adminId], references: [id], onDelete: Cascade) +} + +model ActivityEvent { + id String @id @default(cuid()) + type String + boardId String + postId String? + metadata Json + createdAt DateTime @default(now()) + + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + post Post? @relation(fields: [postId], references: [id], onDelete: SetNull) + + @@index([boardId, createdAt]) + @@index([createdAt]) +} + +model PushSubscription { + id String @id @default(cuid()) + endpoint String + endpointIdx String @unique + keysP256dh String + keysAuth String + userId String + boardId String? + postId String? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade) + post Post? @relation(fields: [postId], references: [id], onDelete: SetNull) +} + +model Category { + id String @id @default(cuid()) + name String @unique + slug String @unique + createdAt DateTime @default(now()) +} diff --git a/packages/api/src/cli/create-admin.ts b/packages/api/src/cli/create-admin.ts new file mode 100644 index 0000000..703daf9 --- /dev/null +++ b/packages/api/src/cli/create-admin.ts @@ -0,0 +1,86 @@ +import { PrismaClient } from "@prisma/client"; +import bcrypt from "bcrypt"; +import { createInterface } from "node:readline"; + +const prisma = new PrismaClient(); + +function getArg(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + if (idx === -1 || idx + 1 >= process.argv.length) return undefined; + return process.argv[idx + 1]; +} + +function readLine(prompt: string, hidden = false): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + if (hidden) { + process.stdout.write(prompt); + let input = ""; + process.stdin.setRawMode?.(true); + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + const handler = (ch: string) => { + if (ch === "\n" || ch === "\r" || ch === "\u0004") { + process.stdin.setRawMode?.(false); + process.stdin.removeListener("data", handler); + process.stdout.write("\n"); + rl.close(); + resolve(input); + } else if (ch === "\u007F" || ch === "\b") { + if (input.length > 0) { + input = input.slice(0, -1); + process.stdout.write("\b \b"); + } + } else { + input += ch; + process.stdout.write("*"); + } + }; + process.stdin.on("data", handler); + } else { + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer); + }); + } + }); +} + +async function main() { + const email = getArg("email") ?? await readLine("Email: "); + if (!email) { + console.error("Email is required"); + process.exit(1); + } + + const existing = await prisma.adminUser.findUnique({ where: { email } }); + if (existing) { + console.error("Admin with this email already exists"); + process.exit(1); + } + + const password = await readLine("Password: ", true); + if (!password || password.length < 8) { + console.error("Password must be at least 8 characters"); + process.exit(1); + } + + const confirm = await readLine("Confirm password: ", true); + if (password !== confirm) { + console.error("Passwords do not match"); + process.exit(1); + } + + const hash = await bcrypt.hash(password, 12); + const admin = await prisma.adminUser.create({ + data: { email, passwordHash: hash }, + }); + + console.log(`Admin created: ${admin.email} (${admin.id})`); + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts new file mode 100644 index 0000000..3ce5fa2 --- /dev/null +++ b/packages/api/src/config.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +const schema = z.object({ + DATABASE_URL: z.string(), + APP_MASTER_KEY: z.string().regex(/^[0-9a-fA-F]{64}$/, "Must be hex-encoded 256-bit key"), + APP_BLIND_INDEX_KEY: z.string().regex(/^[0-9a-fA-F]+$/, "Must be hex-encoded"), + TOKEN_SECRET: z.string(), + JWT_SECRET: z.string(), + ALTCHA_HMAC_KEY: z.string(), + + WEBAUTHN_RP_NAME: z.string().default("Echoboard"), + WEBAUTHN_RP_ID: z.string(), + WEBAUTHN_ORIGIN: z.string().url(), + + PORT: z.coerce.number().default(3000), + ALTCHA_MAX_NUMBER: z.coerce.number().default(500000), + ALTCHA_MAX_NUMBER_VOTE: z.coerce.number().default(50000), + ALTCHA_EXPIRE_SECONDS: z.coerce.number().default(300), + + VAPID_PUBLIC_KEY: z.string().optional(), + VAPID_PRIVATE_KEY: z.string().optional(), + VAPID_CONTACT: z.string().optional(), + + DATA_RETENTION_ACTIVITY_DAYS: z.coerce.number().default(90), + DATA_RETENTION_ORPHAN_USER_DAYS: z.coerce.number().default(180), +}); + +const parsed = schema.safeParse(process.env); + +if (!parsed.success) { + console.error("Invalid environment variables:"); + for (const issue of parsed.error.issues) { + console.error(` ${issue.path.join(".")}: ${issue.message}`); + } + process.exit(1); +} + +export const config = parsed.data; + +export const masterKey = Buffer.from(config.APP_MASTER_KEY, "hex"); +export const blindIndexKey = Buffer.from(config.APP_BLIND_INDEX_KEY, "hex"); diff --git a/packages/api/src/cron/index.ts b/packages/api/src/cron/index.ts new file mode 100644 index 0000000..06ba751 --- /dev/null +++ b/packages/api/src/cron/index.ts @@ -0,0 +1,60 @@ +import cron from "node-cron"; +import { PrismaClient } from "@prisma/client"; +import { config } from "../config.js"; +import { cleanExpiredChallenges } from "../routes/passkey.js"; + +const prisma = new PrismaClient(); + +export function startCronJobs() { + // prune old activity events - daily at 3am + cron.schedule("0 3 * * *", async () => { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS); + + const result = await prisma.activityEvent.deleteMany({ + where: { createdAt: { lt: cutoff } }, + }); + if (result.count > 0) { + console.log(`Pruned ${result.count} old activity events`); + } + }); + + // prune orphaned anonymous users - daily at 4am + cron.schedule("0 4 * * *", async () => { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - config.DATA_RETENTION_ORPHAN_USER_DAYS); + + const result = await prisma.user.deleteMany({ + where: { + authMethod: "COOKIE", + createdAt: { lt: cutoff }, + posts: { none: {} }, + comments: { none: {} }, + votes: { none: {} }, + }, + }); + if (result.count > 0) { + console.log(`Pruned ${result.count} orphaned users`); + } + }); + + // clean webauthn challenges - every 10 minutes + cron.schedule("*/10 * * * *", () => { + cleanExpiredChallenges(); + }); + + // remove failed push subscriptions - daily at 5am + cron.schedule("0 5 * * *", async () => { + // subscriptions with no associated user get cleaned by cascade + // this handles any other stale ones + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 30); + + const result = await prisma.pushSubscription.deleteMany({ + where: { createdAt: { lt: cutoff } }, + }); + if (result.count > 0) { + console.log(`Cleaned ${result.count} old push subscriptions`); + } + }); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..35c7ffc --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,19 @@ +import { createServer } from "./server.js"; +import { config } from "./config.js"; +import { startCronJobs } from "./cron/index.js"; + +async function main() { + const app = await createServer(); + + startCronJobs(); + + try { + await app.listen({ port: config.PORT, host: "0.0.0.0" }); + console.log(`Echoboard API running on port ${config.PORT}`); + } catch (err) { + app.log.error(err); + process.exit(1); + } +} + +main(); diff --git a/packages/api/src/lib/budget.ts b/packages/api/src/lib/budget.ts new file mode 100644 index 0000000..8c4b974 --- /dev/null +++ b/packages/api/src/lib/budget.ts @@ -0,0 +1,74 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export function getCurrentPeriod(resetSchedule: string): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + + switch (resetSchedule) { + case "weekly": { + const startOfYear = new Date(year, 0, 1); + const days = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000); + const week = Math.ceil((days + startOfYear.getDay() + 1) / 7); + return `${year}-W${String(week).padStart(2, "0")}`; + } + case "quarterly": { + const q = Math.ceil((now.getMonth() + 1) / 3); + return `${year}-Q${q}`; + } + case "yearly": + return `${year}`; + case "never": + return "lifetime"; + case "monthly": + default: + return `${year}-${month}`; + } +} + +export async function getRemainingBudget(userId: string, boardId: string): Promise { + const board = await prisma.board.findUnique({ where: { id: boardId } }); + if (!board) return 0; + + if (board.voteBudgetReset === "never" && board.voteBudget === 0) { + return Infinity; + } + + const period = getCurrentPeriod(board.voteBudgetReset); + + const used = await prisma.vote.aggregate({ + where: { voterId: userId, post: { boardId }, budgetPeriod: period }, + _sum: { weight: true }, + }); + + const spent = used._sum.weight ?? 0; + return Math.max(0, board.voteBudget - spent); +} + +export function getNextResetDate(resetSchedule: string): Date { + const now = new Date(); + + switch (resetSchedule) { + case "weekly": { + const d = new Date(now); + d.setDate(d.getDate() + (7 - d.getDay())); + d.setHours(0, 0, 0, 0); + return d; + } + case "quarterly": { + const q = Math.ceil((now.getMonth() + 1) / 3); + return new Date(now.getFullYear(), q * 3, 1); + } + case "yearly": + return new Date(now.getFullYear() + 1, 0, 1); + case "never": + return new Date(8640000000000000); // max date + case "monthly": + default: { + const d = new Date(now.getFullYear(), now.getMonth() + 1, 1); + return d; + } + } +} diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts new file mode 100644 index 0000000..90d0b50 --- /dev/null +++ b/packages/api/src/middleware/auth.ts @@ -0,0 +1,102 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import fp from "fastify-plugin"; +import jwt from "jsonwebtoken"; +import { PrismaClient, User } from "@prisma/client"; +import { hashToken } from "../services/encryption.js"; +import { config } from "../config.js"; + +declare module "fastify" { + interface FastifyRequest { + user?: User; + adminId?: string; + } +} + +const prisma = new PrismaClient(); + +async function authPlugin(app: FastifyInstance) { + app.decorateRequest("user", undefined); + app.decorateRequest("adminId", undefined); + + app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => { + // try cookie auth first + const token = req.cookies?.echoboard_token; + if (token) { + const hash = hashToken(token); + const user = await prisma.user.findUnique({ where: { tokenHash: hash } }); + if (user) { + req.user = user; + return; + } + } + + // try bearer token (passkey sessions) + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + try { + const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string }; + if (decoded.type === "passkey") { + const user = await prisma.user.findUnique({ where: { id: decoded.sub } }); + if (user) { + req.user = user; + return; + } + } + } catch { + // invalid token + } + } + + reply.status(401).send({ error: "Not authenticated" }); + }); + + app.decorate("optionalUser", async (req: FastifyRequest) => { + const token = req.cookies?.echoboard_token; + if (token) { + const hash = hashToken(token); + const user = await prisma.user.findUnique({ where: { tokenHash: hash } }); + if (user) req.user = user; + return; + } + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + try { + const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string }; + if (decoded.type === "passkey") { + const user = await prisma.user.findUnique({ where: { id: decoded.sub } }); + if (user) req.user = user; + } + } catch { + // invalid + } + } + }); + + app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + reply.status(401).send({ error: "Admin token required" }); + return; + } + try { + const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string }; + if (decoded.type !== "admin") { + reply.status(403).send({ error: "Admin access required" }); + return; + } + req.adminId = decoded.sub; + } catch { + reply.status(401).send({ error: "Invalid admin token" }); + } + }); +} + +declare module "fastify" { + interface FastifyInstance { + requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise; + optionalUser: (req: FastifyRequest) => Promise; + requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise; + } +} + +export default fp(authPlugin, { name: "auth" }); diff --git a/packages/api/src/middleware/security.ts b/packages/api/src/middleware/security.ts new file mode 100644 index 0000000..1694e54 --- /dev/null +++ b/packages/api/src/middleware/security.ts @@ -0,0 +1,28 @@ +import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; + +async function securityPlugin(app: FastifyInstance) { + app.addHook("onSend", async (_req, reply) => { + reply.header("Content-Security-Policy", [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; ")); + reply.header("Referrer-Policy", "no-referrer"); + reply.header("X-Content-Type-Options", "nosniff"); + reply.header("X-Frame-Options", "DENY"); + reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + reply.header("X-DNS-Prefetch-Control", "off"); + reply.header("Cross-Origin-Opener-Policy", "same-origin"); + reply.header("Cross-Origin-Resource-Policy", "same-origin"); + }); +} + +export default fp(securityPlugin, { name: "security" }); diff --git a/packages/api/src/plugins/loader.ts b/packages/api/src/plugins/loader.ts new file mode 100644 index 0000000..ab4f8df --- /dev/null +++ b/packages/api/src/plugins/loader.ts @@ -0,0 +1,32 @@ +import { FastifyInstance } from "fastify"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { PluginManifest, EchoboardPlugin } from "./types.js"; + +export async function loadPlugins(app: FastifyInstance) { + const manifestPath = resolve(process.cwd(), "echoboard.plugins.json"); + let manifest: PluginManifest; + + try { + const raw = await readFile(manifestPath, "utf-8"); + manifest = JSON.parse(raw); + } catch { + app.log.info("No plugin manifest found, skipping plugin loading"); + return; + } + + if (!manifest.plugins || !Array.isArray(manifest.plugins)) return; + + for (const entry of manifest.plugins) { + if (!entry.enabled) continue; + + try { + const mod = await import(entry.name) as { default: EchoboardPlugin }; + const plugin = mod.default; + app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`); + await plugin.register(app, entry.config ?? {}); + } catch (err) { + app.log.error(`Failed to load plugin ${entry.name}: ${err}`); + } + } +} diff --git a/packages/api/src/plugins/types.ts b/packages/api/src/plugins/types.ts new file mode 100644 index 0000000..383f13d --- /dev/null +++ b/packages/api/src/plugins/types.ts @@ -0,0 +1,17 @@ +import { FastifyInstance } from "fastify"; + +export interface EchoboardPlugin { + name: string; + version: string; + register: (app: FastifyInstance, config: Record) => Promise; +} + +export interface PluginConfig { + name: string; + enabled: boolean; + config?: Record; +} + +export interface PluginManifest { + plugins: PluginConfig[]; +} diff --git a/packages/api/src/routes/activity.ts b/packages/api/src/routes/activity.ts new file mode 100644 index 0000000..82d302c --- /dev/null +++ b/packages/api/src/routes/activity.ts @@ -0,0 +1,49 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient, Prisma } from "@prisma/client"; +import { z } from "zod"; + +const prisma = new PrismaClient(); + +const querySchema = z.object({ + board: z.string().optional(), + type: z.string().optional(), + page: z.coerce.number().min(1).default(1), + limit: z.coerce.number().min(1).max(100).default(30), +}); + +export default async function activityRoutes(app: FastifyInstance) { + app.get<{ Querystring: Record }>( + "/activity", + async (req, reply) => { + const q = querySchema.parse(req.query); + + const where: Prisma.ActivityEventWhereInput = {}; + if (q.board) { + const board = await prisma.board.findUnique({ where: { slug: q.board } }); + if (board) where.boardId = board.id; + } + if (q.type) where.type = q.type; + + const [events, total] = await Promise.all([ + prisma.activityEvent.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (q.page - 1) * q.limit, + take: q.limit, + include: { + board: { select: { slug: true, name: true } }, + post: { select: { id: true, title: true } }, + }, + }), + prisma.activityEvent.count({ where }), + ]); + + reply.send({ + events, + total, + page: q.page, + pages: Math.ceil(total / q.limit), + }); + } + ); +} diff --git a/packages/api/src/routes/admin/auth.ts b/packages/api/src/routes/admin/auth.ts new file mode 100644 index 0000000..f9a2b2c --- /dev/null +++ b/packages/api/src/routes/admin/auth.ts @@ -0,0 +1,42 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; +import { z } from "zod"; +import { config } from "../../config.js"; + +const prisma = new PrismaClient(); + +const loginBody = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export default async function adminAuthRoutes(app: FastifyInstance) { + app.post<{ Body: z.infer }>( + "/admin/login", + async (req, reply) => { + const body = loginBody.parse(req.body); + + const admin = await prisma.adminUser.findUnique({ where: { email: body.email } }); + if (!admin) { + reply.status(401).send({ error: "Invalid credentials" }); + return; + } + + const valid = await bcrypt.compare(body.password, admin.passwordHash); + if (!valid) { + reply.status(401).send({ error: "Invalid credentials" }); + return; + } + + const token = jwt.sign( + { sub: admin.id, type: "admin" }, + config.JWT_SECRET, + { expiresIn: "24h" } + ); + + reply.send({ token }); + } + ); +} diff --git a/packages/api/src/routes/admin/boards.ts b/packages/api/src/routes/admin/boards.ts new file mode 100644 index 0000000..4020d5d --- /dev/null +++ b/packages/api/src/routes/admin/boards.ts @@ -0,0 +1,112 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { z } from "zod"; + +const prisma = new PrismaClient(); + +const createBoardBody = z.object({ + slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/), + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), + externalUrl: z.string().url().optional(), + voteBudget: z.number().int().min(0).default(10), + voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).default("monthly"), + allowMultiVote: z.boolean().default(false), +}); + +const updateBoardBody = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().max(500).optional().nullable(), + externalUrl: z.string().url().optional().nullable(), + isArchived: z.boolean().optional(), + voteBudget: z.number().int().min(0).optional(), + voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).optional(), + allowMultiVote: z.boolean().optional(), +}); + +export default async function adminBoardRoutes(app: FastifyInstance) { + app.get( + "/admin/boards", + { preHandler: [app.requireAdmin] }, + async (_req, reply) => { + const boards = await prisma.board.findMany({ + orderBy: { createdAt: "asc" }, + include: { + _count: { select: { posts: true } }, + }, + }); + reply.send(boards); + } + ); + + app.post<{ Body: z.infer }>( + "/admin/boards", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const body = createBoardBody.parse(req.body); + + const existing = await prisma.board.findUnique({ where: { slug: body.slug } }); + if (existing) { + reply.status(409).send({ error: "Slug already taken" }); + return; + } + + const board = await prisma.board.create({ data: body }); + reply.status(201).send(board); + } + ); + + app.put<{ Params: { id: string }; Body: z.infer }>( + "/admin/boards/:id", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { id: req.params.id } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const body = updateBoardBody.parse(req.body); + const updated = await prisma.board.update({ + where: { id: board.id }, + data: body, + }); + + reply.send(updated); + } + ); + + app.post<{ Params: { id: string } }>( + "/admin/boards/:id/reset-budget", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { id: req.params.id } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const updated = await prisma.board.update({ + where: { id: board.id }, + data: { lastBudgetReset: new Date() }, + }); + + reply.send(updated); + } + ); + + app.delete<{ Params: { id: string } }>( + "/admin/boards/:id", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { id: req.params.id } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + await prisma.board.delete({ where: { id: board.id } }); + reply.status(204).send(); + } + ); +} diff --git a/packages/api/src/routes/admin/categories.ts b/packages/api/src/routes/admin/categories.ts new file mode 100644 index 0000000..e5d713a --- /dev/null +++ b/packages/api/src/routes/admin/categories.ts @@ -0,0 +1,46 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { z } from "zod"; + +const prisma = new PrismaClient(); + +const createCategoryBody = z.object({ + name: z.string().min(1).max(50), + slug: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/), +}); + +export default async function adminCategoryRoutes(app: FastifyInstance) { + app.post<{ Body: z.infer }>( + "/admin/categories", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const body = createCategoryBody.parse(req.body); + + const existing = await prisma.category.findFirst({ + where: { OR: [{ name: body.name }, { slug: body.slug }] }, + }); + if (existing) { + reply.status(409).send({ error: "Category already exists" }); + return; + } + + const cat = await prisma.category.create({ data: body }); + reply.status(201).send(cat); + } + ); + + app.delete<{ Params: { id: string } }>( + "/admin/categories/:id", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const cat = await prisma.category.findUnique({ where: { id: req.params.id } }); + if (!cat) { + reply.status(404).send({ error: "Category not found" }); + return; + } + + await prisma.category.delete({ where: { id: cat.id } }); + reply.status(204).send(); + } + ); +} diff --git a/packages/api/src/routes/admin/posts.ts b/packages/api/src/routes/admin/posts.ts new file mode 100644 index 0000000..2c0b813 --- /dev/null +++ b/packages/api/src/routes/admin/posts.ts @@ -0,0 +1,173 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient, PostStatus, Prisma } from "@prisma/client"; +import { z } from "zod"; +import { notifyPostSubscribers } from "../../services/push.js"; + +const prisma = new PrismaClient(); + +const statusBody = z.object({ + status: z.nativeEnum(PostStatus), +}); + +const respondBody = z.object({ + body: z.string().min(1).max(5000), +}); + +export default async function adminPostRoutes(app: FastifyInstance) { + app.get<{ Querystring: Record }>( + "/admin/posts", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const page = Math.max(1, parseInt(req.query.page ?? "1", 10)); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit ?? "50", 10))); + const status = req.query.status as PostStatus | undefined; + const boardId = req.query.boardId; + + const where: Prisma.PostWhereInput = {}; + if (status) where.status = status; + if (boardId) where.boardId = boardId; + + const [posts, total] = await Promise.all([ + prisma.post.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * limit, + take: limit, + include: { + board: { select: { slug: true, name: true } }, + author: { select: { id: true, displayName: true } }, + _count: { select: { comments: true, votes: true } }, + }, + }), + prisma.post.count({ where }), + ]); + + reply.send({ posts, total, page, pages: Math.ceil(total / limit) }); + } + ); + + app.put<{ Params: { id: string }; Body: z.infer }>( + "/admin/posts/:id/status", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const { status } = statusBody.parse(req.body); + const oldStatus = post.status; + + const [updated] = await Promise.all([ + prisma.post.update({ where: { id: post.id }, data: { status } }), + prisma.statusChange.create({ + data: { + postId: post.id, + fromStatus: oldStatus, + toStatus: status, + changedBy: req.adminId!, + }, + }), + prisma.activityEvent.create({ + data: { + type: "status_changed", + boardId: post.boardId, + postId: post.id, + metadata: { from: oldStatus, to: status }, + }, + }), + ]); + + await notifyPostSubscribers(post.id, { + title: "Status updated", + body: `"${post.title}" moved to ${status}`, + url: `/post/${post.id}`, + tag: `status-${post.id}`, + }); + + reply.send(updated); + } + ); + + app.put<{ Params: { id: string } }>( + "/admin/posts/:id/pin", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const updated = await prisma.post.update({ + where: { id: post.id }, + data: { isPinned: !post.isPinned }, + }); + + reply.send(updated); + } + ); + + app.post<{ Params: { id: string }; Body: z.infer }>( + "/admin/posts/:id/respond", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const { body } = respondBody.parse(req.body); + + const response = await prisma.adminResponse.create({ + data: { + body, + postId: post.id, + adminId: req.adminId!, + }, + include: { admin: { select: { id: true, email: true } } }, + }); + + await notifyPostSubscribers(post.id, { + title: "Official response", + body: body.slice(0, 100), + url: `/post/${post.id}`, + tag: `response-${post.id}`, + }); + + reply.status(201).send(response); + } + ); + + app.delete<{ Params: { id: string } }>( + "/admin/posts/:id", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + await prisma.post.delete({ where: { id: post.id } }); + reply.status(204).send(); + } + ); + + app.delete<{ Params: { id: string } }>( + "/admin/comments/:id", + { preHandler: [app.requireAdmin] }, + async (req, reply) => { + const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); + if (!comment) { + reply.status(404).send({ error: "Comment not found" }); + return; + } + + await prisma.comment.delete({ where: { id: comment.id } }); + reply.status(204).send(); + } + ); +} diff --git a/packages/api/src/routes/admin/stats.ts b/packages/api/src/routes/admin/stats.ts new file mode 100644 index 0000000..b1ad6f4 --- /dev/null +++ b/packages/api/src/routes/admin/stats.ts @@ -0,0 +1,86 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { config } from "../../config.js"; + +const prisma = new PrismaClient(); + +export default async function adminStatsRoutes(app: FastifyInstance) { + app.get( + "/admin/stats", + { preHandler: [app.requireAdmin] }, + async (_req, reply) => { + const [ + totalPosts, + totalUsers, + totalComments, + totalVotes, + postsByStatus, + postsByType, + boardStats, + ] = await Promise.all([ + prisma.post.count(), + prisma.user.count(), + prisma.comment.count(), + prisma.vote.count(), + prisma.post.groupBy({ by: ["status"], _count: true }), + prisma.post.groupBy({ by: ["type"], _count: true }), + prisma.board.findMany({ + select: { + id: true, + slug: true, + name: true, + _count: { select: { posts: true } }, + }, + }), + ]); + + reply.send({ + totals: { + posts: totalPosts, + users: totalUsers, + comments: totalComments, + votes: totalVotes, + }, + postsByStatus: Object.fromEntries(postsByStatus.map((s) => [s.status, s._count])), + postsByType: Object.fromEntries(postsByType.map((t) => [t.type, t._count])), + boards: boardStats.map((b) => ({ + id: b.id, + slug: b.slug, + name: b.name, + postCount: b._count.posts, + })), + }); + } + ); + + app.get( + "/admin/data-retention", + { preHandler: [app.requireAdmin] }, + async (_req, reply) => { + const activityCutoff = new Date(); + activityCutoff.setDate(activityCutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS); + + const orphanCutoff = new Date(); + orphanCutoff.setDate(orphanCutoff.getDate() - config.DATA_RETENTION_ORPHAN_USER_DAYS); + + const [staleEvents, orphanUsers] = await Promise.all([ + prisma.activityEvent.count({ where: { createdAt: { lt: activityCutoff } } }), + prisma.user.count({ + where: { + createdAt: { lt: orphanCutoff }, + posts: { none: {} }, + comments: { none: {} }, + votes: { none: {} }, + }, + }), + ]); + + reply.send({ + activityRetentionDays: config.DATA_RETENTION_ACTIVITY_DAYS, + orphanRetentionDays: config.DATA_RETENTION_ORPHAN_USER_DAYS, + staleActivityEvents: staleEvents, + orphanedUsers: orphanUsers, + }); + } + ); +} diff --git a/packages/api/src/routes/boards.ts b/packages/api/src/routes/boards.ts new file mode 100644 index 0000000..9f39ed5 --- /dev/null +++ b/packages/api/src/routes/boards.ts @@ -0,0 +1,64 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export default async function boardRoutes(app: FastifyInstance) { + app.get("/boards", async (_req, reply) => { + const boards = await prisma.board.findMany({ + where: { isArchived: false }, + include: { + _count: { select: { posts: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + + const result = boards.map((b) => ({ + id: b.id, + slug: b.slug, + name: b.name, + description: b.description, + externalUrl: b.externalUrl, + voteBudget: b.voteBudget, + voteBudgetReset: b.voteBudgetReset, + allowMultiVote: b.allowMultiVote, + postCount: b._count.posts, + createdAt: b.createdAt, + })); + + reply.send(result); + }); + + app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", async (req, reply) => { + const board = await prisma.board.findUnique({ + where: { slug: req.params.boardSlug }, + include: { + _count: { + select: { + posts: true, + }, + }, + }, + }); + + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + reply.send({ + id: board.id, + slug: board.slug, + name: board.name, + description: board.description, + externalUrl: board.externalUrl, + isArchived: board.isArchived, + voteBudget: board.voteBudget, + voteBudgetReset: board.voteBudgetReset, + allowMultiVote: board.allowMultiVote, + postCount: board._count.posts, + createdAt: board.createdAt, + updatedAt: board.updatedAt, + }); + }); +} diff --git a/packages/api/src/routes/comments.ts b/packages/api/src/routes/comments.ts new file mode 100644 index 0000000..4652105 --- /dev/null +++ b/packages/api/src/routes/comments.ts @@ -0,0 +1,150 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { z } from "zod"; +import { verifyChallenge } from "../services/altcha.js"; + +const prisma = new PrismaClient(); + +const createCommentSchema = z.object({ + body: z.string().min(1).max(5000), + altcha: z.string(), +}); + +const updateCommentSchema = z.object({ + body: z.string().min(1).max(5000), +}); + +export default async function commentRoutes(app: FastifyInstance) { + app.get<{ Params: { boardSlug: string; id: string }; Querystring: { page?: string } }>( + "/boards/:boardSlug/posts/:id/comments", + async (req, reply) => { + const page = Math.max(1, parseInt(req.query.page ?? "1", 10)); + const limit = 50; + + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const [comments, total] = await Promise.all([ + prisma.comment.findMany({ + where: { postId: post.id }, + orderBy: { createdAt: "asc" }, + skip: (page - 1) * limit, + take: limit, + include: { + author: { select: { id: true, displayName: true } }, + reactions: { + select: { emoji: true, userId: true }, + }, + }, + }), + prisma.comment.count({ where: { postId: post.id } }), + ]); + + const grouped = comments.map((c) => { + const reactionMap: Record = {}; + for (const r of c.reactions) { + if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, userIds: [] }; + reactionMap[r.emoji].count++; + reactionMap[r.emoji].userIds.push(r.userId); + } + return { + id: c.id, + body: c.body, + author: c.author, + reactions: reactionMap, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + }; + }); + + reply.send({ comments: grouped, total, page, pages: Math.ceil(total / limit) }); + } + ); + + app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer }>( + "/boards/:boardSlug/posts/:id/comments", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const body = createCommentSchema.parse(req.body); + const valid = await verifyChallenge(body.altcha); + if (!valid) { + reply.status(400).send({ error: "Invalid challenge response" }); + return; + } + + const comment = await prisma.comment.create({ + data: { + body: body.body, + postId: post.id, + authorId: req.user!.id, + }, + include: { + author: { select: { id: true, displayName: true } }, + }, + }); + + await prisma.activityEvent.create({ + data: { + type: "comment_created", + boardId: post.boardId, + postId: post.id, + metadata: {}, + }, + }); + + reply.status(201).send(comment); + } + ); + + app.put<{ Params: { id: string }; Body: z.infer }>( + "/comments/:id", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); + if (!comment) { + reply.status(404).send({ error: "Comment not found" }); + return; + } + if (comment.authorId !== req.user!.id) { + reply.status(403).send({ error: "Not your comment" }); + return; + } + + const body = updateCommentSchema.parse(req.body); + const updated = await prisma.comment.update({ + where: { id: comment.id }, + data: { body: body.body }, + }); + + reply.send(updated); + } + ); + + app.delete<{ Params: { id: string } }>( + "/comments/:id", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); + if (!comment) { + reply.status(404).send({ error: "Comment not found" }); + return; + } + if (comment.authorId !== req.user!.id) { + reply.status(403).send({ error: "Not your comment" }); + return; + } + + await prisma.comment.delete({ where: { id: comment.id } }); + reply.status(204).send(); + } + ); +} diff --git a/packages/api/src/routes/feed.ts b/packages/api/src/routes/feed.ts new file mode 100644 index 0000000..24b1cf0 --- /dev/null +++ b/packages/api/src/routes/feed.ts @@ -0,0 +1,69 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import RSS from "rss"; + +const prisma = new PrismaClient(); + +export default async function feedRoutes(app: FastifyInstance) { + app.get<{ Params: { boardSlug: string } }>( + "/boards/:boardSlug/feed.rss", + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const posts = await prisma.post.findMany({ + where: { boardId: board.id }, + orderBy: { createdAt: "desc" }, + take: 50, + }); + + const feed = new RSS({ + title: `${board.name} - Echoboard`, + description: board.description ?? "", + feed_url: `${req.protocol}://${req.hostname}/api/v1/boards/${board.slug}/feed.rss`, + site_url: `${req.protocol}://${req.hostname}`, + }); + + for (const post of posts) { + feed.item({ + title: post.title, + description: `[${post.type}] ${post.status} - ${post.voteCount} votes`, + url: `${req.protocol}://${req.hostname}/board/${board.slug}/post/${post.id}`, + date: post.createdAt, + categories: post.category ? [post.category] : [], + }); + } + + reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true })); + } + ); + + app.get("/feed.rss", async (req, reply) => { + const posts = await prisma.post.findMany({ + orderBy: { createdAt: "desc" }, + take: 50, + include: { board: { select: { slug: true, name: true } } }, + }); + + const feed = new RSS({ + title: "Echoboard - All Feedback", + feed_url: `${req.protocol}://${req.hostname}/api/v1/feed.rss`, + site_url: `${req.protocol}://${req.hostname}`, + }); + + for (const post of posts) { + feed.item({ + title: `[${post.board.name}] ${post.title}`, + description: `[${post.type}] ${post.status} - ${post.voteCount} votes`, + url: `${req.protocol}://${req.hostname}/board/${post.board.slug}/post/${post.id}`, + date: post.createdAt, + categories: post.category ? [post.category] : [], + }); + } + + reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true })); + }); +} diff --git a/packages/api/src/routes/identity.ts b/packages/api/src/routes/identity.ts new file mode 100644 index 0000000..763c184 --- /dev/null +++ b/packages/api/src/routes/identity.ts @@ -0,0 +1,139 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { randomBytes } from "node:crypto"; +import { z } from "zod"; +import { hashToken, encrypt, decrypt } from "../services/encryption.js"; +import { masterKey } from "../config.js"; + +const prisma = new PrismaClient(); + +const updateMeSchema = z.object({ + displayName: z.string().max(50).optional().nullable(), + darkMode: z.enum(["system", "light", "dark"]).optional(), +}); + +export default async function identityRoutes(app: FastifyInstance) { + app.post("/identity", async (_req, reply) => { + const token = randomBytes(32).toString("hex"); + const hash = hashToken(token); + + const user = await prisma.user.create({ + data: { tokenHash: hash }, + }); + + reply + .setCookie("echoboard_token", token, { + path: "/", + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 365, + }) + .status(201) + .send({ + id: user.id, + authMethod: user.authMethod, + darkMode: user.darkMode, + }); + }); + + app.put<{ Body: z.infer }>( + "/me", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const body = updateMeSchema.parse(req.body); + const data: Record = {}; + + if (body.displayName !== undefined) { + data.displayName = body.displayName ? encrypt(body.displayName, masterKey) : null; + } + if (body.darkMode !== undefined) { + data.darkMode = body.darkMode; + } + + const updated = await prisma.user.update({ + where: { id: req.user!.id }, + data, + }); + + reply.send({ + id: updated.id, + displayName: updated.displayName ? decrypt(updated.displayName, masterKey) : null, + darkMode: updated.darkMode, + authMethod: updated.authMethod, + }); + } + ); + + app.get( + "/me/posts", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const posts = await prisma.post.findMany({ + where: { authorId: req.user!.id }, + orderBy: { createdAt: "desc" }, + include: { + board: { select: { slug: true, name: true } }, + _count: { select: { comments: true } }, + }, + }); + + reply.send(posts.map((p) => ({ + id: p.id, + type: p.type, + title: p.title, + status: p.status, + voteCount: p.voteCount, + commentCount: p._count.comments, + board: p.board, + createdAt: p.createdAt, + }))); + } + ); + + app.delete( + "/me", + { preHandler: [app.requireUser] }, + async (req, reply) => { + await prisma.user.delete({ where: { id: req.user!.id } }); + + reply + .clearCookie("echoboard_token", { path: "/" }) + .send({ ok: true }); + } + ); + + app.get( + "/me/export", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const userId = req.user!.id; + + const [user, posts, comments, votes, reactions] = await Promise.all([ + prisma.user.findUnique({ where: { id: userId } }), + prisma.post.findMany({ where: { authorId: userId } }), + prisma.comment.findMany({ where: { authorId: userId } }), + prisma.vote.findMany({ where: { voterId: userId } }), + prisma.reaction.findMany({ where: { userId } }), + ]); + + const decryptedUser = user ? { + id: user.id, + authMethod: user.authMethod, + displayName: user.displayName ? decrypt(user.displayName, masterKey) : null, + username: user.username ? decrypt(user.username, masterKey) : null, + darkMode: user.darkMode, + createdAt: user.createdAt, + } : null; + + reply.send({ + user: decryptedUser, + posts, + comments, + votes: votes.map((v) => ({ postId: v.postId, weight: v.weight, createdAt: v.createdAt })), + reactions: reactions.map((r) => ({ commentId: r.commentId, emoji: r.emoji, createdAt: r.createdAt })), + exportedAt: new Date().toISOString(), + }); + } + ); +} diff --git a/packages/api/src/routes/passkey.ts b/packages/api/src/routes/passkey.ts new file mode 100644 index 0000000..c9b5c90 --- /dev/null +++ b/packages/api/src/routes/passkey.ts @@ -0,0 +1,247 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server"; +import type { + RegistrationResponseJSON, + AuthenticationResponseJSON, +} from "@simplewebauthn/server"; +import jwt from "jsonwebtoken"; +import { z } from "zod"; +import { config, masterKey, blindIndexKey } from "../config.js"; +import { encrypt, decrypt, blindIndex } from "../services/encryption.js"; + +const prisma = new PrismaClient(); + +const challenges = new Map(); + +function storeChallenge(userId: string, challenge: string) { + challenges.set(userId, { challenge, expires: Date.now() + 5 * 60 * 1000 }); +} + +function getChallenge(userId: string): string | null { + const entry = challenges.get(userId); + if (!entry || entry.expires < Date.now()) { + challenges.delete(userId); + return null; + } + challenges.delete(userId); + return entry.challenge; +} + +export function cleanExpiredChallenges() { + const now = Date.now(); + for (const [key, val] of challenges) { + if (val.expires < now) challenges.delete(key); + } +} + +const registerBody = z.object({ + username: z.string().min(3).max(30), +}); + +export default async function passkeyRoutes(app: FastifyInstance) { + app.post<{ Body: z.infer }>( + "/auth/passkey/register/options", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const { username } = registerBody.parse(req.body); + const user = req.user!; + + const usernameHash = blindIndex(username, blindIndexKey); + const existing = await prisma.user.findUnique({ where: { usernameIdx: usernameHash } }); + if (existing && existing.id !== user.id) { + reply.status(409).send({ error: "Username taken" }); + return; + } + + const existingPasskeys = await prisma.passkey.findMany({ where: { userId: user.id } }); + + const options = await generateRegistrationOptions({ + rpName: config.WEBAUTHN_RP_NAME, + rpID: config.WEBAUTHN_RP_ID, + userID: new TextEncoder().encode(user.id), + userName: username, + attestationType: "none", + excludeCredentials: existingPasskeys.map((pk) => ({ + id: decrypt(pk.credentialId, masterKey), + })), + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }); + + storeChallenge(user.id, options.challenge); + reply.send(options); + } + ); + + app.post<{ Body: { response: RegistrationResponseJSON; username: string } }>( + "/auth/passkey/register/verify", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const user = req.user!; + const { response, username } = req.body; + + const expectedChallenge = getChallenge(user.id); + if (!expectedChallenge) { + reply.status(400).send({ error: "Challenge expired" }); + return; + } + + let verification; + try { + verification = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: config.WEBAUTHN_ORIGIN, + expectedRPID: config.WEBAUTHN_RP_ID, + }); + } catch (err: any) { + reply.status(400).send({ error: err.message }); + return; + } + + if (!verification.verified || !verification.registrationInfo) { + reply.status(400).send({ error: "Verification failed" }); + return; + } + + const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; + + const credIdStr = Buffer.from(credential.id).toString("base64url"); + + await prisma.passkey.create({ + data: { + credentialId: encrypt(credIdStr, masterKey), + credentialIdIdx: blindIndex(credIdStr, blindIndexKey), + credentialPublicKey: Buffer.from(credential.publicKey), + counter: BigInt(credential.counter), + credentialDeviceType, + credentialBackedUp, + transports: credential.transports ? encrypt(JSON.stringify(credential.transports), masterKey) : null, + userId: user.id, + }, + }); + + const usernameHash = blindIndex(username, blindIndexKey); + await prisma.user.update({ + where: { id: user.id }, + data: { + authMethod: "PASSKEY", + username: encrypt(username, masterKey), + usernameIdx: usernameHash, + }, + }); + + reply.send({ verified: true }); + } + ); + + app.post( + "/auth/passkey/login/options", + async (_req, reply) => { + const options = await generateAuthenticationOptions({ + rpID: config.WEBAUTHN_RP_ID, + userVerification: "preferred", + }); + + storeChallenge("login:" + options.challenge, options.challenge); + reply.send(options); + } + ); + + app.post<{ Body: { response: AuthenticationResponseJSON } }>( + "/auth/passkey/login/verify", + async (req, reply) => { + const { response } = req.body; + + const credIdStr = response.id; + const credIdx = blindIndex(credIdStr, blindIndexKey); + + const passkey = await prisma.passkey.findUnique({ where: { credentialIdIdx: credIdx } }); + if (!passkey) { + reply.status(400).send({ error: "Passkey not found" }); + return; + } + + const expectedChallenge = getChallenge("login:" + response.response.clientDataJSON); + // we stored with the challenge value, try to find it + let challenge: string | null = null; + for (const [key, val] of challenges) { + if (key.startsWith("login:") && val.expires > Date.now()) { + challenge = val.challenge; + challenges.delete(key); + break; + } + } + if (!challenge) { + reply.status(400).send({ error: "Challenge expired" }); + return; + } + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: challenge, + expectedOrigin: config.WEBAUTHN_ORIGIN, + expectedRPID: config.WEBAUTHN_RP_ID, + credential: { + id: decrypt(passkey.credentialId, masterKey), + publicKey: new Uint8Array(passkey.credentialPublicKey), + counter: Number(passkey.counter), + transports: passkey.transports + ? JSON.parse(decrypt(passkey.transports, masterKey)) + : undefined, + }, + }); + } catch (err: any) { + reply.status(400).send({ error: err.message }); + return; + } + + if (!verification.verified) { + reply.status(400).send({ error: "Verification failed" }); + return; + } + + await prisma.passkey.update({ + where: { id: passkey.id }, + data: { counter: BigInt(verification.authenticationInfo.newCounter) }, + }); + + const token = jwt.sign( + { sub: passkey.userId, type: "passkey" }, + config.JWT_SECRET, + { expiresIn: "30d" } + ); + + reply.send({ verified: true, token }); + } + ); + + app.post( + "/auth/passkey/logout", + { preHandler: [app.requireUser] }, + async (_req, reply) => { + reply + .clearCookie("echoboard_token", { path: "/" }) + .send({ ok: true }); + } + ); + + app.get<{ Params: { name: string } }>( + "/auth/passkey/check-username/:name", + async (req, reply) => { + const hash = blindIndex(req.params.name, blindIndexKey); + const existing = await prisma.user.findUnique({ where: { usernameIdx: hash } }); + reply.send({ available: !existing }); + } + ); +} diff --git a/packages/api/src/routes/posts.ts b/packages/api/src/routes/posts.ts new file mode 100644 index 0000000..7cc3fdf --- /dev/null +++ b/packages/api/src/routes/posts.ts @@ -0,0 +1,216 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient, PostType, PostStatus, Prisma } from "@prisma/client"; +import { z } from "zod"; +import { verifyChallenge } from "../services/altcha.js"; + +const prisma = new PrismaClient(); + +const createPostSchema = z.object({ + type: z.nativeEnum(PostType), + title: z.string().min(3).max(200), + description: z.any(), + category: z.string().optional(), + altcha: z.string(), +}); + +const updatePostSchema = z.object({ + title: z.string().min(3).max(200).optional(), + description: z.any().optional(), + category: z.string().optional().nullable(), +}); + +const querySchema = z.object({ + type: z.nativeEnum(PostType).optional(), + category: z.string().optional(), + status: z.nativeEnum(PostStatus).optional(), + sort: z.enum(["newest", "oldest", "top", "trending"]).default("newest"), + search: z.string().optional(), + page: z.coerce.number().min(1).default(1), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +export default async function postRoutes(app: FastifyInstance) { + app.get<{ Params: { boardSlug: string }; Querystring: Record }>( + "/boards/:boardSlug/posts", + { preHandler: [app.optionalUser] }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const q = querySchema.parse(req.query); + + const where: Prisma.PostWhereInput = { boardId: board.id }; + if (q.type) where.type = q.type; + if (q.category) where.category = q.category; + if (q.status) where.status = q.status; + if (q.search) where.title = { contains: q.search, mode: "insensitive" }; + + let orderBy: Prisma.PostOrderByWithRelationInput; + switch (q.sort) { + case "oldest": orderBy = { createdAt: "asc" }; break; + case "top": orderBy = { voteCount: "desc" }; break; + case "trending": orderBy = { voteCount: "desc" }; break; + default: orderBy = { createdAt: "desc" }; + } + + const [posts, total] = await Promise.all([ + prisma.post.findMany({ + where, + orderBy: [{ isPinned: "desc" }, orderBy], + skip: (q.page - 1) * q.limit, + take: q.limit, + include: { + _count: { select: { comments: true } }, + author: { select: { id: true, displayName: true } }, + }, + }), + prisma.post.count({ where }), + ]); + + reply.send({ + posts: posts.map((p) => ({ + id: p.id, + type: p.type, + title: p.title, + status: p.status, + category: p.category, + voteCount: p.voteCount, + isPinned: p.isPinned, + commentCount: p._count.comments, + author: p.author, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + })), + total, + page: q.page, + pages: Math.ceil(total / q.limit), + }); + } + ); + + app.get<{ Params: { boardSlug: string; id: string } }>( + "/boards/:boardSlug/posts/:id", + { preHandler: [app.optionalUser] }, + async (req, reply) => { + const post = await prisma.post.findUnique({ + where: { id: req.params.id }, + include: { + author: { select: { id: true, displayName: true } }, + _count: { select: { comments: true, votes: true } }, + adminResponses: { + include: { admin: { select: { id: true, email: true } } }, + orderBy: { createdAt: "asc" }, + }, + statusChanges: { orderBy: { createdAt: "asc" } }, + }, + }); + + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + let voted = false; + if (req.user) { + const existing = await prisma.vote.findUnique({ + where: { postId_voterId: { postId: post.id, voterId: req.user.id } }, + }); + voted = !!existing; + } + + reply.send({ ...post, voted }); + } + ); + + app.post<{ Params: { boardSlug: string }; Body: z.infer }>( + "/boards/:boardSlug/posts", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board || board.isArchived) { + reply.status(404).send({ error: "Board not found or archived" }); + return; + } + + const body = createPostSchema.parse(req.body); + + const valid = await verifyChallenge(body.altcha); + if (!valid) { + reply.status(400).send({ error: "Invalid challenge response" }); + return; + } + + const post = await prisma.post.create({ + data: { + type: body.type, + title: body.title, + description: body.description, + category: body.category, + boardId: board.id, + authorId: req.user!.id, + }, + }); + + await prisma.activityEvent.create({ + data: { + type: "post_created", + boardId: board.id, + postId: post.id, + metadata: { title: post.title, type: post.type }, + }, + }); + + reply.status(201).send(post); + } + ); + + app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer }>( + "/boards/:boardSlug/posts/:id", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + if (post.authorId !== req.user!.id) { + reply.status(403).send({ error: "Not your post" }); + return; + } + + const body = updatePostSchema.parse(req.body); + const updated = await prisma.post.update({ + where: { id: post.id }, + data: { + ...(body.title !== undefined && { title: body.title }), + ...(body.description !== undefined && { description: body.description }), + ...(body.category !== undefined && { category: body.category }), + }, + }); + + reply.send(updated); + } + ); + + app.delete<{ Params: { boardSlug: string; id: string } }>( + "/boards/:boardSlug/posts/:id", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post) { + reply.status(404).send({ error: "Post not found" }); + return; + } + if (post.authorId !== req.user!.id) { + reply.status(403).send({ error: "Not your post" }); + return; + } + + await prisma.post.delete({ where: { id: post.id } }); + reply.status(204).send(); + } + ); +} diff --git a/packages/api/src/routes/privacy.ts b/packages/api/src/routes/privacy.ts new file mode 100644 index 0000000..a613d0b --- /dev/null +++ b/packages/api/src/routes/privacy.ts @@ -0,0 +1,50 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { generateChallenge } from "../services/altcha.js"; +import { config } from "../config.js"; + +const prisma = new PrismaClient(); + +export default async function privacyRoutes(app: FastifyInstance) { + app.get("/altcha/challenge", async (req, reply) => { + const difficulty = req.query && (req.query as any).difficulty === "light" ? "light" : "normal"; + const challenge = await generateChallenge(difficulty as "normal" | "light"); + reply.send(challenge); + }); + + app.get("/privacy/data-manifest", async (_req, reply) => { + reply.send({ + dataCollected: { + anonymous: { + cookieToken: "SHA-256 hashed, used for session identity", + displayName: "AES-256-GCM encrypted, optional", + posts: "Stored with author reference, deletable", + comments: "Stored with author reference, deletable", + votes: "Stored with voter reference, deletable", + reactions: "Stored with user reference, deletable", + }, + passkey: { + username: "AES-256-GCM encrypted with blind index", + credentialId: "AES-256-GCM encrypted with blind index", + publicKey: "Encrypted at rest", + }, + }, + retention: { + activityEvents: `${config.DATA_RETENTION_ACTIVITY_DAYS} days`, + orphanedUsers: `${config.DATA_RETENTION_ORPHAN_USER_DAYS} days`, + }, + encryption: "AES-256-GCM with 96-bit random IV per value", + indexing: "HMAC-SHA256 blind indexes for lookups", + thirdParty: "None - fully self-hosted", + export: "GET /api/v1/me/export", + deletion: "DELETE /api/v1/me", + }); + }); + + app.get("/categories", async (_req, reply) => { + const cats = await prisma.category.findMany({ + orderBy: { name: "asc" }, + }); + reply.send(cats); + }); +} diff --git a/packages/api/src/routes/push.ts b/packages/api/src/routes/push.ts new file mode 100644 index 0000000..434003c --- /dev/null +++ b/packages/api/src/routes/push.ts @@ -0,0 +1,91 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { z } from "zod"; +import { encrypt, blindIndex } from "../services/encryption.js"; +import { masterKey, blindIndexKey } from "../config.js"; + +const prisma = new PrismaClient(); + +const subscribeBody = z.object({ + endpoint: z.string().url(), + keys: z.object({ + p256dh: z.string(), + auth: z.string(), + }), + boardId: z.string().optional(), + postId: z.string().optional(), +}); + +const unsubscribeBody = z.object({ + endpoint: z.string().url(), +}); + +export default async function pushRoutes(app: FastifyInstance) { + app.post<{ Body: z.infer }>( + "/push/subscribe", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const body = subscribeBody.parse(req.body); + const endpointIdx = blindIndex(body.endpoint, blindIndexKey); + + const existing = await prisma.pushSubscription.findUnique({ where: { endpointIdx } }); + if (existing) { + await prisma.pushSubscription.update({ + where: { id: existing.id }, + data: { + boardId: body.boardId ?? null, + postId: body.postId ?? null, + }, + }); + reply.send({ ok: true, updated: true }); + return; + } + + await prisma.pushSubscription.create({ + data: { + endpoint: encrypt(body.endpoint, masterKey), + endpointIdx, + keysP256dh: encrypt(body.keys.p256dh, masterKey), + keysAuth: encrypt(body.keys.auth, masterKey), + userId: req.user!.id, + boardId: body.boardId, + postId: body.postId, + }, + }); + + reply.status(201).send({ ok: true }); + } + ); + + app.delete<{ Body: z.infer }>( + "/push/subscribe", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const body = unsubscribeBody.parse(req.body); + const endpointIdx = blindIndex(body.endpoint, blindIndexKey); + + const deleted = await prisma.pushSubscription.deleteMany({ + where: { endpointIdx, userId: req.user!.id }, + }); + + if (deleted.count === 0) { + reply.status(404).send({ error: "Subscription not found" }); + return; + } + + reply.send({ ok: true }); + } + ); + + app.get( + "/push/subscriptions", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const subs = await prisma.pushSubscription.findMany({ + where: { userId: req.user!.id }, + select: { id: true, boardId: true, postId: true, createdAt: true }, + }); + reply.send(subs); + } + ); +} diff --git a/packages/api/src/routes/reactions.ts b/packages/api/src/routes/reactions.ts new file mode 100644 index 0000000..e1bf423 --- /dev/null +++ b/packages/api/src/routes/reactions.ts @@ -0,0 +1,70 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { z } from "zod"; + +const prisma = new PrismaClient(); + +const reactionBody = z.object({ + emoji: z.string().min(1).max(8), +}); + +export default async function reactionRoutes(app: FastifyInstance) { + app.post<{ Params: { id: string }; Body: z.infer }>( + "/comments/:id/reactions", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); + if (!comment) { + reply.status(404).send({ error: "Comment not found" }); + return; + } + + const { emoji } = reactionBody.parse(req.body); + + const existing = await prisma.reaction.findUnique({ + where: { + commentId_userId_emoji: { + commentId: comment.id, + userId: req.user!.id, + emoji, + }, + }, + }); + + if (existing) { + await prisma.reaction.delete({ where: { id: existing.id } }); + reply.send({ toggled: false }); + } else { + await prisma.reaction.create({ + data: { + emoji, + commentId: comment.id, + userId: req.user!.id, + }, + }); + reply.send({ toggled: true }); + } + } + ); + + app.delete<{ Params: { id: string; emoji: string } }>( + "/comments/:id/reactions/:emoji", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const deleted = await prisma.reaction.deleteMany({ + where: { + commentId: req.params.id, + userId: req.user!.id, + emoji: req.params.emoji, + }, + }); + + if (deleted.count === 0) { + reply.status(404).send({ error: "Reaction not found" }); + return; + } + + reply.send({ ok: true }); + } + ); +} diff --git a/packages/api/src/routes/votes.ts b/packages/api/src/routes/votes.ts new file mode 100644 index 0000000..9ebe597 --- /dev/null +++ b/packages/api/src/routes/votes.ts @@ -0,0 +1,138 @@ +import { FastifyInstance } from "fastify"; +import { PrismaClient } from "@prisma/client"; +import { z } from "zod"; +import { verifyChallenge } from "../services/altcha.js"; +import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js"; + +const prisma = new PrismaClient(); + +const voteBody = z.object({ + altcha: z.string(), +}); + +export default async function voteRoutes(app: FastifyInstance) { + app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer }>( + "/boards/:boardSlug/posts/:id/vote", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const post = await prisma.post.findUnique({ where: { id: req.params.id } }); + if (!post || post.boardId !== board.id) { + reply.status(404).send({ error: "Post not found" }); + return; + } + + const body = voteBody.parse(req.body); + const valid = await verifyChallenge(body.altcha); + if (!valid) { + reply.status(400).send({ error: "Invalid challenge response" }); + return; + } + + const existing = await prisma.vote.findUnique({ + where: { postId_voterId: { postId: post.id, voterId: req.user!.id } }, + }); + + if (existing && !board.allowMultiVote) { + reply.status(409).send({ error: "Already voted" }); + return; + } + + const remaining = await getRemainingBudget(req.user!.id, board.id); + if (remaining <= 0) { + reply.status(429).send({ error: "Vote budget exhausted" }); + return; + } + + const period = getCurrentPeriod(board.voteBudgetReset); + + if (existing && board.allowMultiVote) { + await prisma.vote.update({ + where: { id: existing.id }, + data: { weight: existing.weight + 1 }, + }); + } else { + await prisma.vote.create({ + data: { + postId: post.id, + voterId: req.user!.id, + budgetPeriod: period, + }, + }); + } + + await prisma.post.update({ + where: { id: post.id }, + data: { voteCount: { increment: 1 } }, + }); + + await prisma.activityEvent.create({ + data: { + type: "vote_cast", + boardId: board.id, + postId: post.id, + metadata: {}, + }, + }); + + reply.send({ ok: true, voteCount: post.voteCount + 1 }); + } + ); + + app.delete<{ Params: { boardSlug: string; id: string } }>( + "/boards/:boardSlug/posts/:id/vote", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const vote = await prisma.vote.findUnique({ + where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } }, + }); + + if (!vote) { + reply.status(404).send({ error: "No vote found" }); + return; + } + + const weight = vote.weight; + await prisma.vote.delete({ where: { id: vote.id } }); + await prisma.post.update({ + where: { id: req.params.id }, + data: { voteCount: { decrement: weight } }, + }); + + reply.send({ ok: true }); + } + ); + + app.get<{ Params: { boardSlug: string } }>( + "/boards/:boardSlug/budget", + { preHandler: [app.requireUser] }, + async (req, reply) => { + const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); + if (!board) { + reply.status(404).send({ error: "Board not found" }); + return; + } + + const remaining = await getRemainingBudget(req.user!.id, board.id); + const nextReset = getNextResetDate(board.voteBudgetReset); + + reply.send({ + total: board.voteBudget, + remaining, + resetSchedule: board.voteBudgetReset, + nextReset: nextReset.toISOString(), + }); + } + ); +} diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts new file mode 100644 index 0000000..e8fab65 --- /dev/null +++ b/packages/api/src/server.ts @@ -0,0 +1,93 @@ +import Fastify from "fastify"; +import cookie from "@fastify/cookie"; +import cors from "@fastify/cors"; +import rateLimit from "@fastify/rate-limit"; +import fastifyStatic from "@fastify/static"; +import { resolve } from "node:path"; +import { existsSync } from "node:fs"; + +import securityPlugin from "./middleware/security.js"; +import authPlugin from "./middleware/auth.js"; +import { loadPlugins } from "./plugins/loader.js"; + +import boardRoutes from "./routes/boards.js"; +import postRoutes from "./routes/posts.js"; +import voteRoutes from "./routes/votes.js"; +import commentRoutes from "./routes/comments.js"; +import reactionRoutes from "./routes/reactions.js"; +import identityRoutes from "./routes/identity.js"; +import passkeyRoutes from "./routes/passkey.js"; +import feedRoutes from "./routes/feed.js"; +import activityRoutes from "./routes/activity.js"; +import pushRoutes from "./routes/push.js"; +import privacyRoutes from "./routes/privacy.js"; +import adminAuthRoutes from "./routes/admin/auth.js"; +import adminPostRoutes from "./routes/admin/posts.js"; +import adminBoardRoutes from "./routes/admin/boards.js"; +import adminCategoryRoutes from "./routes/admin/categories.js"; +import adminStatsRoutes from "./routes/admin/stats.js"; + +export async function createServer() { + const app = Fastify({ + logger: { + serializers: { + req(req) { + return { + method: req.method, + url: req.url, + }; + }, + }, + }, + }); + + await app.register(cookie, { secret: process.env.TOKEN_SECRET }); + await app.register(cors, { + origin: true, + credentials: true, + }); + await app.register(rateLimit, { + max: 100, + timeWindow: "1 minute", + }); + + await app.register(securityPlugin); + await app.register(authPlugin); + + // api routes under /api/v1 + await app.register(async (api) => { + await api.register(boardRoutes); + await api.register(postRoutes); + await api.register(voteRoutes); + await api.register(commentRoutes); + await api.register(reactionRoutes); + await api.register(identityRoutes); + await api.register(passkeyRoutes); + await api.register(feedRoutes); + await api.register(activityRoutes); + await api.register(pushRoutes); + await api.register(privacyRoutes); + await api.register(adminAuthRoutes); + await api.register(adminPostRoutes); + await api.register(adminBoardRoutes); + await api.register(adminCategoryRoutes); + await api.register(adminStatsRoutes); + }, { prefix: "/api/v1" }); + + // serve static frontend build in production + const webDist = resolve(process.cwd(), "../web/dist"); + if (process.env.NODE_ENV === "production" && existsSync(webDist)) { + await app.register(fastifyStatic, { + root: webDist, + wildcard: false, + }); + + app.setNotFoundHandler((_req, reply) => { + reply.sendFile("index.html"); + }); + } + + await loadPlugins(app); + + return app; +} diff --git a/packages/api/src/services/altcha.ts b/packages/api/src/services/altcha.ts new file mode 100644 index 0000000..7bcd046 --- /dev/null +++ b/packages/api/src/services/altcha.ts @@ -0,0 +1,21 @@ +import { createChallenge, verifySolution } from "altcha-lib"; +import { config } from "../config.js"; + +export async function generateChallenge(difficulty: "normal" | "light" = "normal") { + const maxNumber = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER; + const challenge = await createChallenge({ + hmacKey: config.ALTCHA_HMAC_KEY, + maxNumber, + expires: new Date(Date.now() + config.ALTCHA_EXPIRE_SECONDS * 1000), + }); + return challenge; +} + +export async function verifyChallenge(payload: string): Promise { + try { + const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY); + return ok; + } catch { + return false; + } +} diff --git a/packages/api/src/services/encryption.ts b/packages/api/src/services/encryption.ts new file mode 100644 index 0000000..e7a751f --- /dev/null +++ b/packages/api/src/services/encryption.ts @@ -0,0 +1,30 @@ +import { createCipheriv, createDecipheriv, createHmac, createHash, randomBytes } from "node:crypto"; + +const IV_LEN = 12; +const TAG_LEN = 16; + +export function encrypt(plaintext: string, key: Buffer): string { + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, encrypted, tag]).toString("base64"); +} + +export function decrypt(encoded: string, key: Buffer): string { + const buf = Buffer.from(encoded, "base64"); + const iv = buf.subarray(0, IV_LEN); + const tag = buf.subarray(buf.length - TAG_LEN); + const ciphertext = buf.subarray(IV_LEN, buf.length - TAG_LEN); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return decipher.update(ciphertext) + decipher.final("utf8"); +} + +export function blindIndex(value: string, key: Buffer): string { + return createHmac("sha256", key).update(value.toLowerCase()).digest("hex"); +} + +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} diff --git a/packages/api/src/services/push.ts b/packages/api/src/services/push.ts new file mode 100644 index 0000000..7821283 --- /dev/null +++ b/packages/api/src/services/push.ts @@ -0,0 +1,71 @@ +import webpush from "web-push"; +import { PrismaClient } from "@prisma/client"; +import { config } from "../config.js"; +import { decrypt } from "./encryption.js"; +import { masterKey } from "../config.js"; + +const prisma = new PrismaClient(); + +if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY && config.VAPID_CONTACT) { + webpush.setVapidDetails( + config.VAPID_CONTACT, + config.VAPID_PUBLIC_KEY, + config.VAPID_PRIVATE_KEY + ); +} + +interface PushPayload { + title: string; + body: string; + url?: string; + tag?: string; +} + +export async function sendNotification(sub: { endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload) { + try { + await webpush.sendNotification( + { + endpoint: decrypt(sub.endpoint, masterKey), + keys: { + p256dh: decrypt(sub.keysP256dh, masterKey), + auth: decrypt(sub.keysAuth, masterKey), + }, + }, + JSON.stringify(payload) + ); + return true; + } catch (err: any) { + if (err.statusCode === 404 || err.statusCode === 410) { + return false; + } + throw err; + } +} + +export async function notifyPostSubscribers(postId: string, event: PushPayload) { + const subs = await prisma.pushSubscription.findMany({ where: { postId } }); + const failed: string[] = []; + + for (const sub of subs) { + const ok = await sendNotification(sub, event); + if (!ok) failed.push(sub.id); + } + + if (failed.length > 0) { + await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } }); + } +} + +export async function notifyBoardSubscribers(boardId: string, event: PushPayload) { + const subs = await prisma.pushSubscription.findMany({ where: { boardId, postId: null } }); + const failed: string[] = []; + + for (const sub of subs) { + const ok = await sendNotification(sub, event); + if (!ok) failed.push(sub.id); + } + + if (failed.length > 0) { + await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } }); + } +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..9c8a7d7 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..890b698 --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,12 @@ + + + + + + Echoboard + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..ee02a63 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "@echoboard/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource/space-grotesk": "^5.0.0", + "@fontsource/sora": "^5.0.0", + "@simplewebauthn/browser": "^11.0.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^7.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx new file mode 100644 index 0000000..b63b362 --- /dev/null +++ b/packages/web/src/App.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react' +import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom' +import { AuthProvider, useAuthState } from './hooks/useAuth' +import { ThemeProvider, useThemeState } from './hooks/useTheme' +import Sidebar from './components/Sidebar' +import MobileNav from './components/MobileNav' +import ThemeToggle from './components/ThemeToggle' +import IdentityBanner from './components/IdentityBanner' +import CommandPalette from './components/CommandPalette' +import PasskeyModal from './components/PasskeyModal' +import BoardIndex from './pages/BoardIndex' +import BoardFeed from './pages/BoardFeed' +import PostDetail from './pages/PostDetail' +import ActivityFeed from './pages/ActivityFeed' +import IdentitySettings from './pages/IdentitySettings' +import MySubmissions from './pages/MySubmissions' +import PrivacyPage from './pages/PrivacyPage' +import AdminLogin from './pages/admin/AdminLogin' +import AdminDashboard from './pages/admin/AdminDashboard' +import AdminPosts from './pages/admin/AdminPosts' +import AdminBoards from './pages/admin/AdminBoards' + +function Layout() { + const location = useLocation() + const [passkeyMode, setPasskeyMode] = useState<'register' | 'login' | null>(null) + const isAdmin = location.pathname.startsWith('/admin') + + return ( + <> + +
+ {!isAdmin && } +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ {!isAdmin && } + + {!isAdmin && ( + setPasskeyMode('register')} /> + )} + setPasskeyMode(null)} + /> + + ) +} + +export default function App() { + const auth = useAuthState() + const theme = useThemeState() + + return ( + + + + + + + + ) +} diff --git a/packages/web/src/app.css b/packages/web/src/app.css new file mode 100644 index 0000000..0565746 --- /dev/null +++ b/packages/web/src/app.css @@ -0,0 +1,161 @@ +@import "tailwindcss"; + +@layer base { + :root { + --bg: #141420; + --surface: #1c1c2e; + --surface-hover: #24243a; + --border: rgba(245, 240, 235, 0.08); + --border-hover: rgba(245, 240, 235, 0.15); + --text: #f5f0eb; + --text-secondary: rgba(245, 240, 235, 0.6); + --text-tertiary: rgba(245, 240, 235, 0.35); + --accent: #F59E0B; + --accent-hover: #D97706; + --accent-subtle: rgba(245, 158, 11, 0.15); + --admin-accent: #06B6D4; + --admin-subtle: rgba(6, 182, 212, 0.15); + --success: #22C55E; + --warning: #EAB308; + --error: #EF4444; + --info: #3B82F6; + --font-heading: 'Space Grotesk', system-ui, sans-serif; + --font-body: 'Sora', system-ui, sans-serif; + } + + html.light { + --bg: #faf9f6; + --surface: #ffffff; + --surface-hover: #f0eeea; + --border: rgba(20, 20, 32, 0.08); + --border-hover: rgba(20, 20, 32, 0.15); + --text: #1a1a2e; + --text-secondary: rgba(26, 26, 46, 0.6); + --text-tertiary: rgba(26, 26, 46, 0.35); + --accent: #D97706; + --accent-hover: #B45309; + --accent-subtle: rgba(217, 119, 6, 0.15); + --admin-accent: #0891B2; + --admin-subtle: rgba(8, 145, 178, 0.15); + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + background: var(--bg); + color: var(--text); + font-family: var(--font-body); + -webkit-font-smoothing: antialiased; + transition: background 200ms ease-out, color 200ms ease-out; + } + + h1, h2, h3, h4, h5, h6 { + font-family: var(--font-heading); + } +} + +@layer components { + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-family: var(--font-body); + font-weight: 500; + font-size: 0.875rem; + transition: all 200ms ease-out; + cursor: pointer; + border: none; + outline: none; + } + + .btn-primary { + background: var(--accent); + color: #141420; + } + .btn-primary:hover { + background: var(--accent-hover); + } + + .btn-secondary { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + } + .btn-secondary:hover { + background: var(--surface-hover); + border-color: var(--border-hover); + } + + .btn-ghost { + background: transparent; + color: var(--text-secondary); + } + .btn-ghost:hover { + background: var(--surface-hover); + color: var(--text); + } + + .btn-admin { + background: var(--admin-accent); + color: #141420; + } + .btn-admin:hover { + opacity: 0.9; + } + + .card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.75rem; + transition: border-color 200ms ease-out, box-shadow 200ms ease-out; + } + .card:hover { + border-color: var(--border-hover); + } + + .input { + width: 100%; + padding: 0.625rem 0.875rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + color: var(--text); + font-family: var(--font-body); + font-size: 0.875rem; + transition: border-color 200ms ease-out; + outline: none; + } + .input:focus { + border-color: var(--accent); + } + .input::placeholder { + color: var(--text-tertiary); + } + + .slide-up { + animation: slideUp 300ms cubic-bezier(0.16, 1, 0.3, 1); + } + .fade-in { + animation: fadeIn 200ms ease-out; + } + + @keyframes slideUp { + from { transform: translateY(16px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } +} diff --git a/packages/web/src/components/CommandPalette.tsx b/packages/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..8891362 --- /dev/null +++ b/packages/web/src/components/CommandPalette.tsx @@ -0,0 +1,227 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { api } from '../lib/api' + +interface SearchResult { + type: 'post' | 'board' + id: string + title: string + slug?: string + boardSlug?: string +} + +export default function CommandPalette() { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [selected, setSelected] = useState(0) + const [loading, setLoading] = useState(false) + const inputRef = useRef(null) + const nav = useNavigate() + + const toggle = useCallback(() => { + setOpen((v) => { + if (!v) { + setQuery('') + setResults([]) + setSelected(0) + } + return !v + }) + }, []) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + toggle() + } + if (e.key === 'Escape' && open) { + setOpen(false) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [open, toggle]) + + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 50) + } + }, [open]) + + useEffect(() => { + if (!query.trim()) { + setResults([]) + return + } + const t = setTimeout(async () => { + setLoading(true) + try { + const res = await api.get(`/search?q=${encodeURIComponent(query)}`) + setResults(res) + setSelected(0) + } catch { + setResults([]) + } finally { + setLoading(false) + } + }, 200) + return () => clearTimeout(t) + }, [query]) + + const navigate = (r: SearchResult) => { + if (r.type === 'board') { + nav(`/b/${r.slug}`) + } else { + nav(`/b/${r.boardSlug}/post/${r.id}`) + } + setOpen(false) + } + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelected((s) => Math.min(s + 1, results.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelected((s) => Math.max(s - 1, 0)) + } else if (e.key === 'Enter' && results[selected]) { + navigate(results[selected]) + } + } + + if (!open) return null + + const boards = results.filter((r) => r.type === 'board') + const posts = results.filter((r) => r.type === 'post') + let idx = -1 + + return ( +
setOpen(false)} + > + {/* Backdrop */} +
+ + {/* Modal */} +
e.stopPropagation()} + > +
+ + + + setQuery(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Search posts and boards..." + className="flex-1 bg-transparent outline-none text-sm" + style={{ color: 'var(--text)', fontFamily: 'var(--font-body)' }} + /> + + ESC + +
+ +
+ {loading && ( +
+ Searching... +
+ )} + + {!loading && query && results.length === 0 && ( +
+ No results for "{query}" +
+ )} + + {!loading && !query && ( +
+ Start typing to search... +
+ )} + + {boards.length > 0 && ( +
+
+ Boards +
+ {boards.map((r) => { + idx++ + const i = idx + return ( + + ) + })} +
+ )} + + {posts.length > 0 && ( +
+
+ Posts +
+ {posts.map((r) => { + idx++ + const i = idx + return ( + + ) + })} +
+ )} +
+ +
+ ↑↓ navigate + Enter open + Esc close +
+
+
+ ) +} diff --git a/packages/web/src/components/EmptyState.tsx b/packages/web/src/components/EmptyState.tsx new file mode 100644 index 0000000..75ec918 --- /dev/null +++ b/packages/web/src/components/EmptyState.tsx @@ -0,0 +1,75 @@ +interface Props { + title?: string + message?: string + actionLabel?: string + onAction?: () => void +} + +export default function EmptyState({ + title = 'Nothing here yet', + message = 'Be the first to share feedback', + actionLabel = 'Create a post', + onAction, +}: Props) { + return ( +
+ {/* Megaphone SVG */} + + + + + + + + +

+ {title} +

+

+ {message} +

+ {onAction && ( + + )} +
+ ) +} diff --git a/packages/web/src/components/IdentityBanner.tsx b/packages/web/src/components/IdentityBanner.tsx new file mode 100644 index 0000000..14876c5 --- /dev/null +++ b/packages/web/src/components/IdentityBanner.tsx @@ -0,0 +1,74 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' + +const DISMISSED_KEY = 'echoboard-identity-ack' + +export default function IdentityBanner({ onRegister }: { onRegister: () => void }) { + const [visible, setVisible] = useState(false) + + useEffect(() => { + if (!localStorage.getItem(DISMISSED_KEY)) { + const t = setTimeout(() => setVisible(true), 800) + return () => clearTimeout(t) + } + }, []) + + if (!visible) return null + + const dismiss = () => { + localStorage.setItem(DISMISSED_KEY, '1') + setVisible(false) + } + + return ( +
+
+
+
+ + + +
+
+

+ Your identity is cookie-based +

+

+ You can post and vote right now - no signup needed. A cookie links your activity. Register a passkey to keep access across devices and browsers. +

+
+ + + + Learn more + +
+
+
+
+
+ ) +} diff --git a/packages/web/src/components/MobileNav.tsx b/packages/web/src/components/MobileNav.tsx new file mode 100644 index 0000000..dae4ed6 --- /dev/null +++ b/packages/web/src/components/MobileNav.tsx @@ -0,0 +1,97 @@ +import { Link, useLocation } from 'react-router-dom' + +const tabs = [ + { + path: '/', + label: 'Home', + icon: ( + + + + ), + }, + { + path: '/search', + label: 'Search', + icon: ( + + + + ), + }, + { + path: '/new', + label: 'New', + icon: ( + + + + ), + accent: true, + }, + { + path: '/activity', + label: 'Activity', + icon: ( + + + + ), + }, + { + path: '/settings', + label: 'Profile', + icon: ( + + + + ), + }, +] + +export default function MobileNav() { + const location = useLocation() + + return ( + + ) +} diff --git a/packages/web/src/components/PasskeyModal.tsx b/packages/web/src/components/PasskeyModal.tsx new file mode 100644 index 0000000..178c380 --- /dev/null +++ b/packages/web/src/components/PasskeyModal.tsx @@ -0,0 +1,186 @@ +import { useState, useEffect, useRef } from 'react' +import { startRegistration, startAuthentication } from '@simplewebauthn/browser' +import { api } from '../lib/api' +import { useAuth } from '../hooks/useAuth' + +interface Props { + mode: 'register' | 'login' + open: boolean + onClose: () => void +} + +export default function PasskeyModal({ mode, open, onClose }: Props) { + const auth = useAuth() + const [username, setUsername] = useState('') + const [checking, setChecking] = useState(false) + const [available, setAvailable] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const inputRef = useRef(null) + const checkTimer = useRef>() + + useEffect(() => { + if (open) { + setUsername('') + setAvailable(null) + setError('') + setTimeout(() => inputRef.current?.focus(), 100) + } + }, [open]) + + useEffect(() => { + if (mode !== 'register' || !username.trim() || username.length < 3) { + setAvailable(null) + return + } + clearTimeout(checkTimer.current) + checkTimer.current = setTimeout(async () => { + setChecking(true) + try { + const res = await api.get<{ available: boolean }>(`/identity/check-username?name=${encodeURIComponent(username)}`) + setAvailable(res.available) + } catch { + setAvailable(null) + } finally { + setChecking(false) + } + }, 400) + return () => clearTimeout(checkTimer.current) + }, [username, mode]) + + const handleRegister = async () => { + if (!username.trim()) { + setError('Username is required') + return + } + setLoading(true) + setError('') + + try { + const opts = await api.post('/auth/passkey/register/options', { username }) + const attestation = await startRegistration({ optionsJSON: opts }) + await api.post('/auth/passkey/register/verify', { username, attestation }) + await auth.refresh() + onClose() + } catch (e: any) { + setError(e?.message || 'Registration failed. Please try again.') + } finally { + setLoading(false) + } + } + + const handleLogin = async () => { + setLoading(true) + setError('') + + try { + const opts = await api.post('/auth/passkey/login/options') + const assertion = await startAuthentication({ optionsJSON: opts }) + await api.post('/auth/passkey/login/verify', { assertion }) + await auth.refresh() + onClose() + } catch (e: any) { + setError(e?.message || 'Authentication failed. Please try again.') + } finally { + setLoading(false) + } + } + + if (!open) return null + + return ( +
+
+
e.stopPropagation()} + > +
+

+ {mode === 'register' ? 'Register Passkey' : 'Login with Passkey'} +

+ +
+ + {mode === 'register' ? ( + <> +

+ Choose a display name and register a passkey to keep your identity across devices. +

+
+ setUsername(e.target.value)} + /> + {checking && ( +
+
+
+ )} + {!checking && available !== null && ( +
+ {available ? ( + + + + ) : ( + + + + )} +
+ )} +
+ {!checking && available === false && ( +

This name is taken

+ )} + + ) : ( +

+ Use your registered passkey to sign in and restore your identity. +

+ )} + + {error &&

{error}

} + + + + {mode === 'register' && ( +

+ Your passkey is stored on your device. No passwords involved. +

+ )} +
+
+ ) +} diff --git a/packages/web/src/components/PostCard.tsx b/packages/web/src/components/PostCard.tsx new file mode 100644 index 0000000..789ce37 --- /dev/null +++ b/packages/web/src/components/PostCard.tsx @@ -0,0 +1,110 @@ +import { Link } from 'react-router-dom' +import StatusBadge from './StatusBadge' + +interface Post { + id: string + title: string + excerpt?: string + type: 'feature' | 'bug' | 'general' + status: string + voteCount: number + commentCount: number + authorName: string + createdAt: string + boardSlug: string + hasVoted?: boolean +} + +export default function PostCard({ + post, + onVote, +}: { + post: Post + onVote?: (id: string) => void +}) { + const timeAgo = formatTimeAgo(post.createdAt) + + return ( +
+ {/* Vote column */} + + + {/* Content zone */} + +
+ + {post.type} + + + {post.authorName} - {timeAgo} + +
+

+ {post.title} +

+ {post.excerpt && ( +

+ {post.excerpt} +

+ )} + + + {/* Status + comments */} +
+ +
+ + + + {post.commentCount} +
+
+
+ ) +} + +function formatTimeAgo(date: string): string { + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000) + if (seconds < 60) return 'just now' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + const months = Math.floor(days / 30) + return `${months}mo ago` +} diff --git a/packages/web/src/components/PostForm.tsx b/packages/web/src/components/PostForm.tsx new file mode 100644 index 0000000..cf8cd99 --- /dev/null +++ b/packages/web/src/components/PostForm.tsx @@ -0,0 +1,183 @@ +import { useState, useRef } from 'react' +import { api } from '../lib/api' + +interface Props { + boardSlug: string + onSubmit?: () => void +} + +type PostType = 'feature' | 'bug' | 'general' + +export default function PostForm({ boardSlug, onSubmit }: Props) { + const [expanded, setExpanded] = useState(false) + const [type, setType] = useState('feature') + const [title, setTitle] = useState('') + const [body, setBody] = useState('') + const [expected, setExpected] = useState('') + const [actual, setActual] = useState('') + const [steps, setSteps] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + const formRef = useRef(null) + + const reset = () => { + setTitle('') + setBody('') + setExpected('') + setActual('') + setSteps('') + setError('') + setExpanded(false) + } + + const submit = async () => { + if (!title.trim()) { + setError('Title is required') + return + } + setSubmitting(true) + setError('') + + const payload: Record = { title, type, body } + if (type === 'bug') { + payload.stepsToReproduce = steps + payload.expected = expected + payload.actual = actual + } + + try { + await api.post(`/boards/${boardSlug}/posts`, payload) + reset() + onSubmit?.() + } catch (e) { + setError('Failed to submit. Please try again.') + } finally { + setSubmitting(false) + } + } + + if (!expanded) { + return ( + + ) + } + + return ( +
+
+

+ New Post +

+ +
+ + {/* Type selector */} +
+ {(['feature', 'bug', 'general'] as PostType[]).map((t) => ( + + ))} +
+ + setTitle(e.target.value)} + /> + +