security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -30,7 +30,12 @@ COPY --from=builder /app/packages/web/dist packages/web/dist/
COPY --from=builder /app/node_modules node_modules/ COPY --from=builder /app/node_modules node_modules/
COPY --from=builder /app/echoboard.plugins.ts ./ COPY --from=builder /app/echoboard.plugins.ts ./
RUN addgroup -g 1001 echoboard && adduser -u 1001 -G echoboard -D echoboard && \
mkdir -p /app/packages/api/uploads && chown -R echoboard:echoboard /app
ENV NODE_ENV=production ENV NODE_ENV=production
EXPOSE 3000 EXPOSE 3000
USER echoboard
CMD ["sh", "-c", "npx prisma migrate deploy --schema=packages/api/prisma/schema.prisma && node packages/api/dist/index.js"] CMD ["sh", "-c", "npx prisma migrate deploy --schema=packages/api/prisma/schema.prisma && node packages/api/dist/index.js"]

View File

@@ -1,41 +1,32 @@
version: "3.8" name: echoboard
services: services:
app: app:
build: . build: .
ports: restart: unless-stopped
- "${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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
env_file: .env
environment:
DATABASE_URL: postgresql://echoboard:${POSTGRES_PASSWORD}@db:5432/echoboard
NODE_ENV: production
ports:
- "${PORT:-3000}:${PORT:-3000}"
volumes:
- ./uploads:/app/packages/api/uploads
db: db:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped
environment: environment:
POSTGRES_DB: echoboard
POSTGRES_USER: echoboard POSTGRES_USER: echoboard
POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: echoboard
volumes: volumes:
- pgdata:/var/lib/postgresql/data - ./data/postgres:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U echoboard"] test: ["CMD-SHELL", "pg_isready -U echoboard"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
volumes:
pgdata:

7507
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch --env-file=../../.env src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
@@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.0", "@fastify/cookie": "^11.0.0",
"@fastify/cors": "^10.0.0", "@fastify/cors": "^10.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.0.0", "@fastify/rate-limit": "^10.0.0",
"@fastify/static": "^8.0.0", "@fastify/static": "^8.0.0",
"@prisma/client": "^6.0.0", "@prisma/client": "^6.0.0",
@@ -34,6 +35,8 @@
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/jsonwebtoken": "^9.0.0", "@types/jsonwebtoken": "^9.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/rss": "^0.0.32",
"@types/web-push": "^3.6.0", "@types/web-push": "^3.6.0",
"prisma": "^6.0.0", "prisma": "^6.0.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",

View File

@@ -0,0 +1,270 @@
-- CreateEnum
CREATE TYPE "AuthMethod" AS ENUM ('COOKIE', 'PASSKEY');
-- CreateEnum
CREATE TYPE "PostType" AS ENUM ('FEATURE_REQUEST', 'BUG_REPORT');
-- CreateEnum
CREATE TYPE "PostStatus" AS ENUM ('OPEN', 'UNDER_REVIEW', 'PLANNED', 'IN_PROGRESS', 'DONE', 'DECLINED');
-- CreateTable
CREATE TABLE "Board" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"externalUrl" TEXT,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
"voteBudget" INTEGER NOT NULL DEFAULT 10,
"voteBudgetReset" TEXT NOT NULL DEFAULT 'monthly',
"lastBudgetReset" TIMESTAMP(3),
"allowMultiVote" BOOLEAN NOT NULL DEFAULT false,
"rssEnabled" BOOLEAN NOT NULL DEFAULT true,
"rssFeedCount" INTEGER NOT NULL DEFAULT 50,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Board_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"authMethod" "AuthMethod" NOT NULL DEFAULT 'COOKIE',
"tokenHash" TEXT,
"username" TEXT,
"usernameIdx" TEXT,
"displayName" TEXT,
"darkMode" TEXT NOT NULL DEFAULT 'system',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Passkey" (
"id" TEXT NOT NULL,
"credentialId" TEXT NOT NULL,
"credentialIdIdx" TEXT NOT NULL,
"credentialPublicKey" BYTEA NOT NULL,
"counter" BIGINT NOT NULL,
"credentialDeviceType" TEXT NOT NULL,
"credentialBackedUp" BOOLEAN NOT NULL,
"transports" TEXT,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Post" (
"id" TEXT NOT NULL,
"type" "PostType" NOT NULL,
"title" TEXT NOT NULL,
"description" JSONB NOT NULL,
"status" "PostStatus" NOT NULL DEFAULT 'OPEN',
"category" TEXT,
"voteCount" INTEGER NOT NULL DEFAULT 0,
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"boardId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StatusChange" (
"id" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"fromStatus" "PostStatus" NOT NULL,
"toStatus" "PostStatus" NOT NULL,
"changedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StatusChange_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"body" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Reaction" (
"id" TEXT NOT NULL,
"emoji" TEXT NOT NULL,
"commentId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Reaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Vote" (
"id" TEXT NOT NULL,
"weight" INTEGER NOT NULL DEFAULT 1,
"postId" TEXT NOT NULL,
"voterId" TEXT NOT NULL,
"budgetPeriod" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Vote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminUser" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminResponse" (
"id" TEXT NOT NULL,
"body" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"adminId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminResponse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ActivityEvent" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"boardId" TEXT NOT NULL,
"postId" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ActivityEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"endpointIdx" TEXT NOT NULL,
"keysP256dh" TEXT NOT NULL,
"keysAuth" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"boardId" TEXT,
"postId" TEXT,
"failureCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Board_slug_key" ON "Board"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "User_tokenHash_key" ON "User"("tokenHash");
-- CreateIndex
CREATE UNIQUE INDEX "User_usernameIdx_key" ON "User"("usernameIdx");
-- CreateIndex
CREATE UNIQUE INDEX "Passkey_credentialIdIdx_key" ON "Passkey"("credentialIdIdx");
-- CreateIndex
CREATE UNIQUE INDEX "Reaction_commentId_userId_emoji_key" ON "Reaction"("commentId", "userId", "emoji");
-- CreateIndex
CREATE UNIQUE INDEX "Vote_postId_voterId_key" ON "Vote"("postId", "voterId");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
-- CreateIndex
CREATE INDEX "ActivityEvent_boardId_createdAt_idx" ON "ActivityEvent"("boardId", "createdAt");
-- CreateIndex
CREATE INDEX "ActivityEvent_createdAt_idx" ON "ActivityEvent"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpointIdx_key" ON "PushSubscription"("endpointIdx");
-- CreateIndex
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
-- AddForeignKey
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StatusChange" ADD CONSTRAINT "StatusChange_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vote" ADD CONSTRAINT "Vote_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vote" ADD CONSTRAINT "Vote_voterId_fkey" FOREIGN KEY ("voterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AdminResponse" ADD CONSTRAINT "AdminResponse_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AdminResponse" ADD CONSTRAINT "AdminResponse_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityEvent" ADD CONSTRAINT "ActivityEvent_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityEvent" ADD CONSTRAINT "ActivityEvent_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "EditHistory" (
"id" TEXT NOT NULL,
"postId" TEXT,
"commentId" TEXT,
"editedBy" TEXT NOT NULL,
"previousTitle" TEXT,
"previousDescription" JSONB,
"previousBody" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EditHistory_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EditHistory_postId_createdAt_idx" ON "EditHistory"("postId", "createdAt");
-- CreateIndex
CREATE INDEX "EditHistory_commentId_createdAt_idx" ON "EditHistory"("commentId", "createdAt");
-- AddForeignKey
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_editedBy_fkey" FOREIGN KEY ("editedBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Post" ADD COLUMN "isEditLocked" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "EditHistory" ALTER COLUMN "editedBy" DROP NOT NULL;
-- DropForeignKey
ALTER TABLE "EditHistory" DROP CONSTRAINT "EditHistory_editedBy_fkey";
-- AddForeignKey
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_editedBy_fkey" FOREIGN KEY ("editedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,16 @@
CREATE TABLE "SiteSettings" (
"id" TEXT NOT NULL DEFAULT 'default',
"appName" TEXT NOT NULL DEFAULT 'Echoboard',
"logoUrl" TEXT,
"faviconUrl" TEXT,
"accentColor" TEXT NOT NULL DEFAULT '#F59E0B',
"headerFont" TEXT,
"bodyFont" TEXT,
"poweredByVisible" BOOLEAN NOT NULL DEFAULT true,
"customCss" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
);
INSERT INTO "SiteSettings" ("id", "updatedAt") VALUES ('default', NOW());

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -17,32 +17,46 @@ enum PostType {
BUG_REPORT BUG_REPORT
} }
enum PostStatus {
OPEN
UNDER_REVIEW
PLANNED
IN_PROGRESS
DONE
DECLINED
}
model Board { model Board {
id String @id @default(cuid()) id String @id @default(cuid())
slug String @unique slug String @unique
name String name String
description String? description String?
externalUrl String? externalUrl String?
iconName String?
iconColor String?
isArchived Boolean @default(false) isArchived Boolean @default(false)
voteBudget Int @default(10) voteBudget Int @default(10)
voteBudgetReset String @default("monthly") voteBudgetReset String @default("monthly")
lastBudgetReset DateTime? lastBudgetReset DateTime?
allowMultiVote Boolean @default(false) allowMultiVote Boolean @default(false)
rssEnabled Boolean @default(true)
rssFeedCount Int @default(50)
staleDays Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
posts Post[] posts Post[]
activityEvents ActivityEvent[] activityEvents ActivityEvent[]
pushSubscriptions PushSubscription[] pushSubscriptions PushSubscription[]
statusConfig BoardStatus[]
templates BoardTemplate[]
changelogEntries ChangelogEntry[]
}
model BoardStatus {
id String @id @default(cuid())
boardId String
status String
label String
color String
position Int @default(0)
enabled Boolean @default(true)
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
@@unique([boardId, status])
@@index([boardId, position])
} }
model User { model User {
@@ -52,16 +66,35 @@ model User {
username String? username String?
usernameIdx String? @unique usernameIdx String? @unique
displayName String? displayName String?
avatarPath String?
darkMode String @default("system") darkMode String @default("system")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
passkeys Passkey[] passkeys Passkey[]
posts Post[] posts Post[]
comments Comment[] comments Comment[]
reactions Reaction[] reactions Reaction[]
votes Vote[] votes Vote[]
notifications Notification[]
pushSubscriptions PushSubscription[] pushSubscriptions PushSubscription[]
attachments Attachment[]
adminLink AdminUser?
edits EditHistory[]
recoveryCode RecoveryCode?
}
model RecoveryCode {
id String @id @default(cuid())
codeHash String
phraseIdx String @unique
userId String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([expiresAt])
} }
model Passkey { model Passkey {
@@ -80,18 +113,26 @@ model Passkey {
} }
model Post { model Post {
id String @id @default(cuid()) id String @id @default(cuid())
type PostType type PostType
title String title String
description Json description Json
status PostStatus @default(OPEN) status String @default("OPEN")
category String? statusReason String?
voteCount Int @default(0) category String?
isPinned Boolean @default(false) templateId String?
boardId String voteCount Int @default(0)
authorId String viewCount Int @default(0)
createdAt DateTime @default(now()) isPinned Boolean @default(false)
updatedAt DateTime @updatedAt isEditLocked Boolean @default(false)
isThreadLocked Boolean @default(false)
isVotingLocked Boolean @default(false)
onBehalfOf String?
boardId String
authorId String
lastActivityAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade) author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@ -99,15 +140,23 @@ model Post {
comments Comment[] comments Comment[]
votes Vote[] votes Vote[]
adminResponses AdminResponse[] adminResponses AdminResponse[]
adminNotes AdminNote[]
notifications Notification[]
activityEvents ActivityEvent[] activityEvents ActivityEvent[]
pushSubscriptions PushSubscription[] pushSubscriptions PushSubscription[]
tags PostTag[]
attachments Attachment[]
editHistory EditHistory[]
@@index([boardId, status])
} }
model StatusChange { model StatusChange {
id String @id @default(cuid()) id String @id @default(cuid())
postId String postId String
fromStatus PostStatus fromStatus String
toStatus PostStatus toStatus String
reason String?
changedBy String changedBy String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -115,16 +164,28 @@ model StatusChange {
} }
model Comment { model Comment {
id String @id @default(cuid()) id String @id @default(cuid())
body String body String
postId String postId String
authorId String authorId String
createdAt DateTime @default(now()) replyToId String?
updatedAt DateTime @updatedAt isAdmin Boolean @default(false)
isEditLocked Boolean @default(false)
adminUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade) post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade) author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
reactions Reaction[] replyTo Comment? @relation("CommentReplies", fields: [replyToId], references: [id], onDelete: SetNull)
replies Comment[] @relation("CommentReplies")
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: SetNull)
reactions Reaction[]
attachments Attachment[]
editHistory EditHistory[]
@@index([replyToId])
@@index([postId, createdAt])
} }
model Reaction { model Reaction {
@@ -143,6 +204,7 @@ model Reaction {
model Vote { model Vote {
id String @id @default(cuid()) id String @id @default(cuid())
weight Int @default(1) weight Int @default(1)
importance String?
postId String postId String
voterId String voterId String
budgetPeriod String budgetPeriod String
@@ -154,13 +216,50 @@ model Vote {
@@unique([postId, voterId]) @@unique([postId, voterId])
} }
model AdminUser { enum TeamRole {
id String @id @default(cuid()) SUPER_ADMIN
email String @unique ADMIN
passwordHash String MODERATOR
createdAt DateTime @default(now()) }
responses AdminResponse[] model AdminUser {
id String @id @default(cuid())
email String? @unique
passwordHash String?
role TeamRole @default(ADMIN)
displayName String?
teamTitle String?
linkedUserId String? @unique
invitedById String?
createdAt DateTime @default(now())
linkedUser User? @relation(fields: [linkedUserId], references: [id], onDelete: SetNull)
invitedBy AdminUser? @relation("TeamInviter", fields: [invitedById], references: [id], onDelete: SetNull)
invitees AdminUser[] @relation("TeamInviter")
responses AdminResponse[]
notes AdminNote[]
comments Comment[]
createdInvites TeamInvite[] @relation("InviteCreator")
claimedInvite TeamInvite? @relation("InviteClaimer")
}
model TeamInvite {
id String @id @default(cuid())
tokenHash String @unique
role TeamRole
label String?
expiresAt DateTime
createdById String
claimedById String? @unique
claimedAt DateTime?
recoveryHash String?
recoveryIdx String?
createdAt DateTime @default(now())
createdBy AdminUser @relation("InviteCreator", fields: [createdById], references: [id], onDelete: Cascade)
claimedBy AdminUser? @relation("InviteClaimer", fields: [claimedById], references: [id], onDelete: SetNull)
@@index([expiresAt])
} }
model AdminResponse { model AdminResponse {
@@ -190,15 +289,16 @@ model ActivityEvent {
} }
model PushSubscription { model PushSubscription {
id String @id @default(cuid()) id String @id @default(cuid())
endpoint String endpoint String
endpointIdx String @unique endpointIdx String @unique
keysP256dh String keysP256dh String
keysAuth String keysAuth String
userId String userId String
boardId String? boardId String?
postId String? postId String?
createdAt DateTime @default(now()) failureCount Int @default(0)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade) board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
@@ -211,3 +311,151 @@ model Category {
slug String @unique slug String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
model Notification {
id String @id @default(cuid())
type String
title String
body String
postId String?
userId String
read Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
@@index([userId, read, createdAt])
}
model Webhook {
id String @id @default(cuid())
url String
secret String
events String[] @default(["status_changed", "post_created", "comment_added"])
active Boolean @default(true)
createdAt DateTime @default(now())
}
model PostMerge {
id String @id @default(cuid())
sourcePostId String
targetPostId String
mergedBy String
createdAt DateTime @default(now())
@@index([sourcePostId])
}
model ChangelogEntry {
id String @id @default(cuid())
title String
body String
boardId String?
publishedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
@@index([boardId, publishedAt])
}
model AdminNote {
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 Tag {
id String @id @default(cuid())
name String @unique
color String @default("#6366F1")
createdAt DateTime @default(now())
posts PostTag[]
}
model PostTag {
postId String
tagId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([postId, tagId])
}
model Attachment {
id String @id @default(cuid())
filename String
path String
size Int
mimeType String
postId String?
commentId String?
uploaderId String
createdAt DateTime @default(now())
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
uploader User @relation(fields: [uploaderId], references: [id], onDelete: Cascade)
}
model BoardTemplate {
id String @id @default(cuid())
boardId String
name String
fields Json
isDefault Boolean @default(false)
position Int @default(0)
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
@@index([boardId, position])
}
model SiteSettings {
id String @id @default("default")
appName String @default("Echoboard")
logoUrl String?
faviconUrl String?
accentColor String @default("#F59E0B")
headerFont String?
bodyFont String?
poweredByVisible Boolean @default(true)
customCss String?
updatedAt DateTime @updatedAt
}
model BlockedToken {
id String @id @default(cuid())
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
@@index([expiresAt])
}
model EditHistory {
id String @id @default(cuid())
postId String?
commentId String?
editedBy String?
previousTitle String?
previousDescription Json?
previousBody String?
createdAt DateTime @default(now())
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
editor User? @relation(fields: [editedBy], references: [id], onDelete: SetNull)
@@index([postId, createdAt])
@@index([commentId, createdAt])
}

1298
packages/api/prisma/seed.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -73,7 +73,7 @@ async function main() {
const hash = await bcrypt.hash(password, 12); const hash = await bcrypt.hash(password, 12);
const admin = await prisma.adminUser.create({ const admin = await prisma.adminUser.create({
data: { email, passwordHash: hash }, data: { email, passwordHash: hash, role: "SUPER_ADMIN" },
}); });
console.log(`Admin created: ${admin.email} (${admin.id})`); console.log(`Admin created: ${admin.email} (${admin.id})`);

View File

@@ -3,10 +3,11 @@ import { z } from "zod";
const schema = z.object({ const schema = z.object({
DATABASE_URL: z.string(), DATABASE_URL: z.string(),
APP_MASTER_KEY: z.string().regex(/^[0-9a-fA-F]{64}$/, "Must be hex-encoded 256-bit key"), 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"), APP_MASTER_KEY_PREVIOUS: z.string().regex(/^[0-9a-fA-F]{64}$/).optional(),
TOKEN_SECRET: z.string(), APP_BLIND_INDEX_KEY: z.string().regex(/^[0-9a-fA-F]{32,}$/, "Must be hex-encoded, at least 128 bits"),
JWT_SECRET: z.string(), TOKEN_SECRET: z.string().min(32),
ALTCHA_HMAC_KEY: z.string(), JWT_SECRET: z.string().min(32),
ALTCHA_HMAC_KEY: z.string().min(32, "ALTCHA HMAC key must be at least 32 characters"),
WEBAUTHN_RP_NAME: z.string().default("Echoboard"), WEBAUTHN_RP_NAME: z.string().default("Echoboard"),
WEBAUTHN_RP_ID: z.string(), WEBAUTHN_RP_ID: z.string(),
@@ -38,4 +39,7 @@ if (!parsed.success) {
export const config = parsed.data; export const config = parsed.data;
export const masterKey = Buffer.from(config.APP_MASTER_KEY, "hex"); export const masterKey = Buffer.from(config.APP_MASTER_KEY, "hex");
export const previousMasterKey = config.APP_MASTER_KEY_PREVIOUS
? Buffer.from(config.APP_MASTER_KEY_PREVIOUS, "hex")
: null;
export const blindIndexKey = Buffer.from(config.APP_BLIND_INDEX_KEY, "hex"); export const blindIndexKey = Buffer.from(config.APP_BLIND_INDEX_KEY, "hex");

View File

@@ -1,9 +1,12 @@
import cron from "node-cron"; import cron from "node-cron";
import { PrismaClient } from "@prisma/client"; import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import prisma from "../lib/prisma.js";
import { config } from "../config.js"; import { config } from "../config.js";
import { cleanExpiredChallenges } from "../routes/passkey.js"; import { cleanExpiredChallenges } from "../routes/passkey.js";
import { cleanupExpiredTokens } from "../lib/token-blocklist.js";
const prisma = new PrismaClient(); import { getPluginCronJobs } from "../plugins/loader.js";
import { cleanupViews } from "../lib/view-tracker.js";
export function startCronJobs() { export function startCronJobs() {
// prune old activity events - daily at 3am // prune old activity events - daily at 3am
@@ -38,23 +41,92 @@ export function startCronJobs() {
} }
}); });
// clean webauthn challenges - every 10 minutes // clean webauthn challenges - every minute
cron.schedule("*/10 * * * *", () => { cron.schedule("* * * * *", () => {
cleanExpiredChallenges(); cleanExpiredChallenges();
}); });
// remove failed push subscriptions - daily at 5am // clean expired recovery codes - daily at 3:30am
cron.schedule("0 5 * * *", async () => { cron.schedule("30 3 * * *", async () => {
// subscriptions with no associated user get cleaned by cascade const result = await prisma.recoveryCode.deleteMany({
// this handles any other stale ones where: { expiresAt: { lt: new Date() } },
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 30);
const result = await prisma.pushSubscription.deleteMany({
where: { createdAt: { lt: cutoff } },
}); });
if (result.count > 0) { if (result.count > 0) {
console.log(`Cleaned ${result.count} old push subscriptions`); console.log(`Cleaned ${result.count} expired recovery codes`);
} }
}); });
// clean expired blocked tokens - every 30 minutes
cron.schedule("*/30 * * * *", async () => {
const count = await cleanupExpiredTokens();
if (count > 0) {
console.log(`Cleaned ${count} expired blocked tokens`);
}
});
// remove push subscriptions with too many failures - daily at 5am
cron.schedule("0 5 * * *", async () => {
const result = await prisma.pushSubscription.deleteMany({
where: { failureCount: { gte: 3 } },
});
if (result.count > 0) {
console.log(`Cleaned ${result.count} failed push subscriptions`);
}
});
// clean orphaned attachments (uploaded but never linked) - hourly
cron.schedule("30 * * * *", async () => {
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
const orphans = await prisma.attachment.findMany({
where: {
postId: null,
commentId: null,
createdAt: { lt: cutoff },
},
select: { id: true, path: true },
});
if (orphans.length === 0) return;
const ids = orphans.map((a) => a.id);
const result = await prisma.attachment.deleteMany({
where: { id: { in: ids }, postId: null, commentId: null },
});
if (result.count > 0) {
console.log(`Cleaned ${result.count} orphaned attachments`);
}
for (const att of orphans) {
try {
await unlink(resolve(process.cwd(), "uploads", att.path));
} catch {}
}
});
// clean expired view-tracker entries - every 5 minutes
cron.schedule("*/5 * * * *", () => { cleanupViews(); });
// register plugin-provided cron jobs (min interval: every minute, reject sub-minute)
for (const job of getPluginCronJobs()) {
if (!cron.validate(job.schedule)) {
console.error(`Plugin cron "${job.name}" has invalid schedule: ${job.schedule}, skipping`);
continue;
}
// reject schedules with 6 fields (seconds) to prevent sub-minute execution
const fields = job.schedule.trim().split(/\s+/);
if (fields.length > 5) {
console.error(`Plugin cron "${job.name}" uses sub-minute schedule, skipping`);
continue;
}
cron.schedule(job.schedule, async () => {
try {
await job.handler();
} catch (err) {
console.error(`Plugin cron job "${job.name}" failed:`, err);
}
});
console.log(`Registered plugin cron: ${job.name} (${job.schedule})`);
}
} }

View File

@@ -1,11 +1,27 @@
import prisma from "./lib/prisma.js";
import { createServer } from "./server.js"; import { createServer } from "./server.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { startCronJobs } from "./cron/index.js"; import { startCronJobs } from "./cron/index.js";
import { reEncryptIfNeeded } from "./services/key-rotation.js";
import { validateManifest } from "./services/manifest-validator.js";
async function main() { async function main() {
validateManifest();
// ensure pg_trgm extension exists for similarity search
await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_trgm`;
// search indexes for fuzzy + full-text search
await Promise.all([
prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_post_title_trgm ON "Post" USING gin (title gin_trgm_ops)`),
prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_post_title_fts ON "Post" USING gin (to_tsvector('english', title))`),
prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_board_name_trgm ON "Board" USING gin (name gin_trgm_ops)`),
]);
const app = await createServer(); const app = await createServer();
startCronJobs(); startCronJobs();
reEncryptIfNeeded().catch((err) => console.error("Key rotation error:", err));
try { try {
await app.listen({ port: config.PORT, host: "0.0.0.0" }); await app.listen({ port: config.PORT, host: "0.0.0.0" });

View File

@@ -1,6 +1,4 @@
import { PrismaClient } from "@prisma/client"; import prisma from "./prisma.js";
const prisma = new PrismaClient();
export function getCurrentPeriod(resetSchedule: string): string { export function getCurrentPeriod(resetSchedule: string): string {
const now = new Date(); const now = new Date();
@@ -9,17 +7,27 @@ export function getCurrentPeriod(resetSchedule: string): string {
switch (resetSchedule) { switch (resetSchedule) {
case "weekly": { case "weekly": {
const startOfYear = new Date(year, 0, 1); // ISO 8601 week number
const days = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000); const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
const week = Math.ceil((days + startOfYear.getDay() + 1) / 7); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
return `${year}-W${String(week).padStart(2, "0")}`; const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}
case "biweekly": {
const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
const biweek = Math.ceil(week / 2);
return `${d.getUTCFullYear()}-BW${String(biweek).padStart(2, "0")}`;
} }
case "quarterly": { case "quarterly": {
const q = Math.ceil((now.getMonth() + 1) / 3); const q = Math.ceil((now.getMonth() + 1) / 3);
return `${year}-Q${q}`; return `${year}-Q${q}`;
} }
case "yearly": case "per_release":
return `${year}`; return "per_release";
case "never": case "never":
return "lifetime"; return "lifetime";
case "monthly": case "monthly":
@@ -28,17 +36,25 @@ export function getCurrentPeriod(resetSchedule: string): string {
} }
} }
export async function getRemainingBudget(userId: string, boardId: string): Promise<number> { export async function getRemainingBudget(userId: string, boardId: string, db: any = prisma): Promise<number> {
const board = await prisma.board.findUnique({ where: { id: boardId } }); const board = await db.board.findUnique({ where: { id: boardId } });
if (!board) return 0; if (!board) return 0;
if (board.voteBudgetReset === "never" && board.voteBudget === 0) { if (board.voteBudgetReset === "never" && board.voteBudget === 0) {
return Infinity; return Infinity;
} }
const period = getCurrentPeriod(board.voteBudgetReset); // per_release uses the timestamp of the last manual reset as the period key
let period: string;
if (board.voteBudgetReset === "per_release") {
period = board.lastBudgetReset
? `release-${board.lastBudgetReset.toISOString()}`
: "release-initial";
} else {
period = getCurrentPeriod(board.voteBudgetReset);
}
const used = await prisma.vote.aggregate({ const used = await db.vote.aggregate({
where: { voterId: userId, post: { boardId }, budgetPeriod: period }, where: { voterId: userId, post: { boardId }, budgetPeriod: period },
_sum: { weight: true }, _sum: { weight: true },
}); });
@@ -53,7 +69,16 @@ export function getNextResetDate(resetSchedule: string): Date {
switch (resetSchedule) { switch (resetSchedule) {
case "weekly": { case "weekly": {
const d = new Date(now); const d = new Date(now);
d.setDate(d.getDate() + (7 - d.getDay())); const daysUntilMonday = ((8 - d.getDay()) % 7) || 7;
d.setDate(d.getDate() + daysUntilMonday);
d.setHours(0, 0, 0, 0);
return d;
}
case "biweekly": {
const d = new Date(now);
const dayOfWeek = d.getDay();
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
d.setDate(d.getDate() + daysUntilMonday + 7);
d.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0);
return d; return d;
} }
@@ -61,10 +86,11 @@ export function getNextResetDate(resetSchedule: string): Date {
const q = Math.ceil((now.getMonth() + 1) / 3); const q = Math.ceil((now.getMonth() + 1) / 3);
return new Date(now.getFullYear(), q * 3, 1); return new Date(now.getFullYear(), q * 3, 1);
} }
case "yearly": case "per_release":
return new Date(now.getFullYear() + 1, 0, 1); // manual reset only - no automatic next date
return new Date(8640000000000000);
case "never": case "never":
return new Date(8640000000000000); // max date return new Date(8640000000000000);
case "monthly": case "monthly":
default: { default: {
const d = new Date(now.getFullYear(), now.getMonth() + 1, 1); const d = new Date(now.getFullYear(), now.getMonth() + 1, 1);

View File

@@ -0,0 +1,158 @@
import { PrismaClient } from "@prisma/client";
interface TemplateField {
key: string;
label: string;
type: "text" | "textarea" | "select";
required: boolean;
placeholder?: string;
options?: string[];
}
interface TemplateDef {
name: string;
fields: TemplateField[];
isDefault: boolean;
position: number;
}
const BUG_TEMPLATES: TemplateDef[] = [
{
name: "Bug Report",
isDefault: true,
position: 0,
fields: [
{ key: "steps_to_reproduce", label: "Steps to reproduce", type: "textarea", required: true, placeholder: "1. Go to...\n2. Click on...\n3. See error" },
{ key: "expected_behavior", label: "Expected behavior", type: "textarea", required: true, placeholder: "What should have happened?" },
{ key: "actual_behavior", label: "Actual behavior", type: "textarea", required: true, placeholder: "What happened instead?" },
{ key: "environment", label: "Environment", type: "text", required: false, placeholder: "e.g. Chrome 120, Windows 11" },
{ key: "severity", label: "Severity", type: "select", required: true, options: ["Critical", "Major", "Minor", "Cosmetic"] },
],
},
{
name: "UI/Visual Bug",
isDefault: false,
position: 1,
fields: [
{ key: "what_looks_wrong", label: "What looks wrong", type: "textarea", required: true, placeholder: "Describe the visual issue" },
{ key: "where_in_app", label: "Where in the app", type: "text", required: true, placeholder: "Page or screen name" },
{ key: "browser_device", label: "Browser and device", type: "text", required: true, placeholder: "e.g. Safari on iPhone 15" },
{ key: "screen_size", label: "Screen size", type: "text", required: false, placeholder: "e.g. 1920x1080 or mobile" },
],
},
{
name: "Performance Issue",
isDefault: false,
position: 2,
fields: [
{ key: "whats_slow", label: "What's slow or laggy", type: "textarea", required: true },
{ key: "when_it_happens", label: "When does it happen", type: "textarea", required: true, placeholder: "Always, sometimes, under specific conditions..." },
{ key: "how_long", label: "How long does it take", type: "text", required: false, placeholder: "e.g. 10+ seconds to load" },
{ key: "device_network", label: "Device and network", type: "text", required: false, placeholder: "e.g. MacBook Pro, WiFi" },
],
},
{
name: "Crash/Error Report",
isDefault: false,
position: 3,
fields: [
{ key: "what_happened", label: "What happened", type: "textarea", required: true },
{ key: "error_message", label: "Error message", type: "textarea", required: false, placeholder: "Copy the error text if visible" },
{ key: "steps_before_crash", label: "Steps before the crash", type: "textarea", required: true },
{ key: "frequency", label: "How often does it happen", type: "select", required: true, options: ["Every time", "Often", "Sometimes", "Once"] },
],
},
{
name: "Data Issue",
isDefault: false,
position: 4,
fields: [
{ key: "what_data_is_wrong", label: "What data is wrong", type: "textarea", required: true },
{ key: "where_you_see_it", label: "Where do you see it", type: "text", required: true, placeholder: "Page, section, or API endpoint" },
{ key: "what_it_should_be", label: "What it should be", type: "textarea", required: true },
{ key: "impact", label: "Impact", type: "select", required: true, options: ["Blocking", "Major", "Minor"] },
],
},
];
const FEATURE_TEMPLATES: TemplateDef[] = [
{
name: "Feature Request",
isDefault: false,
position: 5,
fields: [
{ key: "use_case", label: "Use case", type: "textarea", required: true, placeholder: "What problem would this solve?" },
{ key: "proposed_solution", label: "Proposed solution", type: "textarea", required: true, placeholder: "How do you imagine this working?" },
{ key: "alternatives", label: "Alternatives considered", type: "textarea", required: false },
{ key: "who_benefits", label: "Who benefits", type: "text", required: false, placeholder: "e.g. All users, admins, new users" },
],
},
{
name: "UX Improvement",
isDefault: false,
position: 6,
fields: [
{ key: "whats_confusing", label: "What's confusing or difficult", type: "textarea", required: true },
{ key: "how_should_it_work", label: "How should it work instead", type: "textarea", required: true },
{ key: "who_runs_into_this", label: "Who runs into this", type: "text", required: false, placeholder: "e.g. New users, power users" },
{ key: "frequency", label: "How often does this come up", type: "select", required: false, options: ["Daily", "Weekly", "Occasionally", "Rarely"] },
],
},
{
name: "Integration Request",
isDefault: false,
position: 7,
fields: [
{ key: "tool_or_service", label: "Tool or service", type: "text", required: true, placeholder: "e.g. Slack, Jira, GitHub" },
{ key: "what_it_should_do", label: "What it should do", type: "textarea", required: true, placeholder: "Describe the integration behavior" },
{ key: "current_workaround", label: "Current workaround", type: "textarea", required: false },
{ key: "priority", label: "Priority", type: "select", required: true, options: ["Must have", "Should have", "Nice to have"] },
],
},
{
name: "Content Change",
isDefault: false,
position: 8,
fields: [
{ key: "where", label: "Where is it", type: "text", required: true, placeholder: "Page, section, or URL" },
{ key: "current_text", label: "Current text", type: "textarea", required: true },
{ key: "suggested_text", label: "Suggested text", type: "textarea", required: true },
{ key: "why_change", label: "Why change it", type: "textarea", required: false },
],
},
{
name: "Workflow Improvement",
isDefault: false,
position: 9,
fields: [
{ key: "current_workflow", label: "Current workflow", type: "textarea", required: true, placeholder: "Describe the steps you take today" },
{ key: "pain_point", label: "Pain point", type: "textarea", required: true, placeholder: "What's slow, repetitive, or error-prone?" },
{ key: "ideal_workflow", label: "Ideal workflow", type: "textarea", required: true, placeholder: "How should it work?" },
{ key: "impact", label: "Impact", type: "select", required: false, options: ["Saves significant time", "Reduces errors", "Improves quality", "Minor convenience"] },
],
},
];
export const DEFAULT_TEMPLATES = [...BUG_TEMPLATES, ...FEATURE_TEMPLATES];
export async function seedTemplatesForBoard(prisma: PrismaClient, boardId: string) {
const existing = await prisma.boardTemplate.count({ where: { boardId } });
if (existing > 0) return;
await prisma.boardTemplate.createMany({
data: DEFAULT_TEMPLATES.map((t) => ({
boardId,
name: t.name,
fields: t.fields as any,
isDefault: t.isDefault,
position: t.position,
})),
});
}
export async function seedAllBoardTemplates(prisma: PrismaClient) {
const boards = await prisma.board.findMany({ select: { id: true } });
for (const board of boards) {
await seedTemplatesForBoard(prisma, board.id);
}
}

View File

@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
export default prisma;

View File

@@ -0,0 +1,30 @@
import crypto from "crypto";
import prisma from "./prisma.js";
function hashForBlock(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function blockToken(token: string, ttlMs: number = 25 * 60 * 60 * 1000): Promise<void> {
const tokenHash = hashForBlock(token);
const expiresAt = new Date(Date.now() + ttlMs);
await prisma.blockedToken.upsert({
where: { tokenHash },
create: { tokenHash, expiresAt },
update: { expiresAt },
});
}
export async function isTokenBlocked(token: string): Promise<boolean> {
const tokenHash = hashForBlock(token);
const entry = await prisma.blockedToken.findUnique({ where: { tokenHash } });
if (!entry) return false;
return entry.expiresAt > new Date();
}
export async function cleanupExpiredTokens(): Promise<number> {
const result = await prisma.blockedToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}

View File

@@ -0,0 +1,25 @@
const MAX_ENTRIES = 50000;
const seen = new Map<string, number>();
export function shouldCount(postId: string, identifier: string): boolean {
const key = `${postId}:${identifier}`;
const expiry = seen.get(key);
if (expiry && expiry > Date.now()) return false;
if (seen.size >= MAX_ENTRIES) {
const now = Date.now();
for (const [k, v] of seen) {
if (v < now) seen.delete(k);
if (seen.size < MAX_ENTRIES * 0.8) break;
}
if (seen.size >= MAX_ENTRIES) return false;
}
seen.set(key, Date.now() + 15 * 60 * 1000);
return true;
}
export function cleanupViews(): void {
const now = Date.now();
for (const [k, v] of seen) {
if (v < now) seen.delete(k);
}
}

View File

@@ -0,0 +1,46 @@
import { randomInt } from "node:crypto";
// 256 common, unambiguous English words (4-7 letters)
// 256^6 = ~2.8 * 10^14 combinations - strong enough with rate limiting + ALTCHA
const WORDS = [
"acre", "alps", "arch", "army", "atom", "aura", "axis", "balm",
"band", "bark", "barn", "base", "bath", "beam", "bell", "belt",
"bend", "bird", "bite", "blow", "blur", "boat", "bold", "bolt",
"bond", "bone", "book", "bore", "boss", "bowl", "brim", "bulb",
"bulk", "burn", "bush", "buzz", "cafe", "cage", "calm", "camp",
"cape", "card", "cart", "case", "cast", "cave", "cell", "chat",
"chip", "city", "clam", "clan", "claw", "clay", "clip", "club",
"clue", "coal", "coat", "code", "coil", "coin", "cold", "cone",
"cook", "cool", "cope", "cord", "core", "cork", "corn", "cost",
"cove", "crew", "crop", "crow", "cube", "curb", "cure", "curl",
"dale", "dare", "dart", "dash", "dawn", "deck", "deer", "deli",
"demo", "dent", "desk", "dime", "disc", "dock", "dome", "door",
"dose", "dove", "draw", "drum", "dune", "dusk", "dust", "edge",
"emit", "epic", "exit", "face", "fact", "fame", "fawn", "felt",
"fern", "film", "find", "fire", "firm", "fish", "fist", "flag",
"flak", "flaw", "flex", "flip", "flow", "flux", "foam", "foil",
"fold", "folk", "font", "ford", "fork", "form", "fort", "frog",
"fuel", "fume", "fund", "fury", "fuse", "gale", "game", "gang",
"gate", "gaze", "gear", "germ", "gift", "gist", "glen", "glow",
"glue", "gold", "golf", "gong", "grab", "gram", "grid", "grin",
"grip", "grit", "gulf", "gust", "hail", "half", "hall", "halt",
"hare", "harp", "hawk", "haze", "heap", "helm", "herb", "herd",
"hero", "hike", "hill", "hint", "hive", "hold", "hole", "hood",
"hook", "hope", "horn", "host", "howl", "hull", "hunt", "husk",
"iris", "iron", "isle", "jade", "jazz", "jest", "jolt", "jump",
"jury", "keen", "kelp", "kite", "knob", "knot", "lace", "lake",
"lamb", "lamp", "lane", "lark", "lava", "lawn", "lead", "leaf",
"lens", "lien", "lime", "line", "link", "lion", "lock", "loft",
"loop", "loom", "lore", "luck", "lure", "lynx", "malt", "mane",
"maze", "mesa", "mild", "mill", "mine", "mint", "mist", "moat",
"mode", "mold", "monk", "moon", "moor", "moss", "myth", "navy",
"nest", "node", "norm", "note", "nova", "oaks", "opal", "orca",
];
export function generateRecoveryPhrase(): string {
const words: string[] = [];
for (let i = 0; i < 6; i++) {
words.push(WORDS[randomInt(WORDS.length)]);
}
return words.join("-");
}

View File

@@ -1,27 +1,33 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import fp from "fastify-plugin"; import fp from "fastify-plugin";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { PrismaClient, User } from "@prisma/client"; import type { User } from "@prisma/client";
import prisma from "../lib/prisma.js";
import { hashToken } from "../services/encryption.js"; import { hashToken } from "../services/encryption.js";
import { config } from "../config.js"; import { config } from "../config.js";
import { isTokenBlocked } from "../lib/token-blocklist.js";
declare module "fastify" { declare module "fastify" {
interface FastifyRequest { interface FastifyRequest {
user?: User; user?: User;
adminId?: string; adminId?: string;
adminRole?: string;
} }
} }
const prisma = new PrismaClient();
async function authPlugin(app: FastifyInstance) { async function authPlugin(app: FastifyInstance) {
app.decorateRequest("user", undefined); app.decorateRequest("user", undefined);
app.decorateRequest("adminId", undefined); app.decorateRequest("adminId", undefined);
app.decorateRequest("adminRole", undefined);
app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => { app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => {
// try cookie auth first // try cookie auth first
const token = req.cookies?.echoboard_token; const token = req.cookies?.echoboard_token;
if (token) { if (token) {
if (await isTokenBlocked(token)) {
reply.status(401).send({ error: "Not authenticated" });
return;
}
const hash = hashToken(token); const hash = hashToken(token);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } }); const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) { if (user) {
@@ -30,11 +36,15 @@ async function authPlugin(app: FastifyInstance) {
} }
} }
// try bearer token (passkey sessions) // try passkey session cookie, then fall back to Authorization header
const authHeader = req.headers.authorization; const passkeyToken = req.cookies?.echoboard_passkey
if (authHeader?.startsWith("Bearer ")) { || (req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: null);
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
try { try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string }; const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "passkey") { if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } }); const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) { if (user) {
@@ -53,15 +63,23 @@ async function authPlugin(app: FastifyInstance) {
app.decorate("optionalUser", async (req: FastifyRequest) => { app.decorate("optionalUser", async (req: FastifyRequest) => {
const token = req.cookies?.echoboard_token; const token = req.cookies?.echoboard_token;
if (token) { if (token) {
if (await isTokenBlocked(token)) return;
const hash = hashToken(token); const hash = hashToken(token);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } }); const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) req.user = user; if (user) {
return; req.user = user;
return;
}
} }
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) { const passkeyToken = req.cookies?.echoboard_passkey
|| (req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: null);
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
try { try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string }; const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "passkey") { if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } }); const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) req.user = user; if (user) req.user = user;
@@ -72,22 +90,103 @@ async function authPlugin(app: FastifyInstance) {
} }
}); });
app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => { app.decorate("optionalAdmin", async (req: FastifyRequest) => {
const authHeader = req.headers.authorization; const token = req.cookies?.echoboard_admin;
if (!authHeader?.startsWith("Bearer ")) { if (token && !(await isTokenBlocked(token))) {
reply.status(401).send({ error: "Admin token required" }); try {
return; const decoded = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:admin', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "admin") {
const admin = await prisma.adminUser.findUnique({ where: { id: decoded.sub }, select: { id: true, role: true } });
if (admin) {
req.adminId = decoded.sub;
req.adminRole = admin.role;
return;
}
}
} catch {}
} }
try { // fallback: check if authenticated user is a linked team member
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string }; if (req.user) {
if (decoded.type !== "admin") { const admin = await prisma.adminUser.findUnique({
reply.status(403).send({ error: "Admin access required" }); where: { linkedUserId: req.user.id },
select: { id: true, role: true },
});
if (admin) {
req.adminId = admin.id;
req.adminRole = admin.role;
}
}
});
app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => {
// try admin JWT cookie first (super admin login flow)
const token = req.cookies?.echoboard_admin ?? null;
if (token && !(await isTokenBlocked(token))) {
try {
const decoded = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:admin', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "admin") {
const admin = await prisma.adminUser.findUnique({ where: { id: decoded.sub }, select: { id: true, role: true } });
if (admin) {
req.adminId = admin.id;
req.adminRole = admin.role;
return;
}
}
} catch {}
}
// fallback: check if user is authenticated via cookie and linked to an admin
const cookieToken = req.cookies?.echoboard_token;
if (cookieToken && !(await isTokenBlocked(cookieToken))) {
const hash = hashToken(cookieToken);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) {
const admin = await prisma.adminUser.findUnique({
where: { linkedUserId: user.id },
select: { id: true, role: true },
});
if (admin) {
req.user = user;
req.adminId = admin.id;
req.adminRole = admin.role;
return;
}
}
}
// try passkey token
const passkeyToken = req.cookies?.echoboard_passkey
|| (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
try {
const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) {
const admin = await prisma.adminUser.findUnique({
where: { linkedUserId: user.id },
select: { id: true, role: true },
});
if (admin) {
req.user = user;
req.adminId = admin.id;
req.adminRole = admin.role;
return;
}
}
}
} catch {}
}
reply.status(401).send({ error: "Unauthorized" });
});
app.decorate("requireRole", (...roles: string[]) => {
return async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.adminId || !req.adminRole || !roles.includes(req.adminRole)) {
reply.status(403).send({ error: "Insufficient permissions" });
return; return;
} }
req.adminId = decoded.sub; };
} catch {
reply.status(401).send({ error: "Invalid admin token" });
}
}); });
} }
@@ -95,7 +194,9 @@ declare module "fastify" {
interface FastifyInstance { interface FastifyInstance {
requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise<void>; requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
optionalUser: (req: FastifyRequest) => Promise<void>; optionalUser: (req: FastifyRequest) => Promise<void>;
optionalAdmin: (req: FastifyRequest) => Promise<void>;
requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>; requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
requireRole: (...roles: string[]) => (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
} }
} }

View File

@@ -2,26 +2,51 @@ import { FastifyInstance } from "fastify";
import fp from "fastify-plugin"; import fp from "fastify-plugin";
async function securityPlugin(app: FastifyInstance) { async function securityPlugin(app: FastifyInstance) {
app.addHook("onSend", async (_req, reply) => { app.addHook("onSend", async (req, reply) => {
reply.header("Content-Security-Policy", [ const isEmbed = req.url.startsWith("/api/v1/embed/") || req.url.startsWith("/embed/");
"default-src 'self'",
"script-src 'self'", if (isEmbed) {
"style-src 'self' 'unsafe-inline'", // embed routes need to be frameable by third-party sites
"img-src 'self' data:", reply.header("Content-Security-Policy", [
"font-src 'self'", "default-src 'self'",
"connect-src 'self'", "script-src 'self'",
"frame-ancestors 'none'", "style-src 'self' 'unsafe-inline'",
"base-uri 'self'", "img-src 'self' data:",
"form-action 'self'", "font-src 'self'",
].join("; ")); "connect-src 'self'",
"frame-src 'none'",
"object-src 'none'",
"frame-ancestors *",
"base-uri 'self'",
"form-action 'none'",
].join("; "));
reply.header("Cross-Origin-Resource-Policy", "cross-origin");
reply.header("Access-Control-Allow-Origin", "*");
reply.header("Access-Control-Allow-Credentials", "false");
} else {
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-src 'none'",
"object-src 'none'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "));
reply.header("X-Frame-Options", "DENY");
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
}
reply.header("Referrer-Policy", "no-referrer"); reply.header("Referrer-Policy", "no-referrer");
reply.header("X-Content-Type-Options", "nosniff"); reply.header("X-Content-Type-Options", "nosniff");
reply.header("X-Frame-Options", "DENY"); reply.header("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
reply.header("X-DNS-Prefetch-Control", "off"); reply.header("X-DNS-Prefetch-Control", "off");
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
}); });
} }

View File

@@ -1,17 +1,46 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { PluginManifest, EchoboardPlugin } from "./types.js"; import { PluginManifest, EchoboardPlugin } from "./types.js";
const loadedPlugins: EchoboardPlugin[] = [];
export async function loadPlugins(app: FastifyInstance) { export async function loadPlugins(app: FastifyInstance) {
const manifestPath = resolve(process.cwd(), "echoboard.plugins.json"); // try dynamic import of echoboard.plugins.ts (compiled to .js in production)
const tsPath = resolve(process.cwd(), "echoboard.plugins.js");
const tsSourcePath = resolve(process.cwd(), "echoboard.plugins.ts");
// The plugin directory must be write-protected in production to prevent
// unauthorized code from being loaded via this path.
if (existsSync(tsPath) || existsSync(tsSourcePath)) {
try {
const modPath = existsSync(tsPath) ? tsPath : tsSourcePath;
console.warn(`[plugins] loading plugin file: ${modPath}`);
const mod = await import(pathToFileURL(modPath).href);
const plugins: EchoboardPlugin[] = mod.plugins ?? mod.default?.plugins ?? [];
for (const plugin of plugins) {
app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`);
await plugin.onRegister(app, {});
loadedPlugins.push(plugin);
}
return;
} catch (err) {
app.log.error(`Failed to load plugins from echoboard.plugins: ${err}`);
}
}
// fallback: JSON manifest
const jsonPath = resolve(process.cwd(), "echoboard.plugins.json");
let manifest: PluginManifest; let manifest: PluginManifest;
try { try {
const raw = await readFile(manifestPath, "utf-8"); const raw = await readFile(jsonPath, "utf-8");
manifest = JSON.parse(raw); manifest = JSON.parse(raw);
} catch { } catch {
app.log.info("No plugin manifest found, skipping plugin loading"); app.log.info("No plugin manifest found, running without plugins");
return; return;
} }
@@ -20,13 +49,72 @@ export async function loadPlugins(app: FastifyInstance) {
for (const entry of manifest.plugins) { for (const entry of manifest.plugins) {
if (!entry.enabled) continue; if (!entry.enabled) continue;
// reject package names that look like paths or URLs to prevent arbitrary code loading
if (/[\/\\]|^\./.test(entry.name) || /^https?:/.test(entry.name)) {
app.log.error(`Skipping plugin "${entry.name}": paths and URLs not allowed in JSON manifest`);
continue;
}
// only allow scoped or plain npm package names
if (!/^(@[a-z0-9-]+\/)?[a-z0-9-]+$/.test(entry.name)) {
app.log.error(`Skipping plugin "${entry.name}": invalid package name`);
continue;
}
try { try {
const mod = await import(entry.name) as { default: EchoboardPlugin }; const mod = await import(entry.name) as { default: EchoboardPlugin };
const plugin = mod.default; const plugin = mod.default;
app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`); app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`);
await plugin.register(app, entry.config ?? {}); await plugin.onRegister(app, entry.config ?? {});
loadedPlugins.push(plugin);
} catch (err) { } catch (err) {
app.log.error(`Failed to load plugin ${entry.name}: ${err}`); app.log.error(`Failed to load plugin ${entry.name}: ${err}`);
} }
} }
} }
export async function startupPlugins() {
for (const plugin of loadedPlugins) {
if (plugin.onStartup) {
await plugin.onStartup();
}
}
}
export async function shutdownPlugins() {
for (const plugin of loadedPlugins) {
if (plugin.onShutdown) {
await plugin.onShutdown();
}
}
}
export function getPluginCronJobs() {
return loadedPlugins.flatMap((p) => p.getCronJobs?.() ?? []);
}
export function getPluginAdminRoutes() {
return loadedPlugins.flatMap((p) => p.getAdminRoutes?.() ?? []);
}
export function getPluginComponents() {
const merged: Record<string, (() => unknown)[]> = {};
for (const plugin of loadedPlugins) {
const comps = plugin.getFrontendComponents?.();
if (!comps) continue;
for (const [slot, comp] of Object.entries(comps)) {
if (!merged[slot]) merged[slot] = [];
merged[slot].push(comp);
}
}
return merged;
}
export function getActivePluginInfo() {
return loadedPlugins.map((p) => ({
name: p.name,
version: p.version,
adminRoutes: p.getAdminRoutes?.() ?? [],
slots: Object.keys(p.getFrontendComponents?.() ?? {}),
hasBoardSource: !!p.getBoardSource,
}));
}

View File

@@ -1,9 +1,58 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
export interface AdminRoute {
path: string;
label: string;
component: string;
}
export interface CronJobDefinition {
name: string;
schedule: string;
handler: () => Promise<void>;
}
export interface BoardSource {
name: string;
fetchBoards: () => Promise<{ slug: string; name: string; description?: string; externalUrl?: string }[]>;
}
export interface ComponentMap {
[slotName: string]: () => unknown;
}
export interface PrismaMigration {
name: string;
sql: string;
}
export interface PrismaModelDefinition {
name: string;
schema: string;
}
export interface EchoboardPlugin { export interface EchoboardPlugin {
name: string; name: string;
version: string; version: string;
register: (app: FastifyInstance, config: Record<string, unknown>) => Promise<void>;
// Lifecycle
onRegister(app: FastifyInstance, config: Record<string, unknown>): void | Promise<void>;
onStartup?(): Promise<void>;
onShutdown?(): Promise<void>;
// Database
getMigrations?(): PrismaMigration[];
getModels?(): PrismaModelDefinition[];
// UI
getAdminRoutes?(): AdminRoute[];
getFrontendComponents?(): ComponentMap;
// Scheduled tasks
getCronJobs?(): CronJobDefinition[];
// Board creation
getBoardSource?(): BoardSource;
} }
export interface PluginConfig { export interface PluginConfig {

View File

@@ -1,19 +1,24 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient, Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import prisma from "../lib/prisma.js";
const prisma = new PrismaClient(); const VALID_EVENT_TYPES = [
"post_created", "admin_responded", "comment_created", "comment_edited",
"vote_cast", "vote_milestone", "status_changed", "post_deleted",
] as const;
const querySchema = z.object({ const querySchema = z.object({
board: z.string().optional(), board: z.string().optional(),
type: z.string().optional(), type: z.enum(VALID_EVENT_TYPES).optional(),
page: z.coerce.number().min(1).default(1), page: z.coerce.number().int().min(1).max(500).default(1),
limit: z.coerce.number().min(1).max(100).default(30), limit: z.coerce.number().int().min(1).max(100).default(30),
}); });
export default async function activityRoutes(app: FastifyInstance) { export default async function activityRoutes(app: FastifyInstance) {
app.get<{ Querystring: Record<string, string> }>( app.get<{ Querystring: Record<string, string> }>(
"/activity", "/activity",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const q = querySchema.parse(req.query); const q = querySchema.parse(req.query);

View File

@@ -1,42 +1,156 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { z } from "zod"; import { z } from "zod";
import { config } from "../../config.js"; import { config } from "../../config.js";
import { encrypt, decrypt } from "../../services/encryption.js";
import { masterKey } from "../../config.js";
import { blockToken } from "../../lib/token-blocklist.js";
import { prisma } from "../../lib/prisma.js";
const prisma = new PrismaClient(); const DUMMY_HASH = bcrypt.hashSync("__timing_safe__", 12);
const failedAttempts = new Map<string, { count: number; lastAttempt: number }>();
setInterval(() => {
const cutoff = Date.now() - 15 * 60 * 1000;
for (const [k, v] of failedAttempts) {
if (v.lastAttempt < cutoff) failedAttempts.delete(k);
}
}, 60 * 1000);
const loginBody = z.object({ const loginBody = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(1), password: z.string().min(1),
}); });
async function ensureLinkedUser(adminId: string): Promise<string> {
return prisma.$transaction(async (tx) => {
const admin = await tx.adminUser.findUnique({ where: { id: adminId } });
if (admin?.linkedUserId) return admin.linkedUserId;
const displayName = encrypt("Admin", masterKey);
const user = await tx.user.create({
data: { displayName, authMethod: "COOKIE" },
});
await tx.adminUser.update({
where: { id: adminId },
data: { linkedUserId: user.id },
});
return user.id;
}, { isolationLevel: "Serializable" });
}
export default async function adminAuthRoutes(app: FastifyInstance) { export default async function adminAuthRoutes(app: FastifyInstance) {
app.post<{ Body: z.infer<typeof loginBody> }>( app.post<{ Body: z.infer<typeof loginBody> }>(
"/admin/login", "/admin/login",
{ config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } },
async (req, reply) => { async (req, reply) => {
const body = loginBody.parse(req.body); const body = loginBody.parse(req.body);
const admin = await prisma.adminUser.findUnique({ where: { email: body.email } }); const admin = await prisma.adminUser.findUnique({ where: { email: body.email } });
if (!admin) { const valid = await bcrypt.compare(body.password, admin?.passwordHash ?? DUMMY_HASH);
if (!admin || !valid) {
const bruteKey = `${req.ip}:${body.email.toLowerCase()}`;
if (!failedAttempts.has(bruteKey) && failedAttempts.size > 10000) {
const sorted = [...failedAttempts.entries()].sort((a, b) => a[1].lastAttempt - b[1].lastAttempt);
for (let i = 0; i < sorted.length / 2; i++) failedAttempts.delete(sorted[i][0]);
}
const entry = failedAttempts.get(bruteKey) || { count: 0, lastAttempt: 0 };
entry.count++;
entry.lastAttempt = Date.now();
failedAttempts.set(bruteKey, entry);
const delay = Math.min(100 * Math.pow(2, entry.count - 1), 10000);
await new Promise((r) => setTimeout(r, delay));
reply.status(401).send({ error: "Invalid credentials" }); reply.status(401).send({ error: "Invalid credentials" });
return; return;
} }
failedAttempts.delete(`${req.ip}:${body.email.toLowerCase()}`);
const valid = await bcrypt.compare(body.password, admin.passwordHash); // only auto-upgrade CLI-created admins (have email, not invited)
if (!valid) { if (admin.role !== "SUPER_ADMIN" && admin.email && !admin.invitedById) {
reply.status(401).send({ error: "Invalid credentials" }); await prisma.adminUser.update({ where: { id: admin.id }, data: { role: "SUPER_ADMIN" } });
return;
} }
const token = jwt.sign( const linkedUserId = await ensureLinkedUser(admin.id);
{ sub: admin.id, type: "admin" },
const adminToken = jwt.sign(
{ sub: admin.id, type: "admin", aud: "echoboard:admin", iss: "echoboard" },
config.JWT_SECRET, config.JWT_SECRET,
{ expiresIn: "24h" } { expiresIn: "4h" }
); );
reply.send({ token }); const userToken = jwt.sign(
{ sub: linkedUserId, type: "passkey", aud: "echoboard:user", iss: "echoboard" },
config.JWT_SECRET,
{ expiresIn: "4h" }
);
reply
.setCookie("echoboard_admin", adminToken, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 4,
})
.setCookie("echoboard_passkey", userToken, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 4,
})
.send({ ok: true });
} }
); );
app.get(
"/admin/me",
{ preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
if (!req.adminId) {
reply.send({ isAdmin: false });
return;
}
const admin = await prisma.adminUser.findUnique({
where: { id: req.adminId },
select: { role: true, displayName: true, teamTitle: true },
});
if (!admin) {
reply.send({ isAdmin: false });
return;
}
reply.send({
isAdmin: true,
role: admin.role,
displayName: admin.displayName ? decrypt(admin.displayName, masterKey) : null,
teamTitle: admin.teamTitle ? decrypt(admin.teamTitle, masterKey) : null,
});
}
);
app.post("/admin/exit", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => {
const adminToken = req.cookies?.echoboard_admin;
const passkeyToken = req.cookies?.echoboard_passkey;
if (adminToken) await blockToken(adminToken);
if (passkeyToken) await blockToken(passkeyToken);
reply
.clearCookie("echoboard_admin", { path: "/" })
.clearCookie("echoboard_passkey", { path: "/" })
.clearCookie("echoboard_token", { path: "/" })
.send({ ok: true });
});
app.post("/admin/logout", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => {
const adminToken = req.cookies?.echoboard_admin;
const passkeyToken = req.cookies?.echoboard_passkey;
if (adminToken) await blockToken(adminToken);
if (passkeyToken) await blockToken(passkeyToken);
reply
.clearCookie("echoboard_admin", { path: "/" })
.clearCookie("echoboard_passkey", { path: "/" })
.clearCookie("echoboard_token", { path: "/" })
.send({ ok: true });
});
} }

View File

@@ -1,33 +1,47 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { seedTemplatesForBoard } from "../../lib/default-templates.js";
import { prisma } from "../../lib/prisma.js";
const prisma = new PrismaClient(); const safeUrl = z.string().url().refine((u) => /^https?:\/\//i.test(u), { message: "URL must use http or https" });
const iconName = z.string().max(80).regex(/^Icon[A-Za-z0-9]+$/).optional().nullable();
const iconColor = z.string().max(30).regex(/^#[0-9a-fA-F]{3,8}$/).optional().nullable();
const createBoardBody = z.object({ const createBoardBody = z.object({
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/), slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/),
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
externalUrl: z.string().url().optional(), externalUrl: safeUrl.optional(),
iconName,
iconColor,
voteBudget: z.number().int().min(0).default(10), voteBudget: z.number().int().min(0).default(10),
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).default("monthly"), voteBudgetReset: z.enum(["weekly", "biweekly", "monthly", "quarterly", "per_release", "never"]).default("monthly"),
allowMultiVote: z.boolean().default(false), allowMultiVote: z.boolean().default(false),
rssEnabled: z.boolean().default(true),
rssFeedCount: z.number().int().min(1).max(200).default(50),
staleDays: z.number().int().min(0).max(365).default(0),
}); });
const updateBoardBody = z.object({ const updateBoardBody = z.object({
name: z.string().min(1).max(100).optional(), name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional().nullable(), description: z.string().max(500).optional().nullable(),
externalUrl: z.string().url().optional().nullable(), externalUrl: safeUrl.optional().nullable(),
iconName,
iconColor,
isArchived: z.boolean().optional(), isArchived: z.boolean().optional(),
voteBudget: z.number().int().min(0).optional(), voteBudget: z.number().int().min(0).optional(),
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).optional(), voteBudgetReset: z.enum(["weekly", "biweekly", "monthly", "quarterly", "per_release", "never"]).optional(),
allowMultiVote: z.boolean().optional(), allowMultiVote: z.boolean().optional(),
rssEnabled: z.boolean().optional(),
rssFeedCount: z.number().int().min(1).max(200).optional(),
staleDays: z.number().int().min(0).max(365).optional(),
}); });
export default async function adminBoardRoutes(app: FastifyInstance) { export default async function adminBoardRoutes(app: FastifyInstance) {
app.get( app.get(
"/admin/boards", "/admin/boards",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => { async (_req, reply) => {
const boards = await prisma.board.findMany({ const boards = await prisma.board.findMany({
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
@@ -41,7 +55,7 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
app.post<{ Body: z.infer<typeof createBoardBody> }>( app.post<{ Body: z.infer<typeof createBoardBody> }>(
"/admin/boards", "/admin/boards",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const body = createBoardBody.parse(req.body); const body = createBoardBody.parse(req.body);
@@ -52,13 +66,15 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
} }
const board = await prisma.board.create({ data: body }); const board = await prisma.board.create({ data: body });
await seedTemplatesForBoard(prisma, board.id);
req.log.info({ adminId: req.adminId, boardId: board.id, slug: board.slug }, "board created");
reply.status(201).send(board); reply.status(201).send(board);
} }
); );
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBoardBody> }>( app.put<{ Params: { id: string }; Body: z.infer<typeof updateBoardBody> }>(
"/admin/boards/:id", "/admin/boards/:id",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.id } }); const board = await prisma.board.findUnique({ where: { id: req.params.id } });
if (!board) { if (!board) {
@@ -72,13 +88,14 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
data: body, data: body,
}); });
req.log.info({ adminId: req.adminId, boardId: board.id }, "board updated");
reply.send(updated); reply.send(updated);
} }
); );
app.post<{ Params: { id: string } }>( app.post<{ Params: { id: string } }>(
"/admin/boards/:id/reset-budget", "/admin/boards/:id/reset-budget",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.id } }); const board = await prisma.board.findUnique({ where: { id: req.params.id } });
if (!board) { if (!board) {
@@ -91,21 +108,33 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
data: { lastBudgetReset: new Date() }, data: { lastBudgetReset: new Date() },
}); });
req.log.info({ adminId: req.adminId, boardId: board.id }, "budget reset");
reply.send(updated); reply.send(updated);
} }
); );
app.delete<{ Params: { id: string } }>( app.delete<{ Params: { id: string } }>(
"/admin/boards/:id", "/admin/boards/:id",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.id } }); const board = await prisma.board.findUnique({
where: { id: req.params.id },
include: { _count: { select: { posts: true } } },
});
if (!board) { if (!board) {
reply.status(404).send({ error: "Board not found" }); reply.status(404).send({ error: "Board not found" });
return; return;
} }
if (board._count.posts > 0) {
reply.status(409).send({
error: `Board has ${board._count.posts} posts. Archive it or delete posts first.`,
});
return;
}
await prisma.board.delete({ where: { id: board.id } }); await prisma.board.delete({ where: { id: board.id } });
req.log.info({ adminId: req.adminId, boardId: board.id, boardSlug: board.slug }, "board deleted");
reply.status(204).send(); reply.status(204).send();
} }
); );

View File

@@ -1,8 +1,6 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const prisma = new PrismaClient();
const createCategoryBody = z.object({ const createCategoryBody = z.object({
name: z.string().min(1).max(50), name: z.string().min(1).max(50),
@@ -12,26 +10,27 @@ const createCategoryBody = z.object({
export default async function adminCategoryRoutes(app: FastifyInstance) { export default async function adminCategoryRoutes(app: FastifyInstance) {
app.post<{ Body: z.infer<typeof createCategoryBody> }>( app.post<{ Body: z.infer<typeof createCategoryBody> }>(
"/admin/categories", "/admin/categories",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const body = createCategoryBody.parse(req.body); const body = createCategoryBody.parse(req.body);
const existing = await prisma.category.findFirst({ try {
where: { OR: [{ name: body.name }, { slug: body.slug }] }, const cat = await prisma.category.create({ data: body });
}); req.log.info({ adminId: req.adminId, categoryId: cat.id, name: cat.name }, "category created");
if (existing) { reply.status(201).send(cat);
reply.status(409).send({ error: "Category already exists" }); } catch (err: any) {
return; if (err.code === "P2002") {
reply.status(409).send({ error: "Category already exists" });
return;
}
throw err;
} }
const cat = await prisma.category.create({ data: body });
reply.status(201).send(cat);
} }
); );
app.delete<{ Params: { id: string } }>( app.delete<{ Params: { id: string } }>(
"/admin/categories/:id", "/admin/categories/:id",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const cat = await prisma.category.findUnique({ where: { id: req.params.id } }); const cat = await prisma.category.findUnique({ where: { id: req.params.id } });
if (!cat) { if (!cat) {
@@ -40,6 +39,7 @@ export default async function adminCategoryRoutes(app: FastifyInstance) {
} }
await prisma.category.delete({ where: { id: cat.id } }); await prisma.category.delete({ where: { id: cat.id } });
req.log.info({ adminId: req.adminId, categoryId: cat.id, name: cat.name }, "category deleted");
reply.status(204).send(); reply.status(204).send();
} }
); );

View File

@@ -0,0 +1,91 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const createBody = z.object({
title: z.string().min(1).max(200).trim(),
body: z.string().min(1).max(10000).trim(),
boardId: z.string().optional().nullable(),
publishedAt: z.coerce.date().optional(),
});
const updateBody = z.object({
title: z.string().min(1).max(200).trim().optional(),
body: z.string().min(1).max(10000).trim().optional(),
boardId: z.string().optional().nullable(),
publishedAt: z.coerce.date().optional(),
});
export default async function adminChangelogRoutes(app: FastifyInstance) {
app.get(
"/admin/changelog",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const entries = await prisma.changelogEntry.findMany({
include: { board: { select: { id: true, slug: true, name: true } } },
orderBy: { publishedAt: "desc" },
take: 200,
});
reply.send({ entries });
}
);
app.post<{ Body: z.infer<typeof createBody> }>(
"/admin/changelog",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = createBody.parse(req.body);
const entry = await prisma.changelogEntry.create({
data: {
title: body.title,
body: body.body,
boardId: body.boardId || null,
publishedAt: body.publishedAt ?? new Date(),
},
});
req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry created");
reply.status(201).send(entry);
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBody> }>(
"/admin/changelog/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const entry = await prisma.changelogEntry.findUnique({ where: { id: req.params.id } });
if (!entry) {
reply.status(404).send({ error: "Entry not found" });
return;
}
const body = updateBody.parse(req.body);
const updated = await prisma.changelogEntry.update({
where: { id: entry.id },
data: {
...(body.title !== undefined && { title: body.title }),
...(body.body !== undefined && { body: body.body }),
...(body.boardId !== undefined && { boardId: body.boardId || null }),
...(body.publishedAt !== undefined && { publishedAt: body.publishedAt }),
},
});
req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry updated");
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/changelog/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const entry = await prisma.changelogEntry.findUnique({ where: { id: req.params.id } });
if (!entry) {
reply.status(404).send({ error: "Entry not found" });
return;
}
await prisma.changelogEntry.delete({ where: { id: entry.id } });
req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry deleted");
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,164 @@
import { FastifyInstance } from "fastify";
import { decryptWithFallback } from "../../services/encryption.js";
import { masterKey, previousMasterKey } from "../../config.js";
import { prisma } from "../../lib/prisma.js";
function decryptName(encrypted: string | null): string {
if (!encrypted) return "";
try {
return decryptWithFallback(encrypted, masterKey, previousMasterKey);
} catch {
return "[encrypted]";
}
}
function toCsv(headers: string[], rows: string[][]): string {
const escape = (v: string) => {
// prevent formula injection in spreadsheets
let safe = v;
if (/^[=+\-@\t\r|]/.test(safe)) {
safe = "'" + safe;
}
if (safe.includes(",") || safe.includes('"') || safe.includes("\n")) {
return '"' + safe.replace(/"/g, '""') + '"';
}
return safe;
};
const lines = [headers.map(escape).join(",")];
for (const row of rows) {
lines.push(row.map((c) => escape(String(c ?? ""))).join(","));
}
return lines.join("\n");
}
async function fetchPosts() {
const posts = await prisma.post.findMany({
include: { board: { select: { name: true } }, author: { select: { displayName: true } } },
orderBy: { createdAt: "desc" },
take: 5000,
});
return posts.map((p) => ({
id: p.id,
title: p.title,
type: p.type,
status: p.status,
voteCount: p.voteCount,
board: p.board.name,
author: decryptName(p.author.displayName),
createdAt: p.createdAt.toISOString(),
}));
}
async function fetchVotes() {
const votes = await prisma.vote.findMany({
include: {
post: { select: { title: true } },
voter: { select: { displayName: true } },
},
orderBy: { createdAt: "desc" },
take: 10000,
});
return votes.map((v) => ({
postId: v.postId,
postTitle: v.post.title,
voter: decryptName(v.voter.displayName),
weight: v.weight,
importance: v.importance ?? "",
createdAt: v.createdAt.toISOString(),
}));
}
async function fetchComments() {
const comments = await prisma.comment.findMany({
include: {
post: { select: { title: true } },
author: { select: { displayName: true } },
},
orderBy: { createdAt: "desc" },
take: 10000,
});
return comments.map((c) => ({
postId: c.postId,
postTitle: c.post.title,
body: c.body,
author: decryptName(c.author.displayName),
isAdmin: c.isAdmin,
createdAt: c.createdAt.toISOString(),
}));
}
async function fetchUsers() {
const users = await prisma.user.findMany({ orderBy: { createdAt: "desc" }, take: 5000 });
return users.map((u) => ({
id: u.id,
authMethod: u.authMethod,
displayName: decryptName(u.displayName),
createdAt: u.createdAt.toISOString(),
}));
}
const postHeaders = ["id", "title", "type", "status", "voteCount", "board", "author", "createdAt"];
const voteHeaders = ["postId", "postTitle", "voter", "weight", "importance", "createdAt"];
const commentHeaders = ["postId", "postTitle", "body", "author", "isAdmin", "createdAt"];
const userHeaders = ["id", "authMethod", "displayName", "createdAt"];
function toRows(items: Record<string, unknown>[], headers: string[]): string[][] {
return items.map((item) => headers.map((h) => String(item[h] ?? "")));
}
export default async function adminExportRoutes(app: FastifyInstance) {
app.get(
"/admin/export",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 2, timeWindow: "1 minute" } } },
async (req, reply) => {
const { format = "json", type = "all" } = req.query as { format?: string; type?: string };
const validTypes = new Set(["all", "posts", "votes", "comments", "users"]);
const validFormats = new Set(["json", "csv"]);
if (!validTypes.has(type) || !validFormats.has(format)) {
reply.status(400).send({ error: "Invalid export type or format" });
return;
}
req.log.info({ adminId: req.adminId, exportType: type, format }, "admin data export");
const data: Record<string, unknown[]> = {};
if (type === "posts" || type === "all") data.posts = await fetchPosts();
if (type === "votes" || type === "all") data.votes = await fetchVotes();
if (type === "comments" || type === "all") data.comments = await fetchComments();
if (type === "users" || type === "all") data.users = await fetchUsers();
if (format === "csv") {
const parts: string[] = [];
if (data.posts) {
parts.push("# Posts");
parts.push(toCsv(postHeaders, toRows(data.posts as Record<string, unknown>[], postHeaders)));
}
if (data.votes) {
parts.push("# Votes");
parts.push(toCsv(voteHeaders, toRows(data.votes as Record<string, unknown>[], voteHeaders)));
}
if (data.comments) {
parts.push("# Comments");
parts.push(toCsv(commentHeaders, toRows(data.comments as Record<string, unknown>[], commentHeaders)));
}
if (data.users) {
parts.push("# Users");
parts.push(toCsv(userHeaders, toRows(data.users as Record<string, unknown>[], userHeaders)));
}
const csv = parts.join("\n\n");
reply
.header("Content-Type", "text/csv; charset=utf-8")
.header("Content-Disposition", `attachment; filename="echoboard-export-${type}.csv"`)
.send(csv);
return;
}
reply.send(data);
}
);
}

View File

@@ -0,0 +1,100 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
function redactEmail(email: string): string {
const [local, domain] = email.split("@");
if (!domain) return "***";
return local[0] + "***@" + domain;
}
const createNoteBody = z.object({
body: z.string().min(1).max(2000).trim(),
});
export default async function adminNoteRoutes(app: FastifyInstance) {
// Get notes for a post
app.get<{ Params: { id: string } }>(
"/admin/posts/:id/notes",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
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 notes = await prisma.adminNote.findMany({
where: { postId: post.id },
orderBy: { createdAt: "desc" },
take: 100,
include: {
admin: { select: { email: true } },
},
});
reply.send({
notes: notes.map((n) => ({
id: n.id,
body: n.body,
adminEmail: n.admin.email ? redactEmail(n.admin.email) : "Team member",
createdAt: n.createdAt,
})),
});
}
);
// Add a note to a post
app.post<{ Params: { id: string }; Body: z.infer<typeof createNoteBody> }>(
"/admin/posts/:id/notes",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
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 } = createNoteBody.parse(req.body);
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! }, select: { email: true } });
const note = await prisma.adminNote.create({
data: {
body,
postId: post.id,
adminId: req.adminId!,
},
});
reply.status(201).send({
id: note.id,
body: note.body,
postId: note.postId,
adminEmail: admin?.email ? redactEmail(admin.email) : "Team member",
createdAt: note.createdAt,
});
}
);
// Delete a note
app.delete<{ Params: { noteId: string } }>(
"/admin/notes/:noteId",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const note = await prisma.adminNote.findUnique({ where: { id: req.params.noteId } });
if (!note) {
reply.status(404).send({ error: "Note not found" });
return;
}
if (note.adminId !== req.adminId) {
reply.status(403).send({ error: "You can only delete your own notes" });
return;
}
await prisma.adminNote.delete({ where: { id: note.id } });
reply.status(204).send();
}
);
}

View File

@@ -1,27 +1,87 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient, PostStatus, Prisma } from "@prisma/client"; import { Prisma, PostType } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import { notifyPostSubscribers } from "../../services/push.js"; import { notifyPostSubscribers } from "../../services/push.js";
import { fireWebhook } from "../../services/webhooks.js";
import { decrypt } from "../../services/encryption.js";
import { masterKey } from "../../config.js";
import { prisma } from "../../lib/prisma.js";
const prisma = new PrismaClient(); const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
function decryptName(v: string | null): string | null {
if (!v) return null;
try { return decrypt(v, masterKey); } catch { return null; }
}
const statusBody = z.object({ const statusBody = z.object({
status: z.nativeEnum(PostStatus), status: z.string().min(1).max(50),
reason: z.string().max(2000).optional(),
}); });
const respondBody = z.object({ const respondBody = z.object({
body: z.string().min(1).max(5000), body: z.string().min(1).max(5000),
}); });
const mergeBody = z.object({
targetPostId: z.string().min(1),
});
const rollbackBody = z.object({
editHistoryId: z.string().min(1),
});
const bulkIds = z.array(z.string().min(1)).min(1).max(100);
const bulkStatusBody = z.object({
postIds: bulkIds,
status: z.string().min(1).max(50),
});
const bulkDeleteBody = z.object({
postIds: bulkIds,
});
const bulkTagBody = z.object({
postIds: bulkIds,
tagId: z.string().min(1),
action: z.enum(['add', 'remove']),
});
const allowedDescKeys = new Set(["stepsToReproduce", "expectedBehavior", "actualBehavior", "environment", "useCase", "proposedSolution", "alternativesConsidered", "additionalContext"]);
const descriptionRecord = z.record(z.string()).refine(
(obj) => Object.keys(obj).length <= 10 && Object.keys(obj).every((k) => allowedDescKeys.has(k)),
{ message: "Unknown description fields" }
);
const proxyPostBody = z.object({
type: z.nativeEnum(PostType),
title: z.string().min(5).max(200),
description: descriptionRecord,
onBehalfOf: z.string().min(1).max(200),
});
const adminPostsQuery = z.object({
page: z.coerce.number().int().min(1).max(500).default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
status: z.string().max(50).optional(),
boardId: z.string().min(1).optional(),
});
export default async function adminPostRoutes(app: FastifyInstance) { export default async function adminPostRoutes(app: FastifyInstance) {
app.get<{ Querystring: Record<string, string> }>( app.get<{ Querystring: Record<string, string> }>(
"/admin/posts", "/admin/posts",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const page = Math.max(1, parseInt(req.query.page ?? "1", 10)); const q = adminPostsQuery.safeParse(req.query);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit ?? "50", 10))); if (!q.success) {
const status = req.query.status as PostStatus | undefined; reply.status(400).send({ error: "Invalid query parameters" });
const boardId = req.query.boardId; return;
}
const { page, limit, status, boardId } = q.data;
const where: Prisma.PostWhereInput = {}; const where: Prisma.PostWhereInput = {};
if (status) where.status = status; if (status) where.status = status;
@@ -31,24 +91,46 @@ export default async function adminPostRoutes(app: FastifyInstance) {
prisma.post.findMany({ prisma.post.findMany({
where, where,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
skip: (page - 1) * limit, skip: Math.min((page - 1) * limit, 50000),
take: limit, take: limit,
include: { include: {
board: { select: { slug: true, name: true } }, board: { select: { slug: true, name: true } },
author: { select: { id: true, displayName: true } }, author: { select: { id: true, displayName: true, avatarPath: true } },
_count: { select: { comments: true, votes: true } }, _count: { select: { comments: true, votes: true, adminNotes: true } },
tags: { include: { tag: true } },
}, },
}), }),
prisma.post.count({ where }), prisma.post.count({ where }),
]); ]);
reply.send({ posts, total, page, pages: Math.ceil(total / limit) }); reply.send({
posts: posts.map((p) => ({
id: p.id,
type: p.type,
title: p.title,
description: p.description,
status: p.status,
statusReason: p.statusReason,
category: p.category,
voteCount: p.voteCount,
viewCount: p.viewCount,
isPinned: p.isPinned,
onBehalfOf: p.onBehalfOf,
board: p.board,
author: p.author ? { id: p.author.id, displayName: decryptName(p.author.displayName), avatarUrl: p.author.avatarPath ? `/api/v1/avatars/${p.author.id}` : null } : null,
tags: p.tags.map((pt) => pt.tag),
_count: p._count,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
total, page, pages: Math.ceil(total / limit),
});
} }
); );
app.put<{ Params: { id: string }; Body: z.infer<typeof statusBody> }>( app.put<{ Params: { id: string }; Body: z.infer<typeof statusBody> }>(
"/admin/posts/:id/status", "/admin/posts/:id/status",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } }); const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) { if (!post) {
@@ -56,17 +138,37 @@ export default async function adminPostRoutes(app: FastifyInstance) {
return; return;
} }
const { status } = statusBody.parse(req.body); const { status, reason } = statusBody.parse(req.body);
const reasonText = reason?.trim() || null;
// check if the target status exists and is enabled for this board
const boardConfig = await prisma.boardStatus.findMany({
where: { boardId: post.boardId, enabled: true },
});
if (boardConfig.length === 0) {
reply.status(400).send({ error: "No statuses configured for this board" });
return;
}
const statusEntry = boardConfig.find((c) => c.status === status);
if (!statusEntry) {
reply.status(400).send({ error: `Status "${status}" is not available for this board` });
return;
}
const oldStatus = post.status; const oldStatus = post.status;
const [updated] = await Promise.all([ const [updated] = await Promise.all([
prisma.post.update({ where: { id: post.id }, data: { status } }), prisma.post.update({
where: { id: post.id },
data: { status, statusReason: reasonText, lastActivityAt: new Date() },
}),
prisma.statusChange.create({ prisma.statusChange.create({
data: { data: {
postId: post.id, postId: post.id,
fromStatus: oldStatus, fromStatus: oldStatus,
toStatus: status, toStatus: status,
changedBy: req.adminId!, changedBy: req.adminId!,
reason: reasonText,
}, },
}), }),
prisma.activityEvent.create({ prisma.activityEvent.create({
@@ -79,20 +181,55 @@ export default async function adminPostRoutes(app: FastifyInstance) {
}), }),
]); ]);
// notify post author and voters
const voters = await prisma.vote.findMany({
where: { postId: post.id },
select: { voterId: true },
});
const statusLabel = statusEntry.label || status.replace(/_/g, " ");
const notifBody = reasonText
? `"${post.title}" moved to ${statusLabel} - Reason: ${reasonText}`
: `"${post.title}" moved to ${statusLabel}`;
const sentinelId = "deleted-user-sentinel";
const voterIds = voters.map((v) => v.voterId).filter((id) => id !== sentinelId);
if (voterIds.length > 1000) {
req.log.warn({ postId: post.id, totalVoters: voterIds.length }, "notification capped at 1000 voters");
}
const notifyIds = voterIds.slice(0, 1000);
const userIds = new Set([post.authorId, ...notifyIds]);
userIds.delete(sentinelId);
await prisma.notification.createMany({
data: [...userIds].map((userId) => ({
type: "status_changed",
title: "Status updated",
body: notifBody,
postId: post.id,
userId,
})),
});
await notifyPostSubscribers(post.id, { await notifyPostSubscribers(post.id, {
title: "Status updated", title: "Status updated",
body: `"${post.title}" moved to ${status}`, body: notifBody,
url: `/post/${post.id}`, url: `/post/${post.id}`,
tag: `status-${post.id}`, tag: `status-${post.id}`,
}); });
fireWebhook("status_changed", {
postId: post.id,
title: post.title,
boardId: post.boardId,
from: oldStatus,
to: status,
});
reply.send(updated); reply.send(updated);
} }
); );
app.put<{ Params: { id: string } }>( app.put<{ Params: { id: string } }>(
"/admin/posts/:id/pin", "/admin/posts/:id/pin",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } }); const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) { if (!post) {
@@ -100,65 +237,323 @@ export default async function adminPostRoutes(app: FastifyInstance) {
return; return;
} }
if (!post.isPinned) {
try {
const updated = await prisma.$transaction(async (tx) => {
const pinnedCount = await tx.post.count({
where: { boardId: post.boardId, isPinned: true },
});
if (pinnedCount >= 3) throw new Error("PIN_LIMIT");
return tx.post.update({
where: { id: post.id },
data: { isPinned: true },
});
}, { isolationLevel: "Serializable" });
reply.send(updated);
} catch (err: any) {
if (err.message === "PIN_LIMIT") {
reply.status(409).send({ error: "Max 3 pinned posts per board" });
return;
}
throw err;
}
} else {
const updated = await prisma.post.update({
where: { id: post.id },
data: { isPinned: false },
});
reply.send(updated);
}
}
);
app.post<{ Params: { id: string }; Body: z.infer<typeof respondBody> }>(
"/admin/posts/:id/respond",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
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 admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin || !admin.linkedUserId) {
reply.status(500).send({ error: "Admin account not linked" });
return;
}
const { body } = respondBody.parse(req.body);
const cleanBody = body.replace(INVISIBLE_RE, '');
const comment = await prisma.comment.create({
data: {
body: cleanBody,
postId: post.id,
authorId: admin.linkedUserId,
isAdmin: true,
adminUserId: req.adminId!,
},
});
await prisma.activityEvent.create({
data: {
type: "admin_responded",
boardId: post.boardId,
postId: post.id,
metadata: {},
},
});
await notifyPostSubscribers(post.id, {
title: "Official response",
body: cleanBody.slice(0, 100),
url: `/post/${post.id}`,
tag: `response-${post.id}`,
});
reply.status(201).send(comment);
}
);
// Admin creates a post on behalf of a user
app.post<{ Params: { boardId: string }; Body: z.infer<typeof proxyPostBody> }>(
"/admin/boards/:boardId/posts",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board || board.isArchived) {
reply.status(404).send({ error: "Board not found or archived" });
return;
}
const body = proxyPostBody.parse(req.body);
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin || !admin.linkedUserId) {
reply.status(400).send({ error: "Admin account must be linked to a user to submit posts" });
return;
}
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
const cleanDesc: Record<string, string> = {};
for (const [k, v] of Object.entries(body.description)) {
cleanDesc[k] = v.replace(INVISIBLE_RE, '');
}
const post = await prisma.post.create({
data: {
type: body.type,
title: cleanTitle,
description: cleanDesc,
boardId: board.id,
authorId: admin.linkedUserId,
onBehalfOf: body.onBehalfOf,
},
});
await prisma.activityEvent.create({
data: {
type: "post_created",
boardId: board.id,
postId: post.id,
metadata: { title: post.title, type: post.type, onBehalfOf: body.onBehalfOf },
},
});
fireWebhook("post_created", {
postId: post.id,
title: post.title,
type: post.type,
boardId: board.id,
boardSlug: board.slug,
onBehalfOf: body.onBehalfOf,
});
reply.status(201).send(post);
}
);
// Merge source post into target post
app.post<{ Params: { id: string }; Body: z.infer<typeof mergeBody> }>(
"/admin/posts/:id/merge",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (req, reply) => {
const { targetPostId } = mergeBody.parse(req.body);
const source = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!source) {
reply.status(404).send({ error: "Source post not found" });
return;
}
const target = await prisma.post.findUnique({ where: { id: targetPostId } });
if (!target) {
reply.status(404).send({ error: "Target post not found" });
return;
}
if (source.id === target.id) {
reply.status(400).send({ error: "Cannot merge a post into itself" });
return;
}
if (source.boardId !== target.boardId) {
reply.status(400).send({ error: "Cannot merge posts across different boards" });
return;
}
// only load IDs and weights to minimize memory
const sourceVotes = await prisma.vote.findMany({
where: { postId: source.id },
select: { id: true, voterId: true, weight: true },
});
const targetVoterIds = new Set(
(await prisma.vote.findMany({ where: { postId: target.id }, select: { voterId: true } }))
.map((v) => v.voterId)
);
const votesToMove = sourceVotes.filter((v) => !targetVoterIds.has(v.voterId));
const targetTagIds = (await prisma.postTag.findMany({
where: { postId: target.id },
select: { tagId: true },
})).map((t) => t.tagId);
await prisma.$transaction(async (tx) => {
for (const v of votesToMove) {
await tx.vote.update({ where: { id: v.id }, data: { postId: target.id } });
}
await tx.vote.deleteMany({ where: { postId: source.id } });
await tx.comment.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
await tx.attachment.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
await tx.postTag.deleteMany({
where: { postId: source.id, tagId: { in: targetTagIds } },
});
await tx.postTag.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
await tx.post.update({
where: { id: target.id },
data: { voteCount: { increment: votesToMove.reduce((sum, v) => sum + v.weight, 0) } },
});
await tx.postMerge.create({
data: { sourcePostId: source.id, targetPostId: target.id, mergedBy: req.adminId! },
});
await tx.post.delete({ where: { id: source.id } });
}, { isolationLevel: "Serializable" });
const actualCount = await prisma.vote.aggregate({ where: { postId: target.id }, _sum: { weight: true } });
await prisma.post.update({ where: { id: target.id }, data: { voteCount: actualCount._sum.weight ?? 0 } });
req.log.info({ adminId: req.adminId, sourcePostId: source.id, targetPostId: target.id }, "posts merged");
reply.send({ merged: true, targetPostId: target.id });
}
);
app.put<{ Params: { id: string } }>(
"/admin/posts/:id/lock-edits",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
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({ const updated = await prisma.post.update({
where: { id: post.id }, where: { id: post.id },
data: { isPinned: !post.isPinned }, data: { isEditLocked: !post.isEditLocked },
});
reply.send({ isEditLocked: updated.isEditLocked });
}
);
app.put<{ Params: { id: string } }>(
"/admin/posts/:id/lock-thread",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = z.object({ lockVoting: z.boolean().optional() }).parse(req.body);
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const newThreadLocked = !post.isThreadLocked;
const data: Record<string, boolean> = { isThreadLocked: newThreadLocked };
if (newThreadLocked && body.lockVoting) {
data.isVotingLocked = true;
}
if (!newThreadLocked) {
data.isVotingLocked = false;
}
const updated = await prisma.post.update({ where: { id: post.id }, data });
reply.send({ isThreadLocked: updated.isThreadLocked, isVotingLocked: updated.isVotingLocked });
}
);
app.put<{ Params: { id: string } }>(
"/admin/comments/:id/lock-edits",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
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 updated = await prisma.comment.update({
where: { id: comment.id },
data: { isEditLocked: !comment.isEditLocked },
});
reply.send({ isEditLocked: updated.isEditLocked });
}
);
app.post<{ Params: { id: string }; Body: z.infer<typeof rollbackBody> }>(
"/admin/posts/:id/rollback",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
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 { editHistoryId } = rollbackBody.parse(req.body);
const edit = await prisma.editHistory.findUnique({ where: { id: editHistoryId } });
if (!edit || edit.postId !== post.id) {
reply.status(404).send({ error: "Edit history entry not found" });
return;
}
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin?.linkedUserId) {
reply.status(400).send({ error: "Admin account must be linked to a user" });
return;
}
// save current state before rollback
await prisma.editHistory.create({
data: {
postId: post.id,
editedBy: admin.linkedUserId,
previousTitle: post.title,
previousDescription: post.description as any,
},
});
const data: Record<string, any> = {};
if (edit.previousTitle !== null) data.title = edit.previousTitle;
if (edit.previousDescription !== null) data.description = edit.previousDescription;
const updated = await prisma.post.update({
where: { id: post.id },
data,
}); });
reply.send(updated); reply.send(updated);
} }
); );
app.post<{ Params: { id: string }; Body: z.infer<typeof respondBody> }>( app.post<{ Params: { id: string }; Body: z.infer<typeof rollbackBody> }>(
"/admin/posts/:id/respond", "/admin/comments/:id/rollback",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
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) => { async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) { if (!comment) {
@@ -166,8 +561,284 @@ export default async function adminPostRoutes(app: FastifyInstance) {
return; return;
} }
await prisma.comment.delete({ where: { id: comment.id } }); const { editHistoryId } = rollbackBody.parse(req.body);
const edit = await prisma.editHistory.findUnique({ where: { id: editHistoryId } });
if (!edit || edit.commentId !== comment.id) {
reply.status(404).send({ error: "Edit history entry not found" });
return;
}
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
if (!admin?.linkedUserId) {
reply.status(400).send({ error: "Admin account must be linked to a user" });
return;
}
await prisma.editHistory.create({
data: {
commentId: comment.id,
editedBy: admin.linkedUserId,
previousBody: comment.body,
},
});
if (edit.previousBody === null) {
reply.status(400).send({ error: "No previous body to restore" });
return;
}
const updated = await prisma.comment.update({
where: { id: comment.id },
data: { body: edit.previousBody },
});
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/posts/:id",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
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 commentIds = (await prisma.comment.findMany({
where: { postId: post.id },
select: { id: true },
})).map((c) => c.id);
const attachments = await prisma.attachment.findMany({
where: {
OR: [
{ postId: post.id },
...(commentIds.length ? [{ commentId: { in: commentIds } }] : []),
],
},
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.postMerge.deleteMany({
where: { OR: [{ sourcePostId: post.id }, { targetPostId: post.id }] },
});
await prisma.post.delete({ where: { id: post.id } });
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
req.log.info({ adminId: req.adminId, postId: post.id }, "admin post deleted");
reply.status(204).send(); reply.status(204).send();
} }
); );
app.delete<{ Params: { id: string } }>(
"/admin/comments/:id",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
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 attachments = await prisma.attachment.findMany({
where: { commentId: comment.id },
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.comment.delete({ where: { id: comment.id } });
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
req.log.info({ adminId: req.adminId, commentId: comment.id }, "admin comment deleted");
reply.status(204).send();
}
);
app.post<{ Body: z.infer<typeof bulkStatusBody> }>(
"/admin/posts/bulk-status",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const parsed = bulkStatusBody.safeParse(req.body);
if (!parsed.success) {
reply.status(400).send({ error: "Invalid request body" });
return;
}
const { postIds, status } = parsed.data;
const posts = await prisma.post.findMany({
where: { id: { in: postIds } },
select: { id: true, status: true, boardId: true },
});
if (posts.length === 0) {
reply.send({ updated: 0 });
return;
}
// validate target status against each board's config
const boardIds = [...new Set(posts.map((p) => p.boardId))];
for (const boardId of boardIds) {
const boardStatuses = await prisma.boardStatus.findMany({
where: { boardId, enabled: true },
});
if (boardStatuses.length > 0 && !boardStatuses.some((s) => s.status === status)) {
reply.status(400).send({ error: `Status "${status}" is not enabled for board ${boardId}` });
return;
}
}
await prisma.$transaction([
prisma.post.updateMany({
where: { id: { in: posts.map((p) => p.id) } },
data: { status, lastActivityAt: new Date() },
}),
...posts.map((p) =>
prisma.statusChange.create({
data: {
postId: p.id,
fromStatus: p.status,
toStatus: status,
changedBy: req.adminId!,
},
})
),
]);
reply.send({ updated: posts.length });
}
);
app.post<{ Body: z.infer<typeof bulkDeleteBody> }>(
"/admin/posts/bulk-delete",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (req, reply) => {
const parsed = bulkDeleteBody.safeParse(req.body);
if (!parsed.success) {
reply.status(400).send({ error: "Invalid request body" });
return;
}
const { postIds } = parsed.data;
const posts = await prisma.post.findMany({
where: { id: { in: postIds } },
select: { id: true, boardId: true, title: true },
});
if (posts.length === 0) {
reply.send({ deleted: 0 });
return;
}
const validPostIds = posts.map((p) => p.id);
const commentIds = (await prisma.comment.findMany({
where: { postId: { in: validPostIds } },
select: { id: true },
})).map((c) => c.id);
const attachments = await prisma.attachment.findMany({
where: {
OR: [
{ postId: { in: validPostIds } },
...(commentIds.length ? [{ commentId: { in: commentIds } }] : []),
],
},
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.$transaction([
prisma.post.deleteMany({ where: { id: { in: validPostIds } } }),
...posts.map((p) =>
prisma.activityEvent.create({
data: {
type: "post_deleted",
boardId: p.boardId,
postId: p.id,
metadata: { title: p.title, deletedBy: req.adminId },
},
})
),
]);
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
req.log.info({ adminId: req.adminId, count: posts.length }, "bulk posts deleted");
reply.send({ deleted: posts.length });
}
);
app.post<{ Body: z.infer<typeof bulkTagBody> }>(
"/admin/posts/bulk-tag",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const parsed = bulkTagBody.safeParse(req.body);
if (!parsed.success) {
reply.status(400).send({ error: "Invalid request body" });
return;
}
const { postIds, tagId, action } = parsed.data;
const tag = await prisma.tag.findUnique({ where: { id: tagId } });
if (!tag) {
reply.status(404).send({ error: "Tag not found" });
return;
}
const existingPosts = await prisma.post.findMany({
where: { id: { in: postIds } },
select: { id: true },
});
const validIds = existingPosts.map((p) => p.id);
if (validIds.length === 0) {
reply.send({ affected: 0 });
return;
}
if (action === 'add') {
const existing = await prisma.postTag.findMany({
where: { tagId, postId: { in: validIds } },
select: { postId: true },
});
const alreadyTagged = new Set(existing.map((e) => e.postId));
const toAdd = validIds.filter((id) => !alreadyTagged.has(id));
if (toAdd.length > 0) {
await prisma.postTag.createMany({
data: toAdd.map((postId) => ({ postId, tagId })),
});
}
reply.send({ affected: toAdd.length });
} else {
const result = await prisma.postTag.deleteMany({
where: { tagId, postId: { in: validIds } },
});
reply.send({ affected: result.count });
}
}
);
} }

View File

@@ -0,0 +1,59 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const updateSchema = z.object({
appName: z.string().min(1).max(100).optional(),
logoUrl: z.string().url().max(500).nullable().optional(),
faviconUrl: z.string().url().max(500).nullable().optional(),
accentColor: z.string().regex(HEX_COLOR).optional(),
headerFont: z.string().max(100).nullable().optional(),
bodyFont: z.string().max(100).nullable().optional(),
poweredByVisible: z.boolean().optional(),
customCss: z.string().max(10000).nullable().optional(),
});
export default async function settingsRoutes(app: FastifyInstance) {
app.get(
"/site-settings",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const settings = await prisma.siteSettings.findUnique({ where: { id: "default" } });
reply.header("Cache-Control", "public, max-age=60");
reply.send(settings ?? {
appName: "Echoboard",
accentColor: "#F59E0B",
poweredByVisible: true,
});
}
);
app.put(
"/admin/site-settings",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = updateSchema.parse(req.body);
if (body.customCss) {
// decode CSS escape sequences before checking so \75\72\6c() can't bypass url() detection
const decoded = body.customCss
.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/\\(.)/g, '$1');
const forbidden = /@import|@font-face|@charset|@namespace|url\s*\(|expression\s*\(|javascript:|behavior\s*:|binding\s*:|-moz-binding\s*:/i;
if (forbidden.test(decoded)) {
reply.status(400).send({ error: "Custom CSS contains forbidden patterns" });
return;
}
}
const settings = await prisma.siteSettings.upsert({
where: { id: "default" },
create: { id: "default", ...body },
update: body,
});
reply.send(settings);
}
);
}

View File

@@ -1,27 +1,32 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { config } from "../../config.js"; import { config } from "../../config.js";
import { prisma } from "../../lib/prisma.js";
const prisma = new PrismaClient();
export default async function adminStatsRoutes(app: FastifyInstance) { export default async function adminStatsRoutes(app: FastifyInstance) {
app.get( app.get(
"/admin/stats", "/admin/stats",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => { async (_req, reply) => {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
const [ const [
totalPosts, totalPosts,
totalUsers, totalUsers,
totalComments, totalComments,
totalVotes, totalVotes,
thisWeek,
postsByStatus, postsByStatus,
postsByType, postsByType,
boardStats, boardStats,
topUnresolved,
usersByAuth,
] = await Promise.all([ ] = await Promise.all([
prisma.post.count(), prisma.post.count(),
prisma.user.count(), prisma.user.count(),
prisma.comment.count(), prisma.comment.count(),
prisma.vote.count(), prisma.vote.count(),
prisma.post.count({ where: { createdAt: { gte: weekAgo } } }),
prisma.post.groupBy({ by: ["status"], _count: true }), prisma.post.groupBy({ by: ["status"], _count: true }),
prisma.post.groupBy({ by: ["type"], _count: true }), prisma.post.groupBy({ by: ["type"], _count: true }),
prisma.board.findMany({ prisma.board.findMany({
@@ -32,30 +37,51 @@ export default async function adminStatsRoutes(app: FastifyInstance) {
_count: { select: { posts: true } }, _count: { select: { posts: true } },
}, },
}), }),
prisma.post.findMany({
where: { status: { in: ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS"] } },
orderBy: { voteCount: "desc" },
take: 10,
select: {
id: true,
title: true,
voteCount: true,
board: { select: { slug: true } },
},
}),
prisma.user.groupBy({ by: ["authMethod"], _count: true }),
]); ]);
reply.send({ reply.send({
totalPosts,
thisWeek,
byStatus: Object.fromEntries(postsByStatus.map((s) => [s.status, s._count])),
byType: Object.fromEntries(postsByType.map((t) => [t.type, t._count])),
topUnresolved: topUnresolved.map((p) => ({
id: p.id,
title: p.title,
voteCount: p.voteCount,
boardSlug: p.board.slug,
})),
totals: { totals: {
posts: totalPosts, posts: totalPosts,
users: totalUsers, users: totalUsers,
comments: totalComments, comments: totalComments,
votes: totalVotes, 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) => ({ boards: boardStats.map((b) => ({
id: b.id, id: b.id,
slug: b.slug, slug: b.slug,
name: b.name, name: b.name,
postCount: b._count.posts, postCount: b._count.posts,
})), })),
authMethodRatio: Object.fromEntries(usersByAuth.map((u) => [u.authMethod, u._count])),
}); });
} }
); );
app.get( app.get(
"/admin/data-retention", "/admin/data-retention",
{ preHandler: [app.requireAdmin] }, { preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => { async (_req, reply) => {
const activityCutoff = new Date(); const activityCutoff = new Date();
activityCutoff.setDate(activityCutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS); activityCutoff.setDate(activityCutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS);

View File

@@ -0,0 +1,235 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const DEFAULT_STATUSES = [
{ status: "OPEN", label: "Open", color: "#F59E0B", position: 0 },
{ status: "UNDER_REVIEW", label: "Under Review", color: "#06B6D4", position: 1 },
{ status: "PLANNED", label: "Planned", color: "#3B82F6", position: 2 },
{ status: "IN_PROGRESS", label: "In Progress", color: "#EAB308", position: 3 },
{ status: "DONE", label: "Done", color: "#22C55E", position: 4 },
{ status: "DECLINED", label: "Declined", color: "#EF4444", position: 5 },
];
const statusEntry = z.object({
status: z.string().min(1).max(50).trim(),
label: z.string().min(1).max(40).trim(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
position: z.number().int().min(0).max(50),
});
const bulkUpdateBody = z.object({
statuses: z.array(statusEntry).min(1).max(50),
});
const movePostsBody = z.object({
fromStatus: z.string().min(1).max(50),
toStatus: z.string().min(1).max(50),
});
export default async function adminStatusRoutes(app: FastifyInstance) {
app.get<{ Params: { boardId: string } }>(
"/admin/boards/:boardId/statuses",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const existing = await prisma.boardStatus.findMany({
where: { boardId: board.id, enabled: true },
orderBy: { position: "asc" },
});
if (existing.length === 0) {
reply.send({
statuses: DEFAULT_STATUSES.map((s) => ({
...s,
enabled: true,
isDefault: true,
})),
});
return;
}
reply.send({
statuses: existing.map((s) => ({
status: s.status,
label: s.label,
color: s.color,
position: s.position,
enabled: true,
})),
});
}
);
app.put<{ Params: { boardId: string }; Body: z.infer<typeof bulkUpdateBody> }>(
"/admin/boards/:boardId/statuses",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const { statuses } = bulkUpdateBody.parse(req.body);
const seen = new Set<string>();
for (const s of statuses) {
if (seen.has(s.status)) {
reply.status(400).send({ error: `Duplicate status: ${s.status}` });
return;
}
seen.add(s.status);
}
if (!statuses.some((s) => s.status === "OPEN")) {
reply.status(400).send({ error: "OPEN status must be included" });
return;
}
// find currently active statuses being removed
const sentStatuses = new Set(statuses.map((s) => s.status));
const currentActive = await prisma.boardStatus.findMany({
where: { boardId: board.id, enabled: true },
});
const removedStatuses = currentActive
.map((s) => s.status)
.filter((s) => !sentStatuses.has(s));
if (removedStatuses.length > 0) {
const inUse = await prisma.post.count({
where: { boardId: board.id, status: { in: removedStatuses } },
});
if (inUse > 0) {
reply.status(409).send({
error: "Cannot remove statuses that have posts. Move them first.",
inUseStatuses: removedStatuses,
});
return;
}
}
// upsert active, disable removed
const ops = [
...statuses.map((s) =>
prisma.boardStatus.upsert({
where: { boardId_status: { boardId: board.id, status: s.status } },
create: {
boardId: board.id,
status: s.status,
label: s.label,
color: s.color,
position: s.position,
enabled: true,
},
update: {
label: s.label,
color: s.color,
position: s.position,
enabled: true,
},
})
),
// disable any removed statuses
...(removedStatuses.length > 0
? [prisma.boardStatus.updateMany({
where: { boardId: board.id, status: { in: removedStatuses } },
data: { enabled: false },
})]
: []),
];
await prisma.$transaction(ops);
const result = await prisma.boardStatus.findMany({
where: { boardId: board.id, enabled: true },
orderBy: { position: "asc" },
});
reply.send({
statuses: result.map((s) => ({
status: s.status,
label: s.label,
color: s.color,
position: s.position,
enabled: true,
})),
});
}
);
// Move all posts from one status to another on a board
app.post<{ Params: { boardId: string }; Body: z.infer<typeof movePostsBody> }>(
"/admin/boards/:boardId/statuses/move",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const { fromStatus, toStatus } = movePostsBody.parse(req.body);
if (fromStatus === toStatus) {
reply.status(400).send({ error: "Source and target status must differ" });
return;
}
// validate that toStatus is enabled for this board
const boardStatuses = await prisma.boardStatus.findMany({
where: { boardId: board.id, enabled: true },
});
if (boardStatuses.length > 0 && !boardStatuses.some((s) => s.status === toStatus)) {
reply.status(400).send({ error: `Target status "${toStatus}" is not enabled for this board` });
return;
}
// find affected posts first for audit trail
const totalAffected = await prisma.post.count({
where: { boardId: board.id, status: fromStatus },
});
if (totalAffected > 500) {
reply.status(400).send({ error: `Too many posts (${totalAffected}). Move in smaller batches by filtering first.` });
return;
}
const affected = await prisma.post.findMany({
where: { boardId: board.id, status: fromStatus },
select: { id: true },
take: 500,
});
if (affected.length === 0) {
reply.send({ moved: 0 });
return;
}
const affectedIds = affected.map((p) => p.id);
await prisma.$transaction([
prisma.post.updateMany({
where: { id: { in: affectedIds } },
data: { status: toStatus },
}),
...affectedIds.map((postId) =>
prisma.statusChange.create({
data: {
postId,
fromStatus,
toStatus,
changedBy: req.adminId!,
reason: "Bulk status move",
},
})
),
]);
req.log.info({ adminId: req.adminId, boardId: board.id, fromStatus, toStatus, count: affectedIds.length }, "bulk status move");
reply.send({ moved: affectedIds.length });
}
);
}

View File

@@ -0,0 +1,137 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const createTagBody = z.object({
name: z.string().min(1).max(30).trim(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#6366F1"),
});
const updateTagBody = z.object({
name: z.string().min(1).max(30).trim().optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
});
const tagPostBody = z.object({
tagIds: z.array(z.string().min(1)).max(10),
});
export default async function adminTagRoutes(app: FastifyInstance) {
app.get(
"/admin/tags",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const tags = await prisma.tag.findMany({
orderBy: { name: "asc" },
include: { _count: { select: { posts: true } } },
take: 500,
});
reply.send({ tags });
}
);
app.post<{ Body: z.infer<typeof createTagBody> }>(
"/admin/tags",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = createTagBody.parse(req.body);
try {
const tag = await prisma.tag.create({ data: body });
req.log.info({ adminId: req.adminId, tagId: tag.id, tagName: tag.name }, "tag created");
reply.status(201).send(tag);
} catch (err: any) {
if (err.code === "P2002") {
reply.status(409).send({ error: "Tag already exists" });
return;
}
throw err;
}
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateTagBody> }>(
"/admin/tags/:id",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const tag = await prisma.tag.findUnique({ where: { id: req.params.id } });
if (!tag) {
reply.status(404).send({ error: "Tag not found" });
return;
}
const body = updateTagBody.parse(req.body);
try {
const updated = await prisma.tag.update({
where: { id: tag.id },
data: {
...(body.name !== undefined && { name: body.name }),
...(body.color !== undefined && { color: body.color }),
},
});
req.log.info({ adminId: req.adminId, tagId: tag.id }, "tag updated");
reply.send(updated);
} catch (err: any) {
if (err.code === "P2002") {
reply.status(409).send({ error: "Tag name already taken" });
return;
}
throw err;
}
}
);
app.delete<{ Params: { id: string } }>(
"/admin/tags/:id",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const tag = await prisma.tag.findUnique({ where: { id: req.params.id } });
if (!tag) {
reply.status(404).send({ error: "Tag not found" });
return;
}
await prisma.tag.delete({ where: { id: tag.id } });
req.log.info({ adminId: req.adminId, tagId: tag.id, tagName: tag.name }, "tag deleted");
reply.status(204).send();
}
);
// Set tags on a post (replaces existing tags)
app.put<{ Params: { id: string }; Body: z.infer<typeof tagPostBody> }>(
"/admin/posts/:id/tags",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
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 { tagIds } = tagPostBody.parse(req.body);
// verify all tags exist
const tags = await prisma.tag.findMany({ where: { id: { in: tagIds } } });
if (tags.length !== tagIds.length) {
reply.status(400).send({ error: "One or more tags not found" });
return;
}
// replace all tags in a transaction
await prisma.$transaction([
prisma.postTag.deleteMany({ where: { postId: post.id } }),
...tagIds.map((tagId) =>
prisma.postTag.create({ data: { postId: post.id, tagId } })
),
]);
const updated = await prisma.postTag.findMany({
where: { postId: post.id },
include: { tag: true },
});
reply.send({ tags: updated.map((pt) => pt.tag) });
}
);
}

View File

@@ -0,0 +1,358 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "node:crypto";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { prisma } from "../../lib/prisma.js";
import { config, masterKey, blindIndexKey } from "../../config.js";
import { encrypt, decrypt, hashToken, blindIndex } from "../../services/encryption.js";
import { generateRecoveryPhrase } from "../../lib/wordlist.js";
function decryptSafe(v: string | null): string | null {
if (!v) return null;
try { return decrypt(v, masterKey); } catch { return null; }
}
const inviteBody = z.object({
role: z.enum(["ADMIN", "MODERATOR"]),
expiresInHours: z.number().int().min(1).max(168).default(72),
label: z.string().min(1).max(100).optional(),
generateRecovery: z.boolean().default(false),
});
const claimBody = z.object({
displayName: z.string().min(1).max(100).trim(),
teamTitle: z.string().max(100).trim().optional(),
});
const updateProfileBody = z.object({
displayName: z.string().min(1).max(100).trim().optional(),
teamTitle: z.string().max(100).trim().optional(),
});
export default async function adminTeamRoutes(app: FastifyInstance) {
// list team members
app.get(
"/admin/team",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const where = req.adminRole === "SUPER_ADMIN" ? {} : { invitedById: req.adminId };
const members = await prisma.adminUser.findMany({
where,
include: {
linkedUser: { select: { id: true, authMethod: true, avatarPath: true } },
invitedBy: { select: { id: true, displayName: true } },
_count: { select: { invitees: true } },
},
orderBy: { createdAt: "asc" },
});
reply.send({
members: members.map((m) => ({
id: m.id,
role: m.role,
displayName: decryptSafe(m.displayName),
teamTitle: decryptSafe(m.teamTitle),
email: m.email ?? null,
hasPasskey: m.linkedUser?.authMethod === "PASSKEY",
avatarUrl: m.linkedUser?.avatarPath ? `/api/v1/avatars/${m.linkedUser.id}` : null,
invitedBy: m.invitedBy ? { id: m.invitedBy.id, displayName: decryptSafe(m.invitedBy.displayName) } : null,
inviteeCount: m._count.invitees,
createdAt: m.createdAt,
})),
});
}
);
// generate invite
app.post(
"/admin/team/invite",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
async (req, reply) => {
const body = inviteBody.parse(req.body);
// admins can only invite moderators
if (req.adminRole === "ADMIN" && body.role !== "MODERATOR") {
reply.status(403).send({ error: "Admins can only invite moderators" });
return;
}
const token = randomBytes(32).toString("hex");
const tokenH = hashToken(token);
const expiresAt = new Date(Date.now() + body.expiresInHours * 60 * 60 * 1000);
let recoveryPhrase: string | null = null;
let recoveryHash: string | null = null;
let recoveryIdx: string | null = null;
if (body.generateRecovery) {
recoveryPhrase = generateRecoveryPhrase();
recoveryHash = await bcrypt.hash(recoveryPhrase, 12);
recoveryIdx = blindIndex(recoveryPhrase, blindIndexKey);
}
await prisma.teamInvite.create({
data: {
tokenHash: tokenH,
role: body.role,
label: body.label ?? null,
expiresAt,
createdById: req.adminId!,
recoveryHash,
recoveryIdx,
},
});
const inviteUrl = `${config.WEBAUTHN_ORIGIN}/admin/join/${token}`;
reply.status(201).send({ inviteUrl, token, recoveryPhrase, expiresAt });
}
);
// list pending invites
app.get(
"/admin/team/invites",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const where: Record<string, unknown> = { claimedAt: null, expiresAt: { gt: new Date() } };
if (req.adminRole !== "SUPER_ADMIN") where.createdById = req.adminId;
const invites = await prisma.teamInvite.findMany({
where,
include: { createdBy: { select: { id: true, displayName: true } } },
orderBy: { createdAt: "desc" },
});
reply.send({
invites: invites.map((inv) => ({
id: inv.id,
role: inv.role,
label: inv.label,
expiresAt: inv.expiresAt,
hasRecovery: !!inv.recoveryHash,
createdBy: { id: inv.createdBy.id, displayName: decryptSafe(inv.createdBy.displayName) },
createdAt: inv.createdAt,
})),
});
}
);
// revoke invite
app.delete<{ Params: { id: string } }>(
"/admin/team/invites/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const invite = await prisma.teamInvite.findUnique({ where: { id: req.params.id } });
if (!invite || invite.claimedAt) {
reply.status(404).send({ error: "Invite not found" });
return;
}
if (req.adminRole !== "SUPER_ADMIN" && invite.createdById !== req.adminId) {
reply.status(403).send({ error: "Not your invite" });
return;
}
await prisma.teamInvite.delete({ where: { id: invite.id } });
reply.status(204).send();
}
);
// remove team member (super admin only)
app.delete<{ Params: { id: string } }>(
"/admin/team/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
if (req.params.id === req.adminId) {
reply.status(400).send({ error: "Cannot remove yourself" });
return;
}
const member = await prisma.adminUser.findUnique({ where: { id: req.params.id } });
if (!member) {
reply.status(404).send({ error: "Team member not found" });
return;
}
if (member.role === "SUPER_ADMIN") {
reply.status(403).send({ error: "Cannot remove the super admin" });
return;
}
await prisma.adminUser.delete({ where: { id: member.id } });
reply.status(204).send();
}
);
// update own profile
app.put(
"/admin/team/me",
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = updateProfileBody.parse(req.body);
const data: Record<string, string> = {};
if (body.displayName !== undefined) data.displayName = encrypt(body.displayName, masterKey);
if (body.teamTitle !== undefined) data.teamTitle = body.teamTitle ? encrypt(body.teamTitle, masterKey) : "";
if (Object.keys(data).length === 0) {
reply.status(400).send({ error: "Nothing to update" });
return;
}
await prisma.adminUser.update({ where: { id: req.adminId! }, data });
reply.send({ ok: true });
}
);
// regenerate recovery phrase for a team member
app.post<{ Params: { id: string } }>(
"/admin/team/:id/recovery",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 hour" } } },
async (req, reply) => {
const target = await prisma.adminUser.findUnique({
where: { id: req.params.id },
select: { id: true, role: true, invitedById: true, linkedUserId: true },
});
if (!target || !target.linkedUserId) {
reply.status(404).send({ error: "Team member not found" });
return;
}
if (target.role === "SUPER_ADMIN") {
reply.status(403).send({ error: "Cannot regenerate recovery for super admin" });
return;
}
// admins can only regenerate for people they invited
if (req.adminRole === "ADMIN" && target.invitedById !== req.adminId) {
reply.status(403).send({ error: "You can only regenerate recovery for people you invited" });
return;
}
const phrase = generateRecoveryPhrase();
const codeHash = await bcrypt.hash(phrase, 12);
const phraseIdx = blindIndex(phrase, blindIndexKey);
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
await prisma.recoveryCode.upsert({
where: { userId: target.linkedUserId },
create: { codeHash, phraseIdx, userId: target.linkedUserId, expiresAt },
update: { codeHash, phraseIdx, expiresAt },
});
reply.send({ phrase, expiresAt });
}
);
// validate invite token (public, for the claim page)
app.get<{ Params: { token: string } }>(
"/admin/join/:token",
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const tokenH = hashToken(req.params.token);
const invite = await prisma.teamInvite.findUnique({
where: { tokenHash: tokenH },
include: { createdBy: { select: { displayName: true } } },
});
if (!invite || invite.claimedAt || invite.expiresAt < new Date()) {
reply.status(404).send({ error: "Invalid or expired invite" });
return;
}
reply.send({
role: invite.role,
label: invite.label,
invitedBy: decryptSafe(invite.createdBy.displayName) ?? "Admin",
expiresAt: invite.expiresAt,
hasRecovery: !!invite.recoveryHash,
});
}
);
// claim invite
app.post<{ Params: { token: string } }>(
"/admin/join/:token",
{ config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } },
async (req, reply) => {
const body = claimBody.parse(req.body);
const tokenH = hashToken(req.params.token);
const sessionToken = randomBytes(32).toString("hex");
const sessionHash = hashToken(sessionToken);
const encDisplayName = encrypt(body.displayName, masterKey);
const encTeamTitle = body.teamTitle ? encrypt(body.teamTitle, masterKey) : null;
let result;
try {
result = await prisma.$transaction(async (tx) => {
const invite = await tx.teamInvite.findUnique({ where: { tokenHash: tokenH } });
if (!invite || invite.claimedAt || invite.expiresAt < new Date()) {
throw new Error("INVITE_INVALID");
}
const user = await tx.user.create({
data: {
displayName: encDisplayName,
authMethod: "COOKIE",
tokenHash: sessionHash,
},
});
const admin = await tx.adminUser.create({
data: {
role: invite.role,
displayName: encDisplayName,
teamTitle: encTeamTitle,
linkedUserId: user.id,
invitedById: invite.createdById,
},
});
await tx.teamInvite.update({
where: { id: invite.id },
data: { claimedById: admin.id, claimedAt: new Date() },
});
if (invite.recoveryHash && invite.recoveryIdx) {
await tx.recoveryCode.create({
data: {
codeHash: invite.recoveryHash,
phraseIdx: invite.recoveryIdx,
userId: user.id,
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
},
});
}
return { userId: user.id, adminId: admin.id, hasRecovery: !!invite.recoveryHash };
}, { isolationLevel: "Serializable" });
} catch (err: any) {
if (err.message === "INVITE_INVALID") {
reply.status(404).send({ error: "Invalid or expired invite" });
return;
}
throw err;
}
const adminJwt = jwt.sign(
{ sub: result.adminId, type: "admin", aud: "echoboard:admin", iss: "echoboard" },
config.JWT_SECRET,
{ expiresIn: "24h" }
);
const userJwt = jwt.sign(
{ sub: result.userId, type: "passkey", aud: "echoboard:user", iss: "echoboard" },
config.JWT_SECRET,
{ expiresIn: "24h" }
);
reply
.setCookie("echoboard_token", sessionToken, {
path: "/", httpOnly: true, sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 90,
})
.setCookie("echoboard_admin", adminJwt, {
path: "/", httpOnly: true, sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24,
})
.setCookie("echoboard_passkey", userJwt, {
path: "/", httpOnly: true, sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24,
})
.send({ ok: true, needsSetup: !result.hasRecovery });
}
);
}

View File

@@ -0,0 +1,125 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
const fieldSchema = z.object({
key: z.string().min(1).max(50).regex(/^[a-zA-Z_][a-zA-Z0-9_ ]*$/, "Invalid field key"),
label: z.string().min(1).max(100),
type: z.enum(["text", "textarea", "select"]),
required: z.boolean(),
placeholder: z.string().max(200).optional(),
options: z.array(z.string().max(100)).optional(),
});
const createBody = z.object({
name: z.string().min(1).max(100).trim(),
fields: z.array(fieldSchema).min(1).max(30),
isDefault: z.boolean().default(false),
});
const updateBody = z.object({
name: z.string().min(1).max(100).trim().optional(),
fields: z.array(fieldSchema).min(1).max(30).optional(),
isDefault: z.boolean().optional(),
position: z.number().int().min(0).optional(),
});
export default async function adminTemplateRoutes(app: FastifyInstance) {
app.get<{ Params: { boardId: string } }>(
"/admin/boards/:boardId/templates",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board) return reply.status(404).send({ error: "Board not found" });
const templates = await prisma.boardTemplate.findMany({
where: { boardId: board.id },
orderBy: { position: "asc" },
});
reply.send({ templates });
}
);
app.post<{ Params: { boardId: string }; Body: z.infer<typeof createBody> }>(
"/admin/boards/:boardId/templates",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board) return reply.status(404).send({ error: "Board not found" });
const body = createBody.parse(req.body);
const maxPos = await prisma.boardTemplate.aggregate({
where: { boardId: board.id },
_max: { position: true },
});
const nextPos = (maxPos._max.position ?? -1) + 1;
// if setting as default, unset others
if (body.isDefault) {
await prisma.boardTemplate.updateMany({
where: { boardId: board.id, isDefault: true },
data: { isDefault: false },
});
}
const template = await prisma.boardTemplate.create({
data: {
boardId: board.id,
name: body.name,
fields: body.fields as any,
isDefault: body.isDefault,
position: nextPos,
},
});
req.log.info({ adminId: req.adminId, templateId: template.id, boardId: board.id }, "template created");
reply.status(201).send(template);
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBody> }>(
"/admin/templates/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const existing = await prisma.boardTemplate.findUnique({ where: { id: req.params.id } });
if (!existing) return reply.status(404).send({ error: "Template not found" });
const body = updateBody.parse(req.body);
if (body.isDefault) {
await prisma.boardTemplate.updateMany({
where: { boardId: existing.boardId, isDefault: true, id: { not: existing.id } },
data: { isDefault: false },
});
}
const updated = await prisma.boardTemplate.update({
where: { id: existing.id },
data: {
...(body.name !== undefined && { name: body.name }),
...(body.fields !== undefined && { fields: body.fields as any }),
...(body.isDefault !== undefined && { isDefault: body.isDefault }),
...(body.position !== undefined && { position: body.position }),
},
});
req.log.info({ adminId: req.adminId, templateId: existing.id }, "template updated");
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/templates/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const existing = await prisma.boardTemplate.findUnique({ where: { id: req.params.id } });
if (!existing) return reply.status(404).send({ error: "Template not found" });
await prisma.boardTemplate.delete({ where: { id: existing.id } });
req.log.info({ adminId: req.adminId, templateId: existing.id, boardId: existing.boardId }, "template deleted");
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,144 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "node:crypto";
import { encrypt } from "../../services/encryption.js";
import { masterKey } from "../../config.js";
import { isAllowedUrl, resolvedIpIsAllowed } from "../../services/webhooks.js";
import { prisma } from "../../lib/prisma.js";
const VALID_EVENTS = ["status_changed", "post_created", "comment_added"] as const;
const httpsUrl = z.string().url().max(500).refine((val) => {
try {
return new URL(val).protocol === "https:";
} catch { return false; }
}, { message: "Only HTTPS URLs are allowed" });
const createBody = z.object({
url: httpsUrl,
events: z.array(z.enum(VALID_EVENTS)).min(1),
});
const updateBody = z.object({
url: httpsUrl.optional(),
events: z.array(z.enum(VALID_EVENTS)).min(1).optional(),
active: z.boolean().optional(),
});
export default async function adminWebhookRoutes(app: FastifyInstance) {
app.get(
"/admin/webhooks",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (_req, reply) => {
const webhooks = await prisma.webhook.findMany({ orderBy: { createdAt: "desc" }, take: 100 });
reply.send({
webhooks: webhooks.map((w) => ({
id: w.id,
url: w.url,
events: w.events,
active: w.active,
createdAt: w.createdAt,
})),
});
}
);
app.post<{ Body: z.infer<typeof createBody> }>(
"/admin/webhooks",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = createBody.parse(req.body);
if (!isAllowedUrl(body.url)) {
reply.status(400).send({ error: "URL not allowed" });
return;
}
const createHostname = new URL(body.url).hostname;
if (!await resolvedIpIsAllowed(createHostname)) {
reply.status(400).send({ error: "URL resolves to a disallowed address" });
return;
}
const plainSecret = randomBytes(32).toString("hex");
const encryptedSecret = encrypt(plainSecret, masterKey);
const webhook = await prisma.webhook.create({
data: {
url: body.url,
secret: encryptedSecret,
events: body.events,
},
});
req.log.info({ adminId: req.adminId, webhookId: webhook.id }, "webhook created");
// return plaintext secret only on creation - admin needs it to verify signatures
reply.status(201).send({
id: webhook.id,
url: webhook.url,
events: webhook.events,
active: webhook.active,
secret: plainSecret,
createdAt: webhook.createdAt,
});
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBody> }>(
"/admin/webhooks/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const wh = await prisma.webhook.findUnique({ where: { id: req.params.id } });
if (!wh) {
reply.status(404).send({ error: "Webhook not found" });
return;
}
const body = updateBody.parse(req.body);
if (body.url) {
if (!isAllowedUrl(body.url)) {
reply.status(400).send({ error: "URL not allowed" });
return;
}
const updateHostname = new URL(body.url).hostname;
if (!await resolvedIpIsAllowed(updateHostname)) {
reply.status(400).send({ error: "URL resolves to a disallowed address" });
return;
}
}
const updated = await prisma.webhook.update({
where: { id: wh.id },
data: {
...(body.url !== undefined && { url: body.url }),
...(body.events !== undefined && { events: body.events }),
...(body.active !== undefined && { active: body.active }),
},
});
req.log.info({ adminId: req.adminId, webhookId: wh.id }, "webhook updated");
reply.send({
id: updated.id,
url: updated.url,
events: updated.events,
active: updated.active,
createdAt: updated.createdAt,
});
}
);
app.delete<{ Params: { id: string } }>(
"/admin/webhooks/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const wh = await prisma.webhook.findUnique({ where: { id: req.params.id } });
if (!wh) {
reply.status(404).send({ error: "Webhook not found" });
return;
}
await prisma.webhook.delete({ where: { id: wh.id } });
req.log.info({ adminId: req.adminId, webhookId: wh.id }, "webhook deleted");
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,170 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
import { existsSync, mkdirSync, createReadStream } from "node:fs";
import { unlink, writeFile, realpath } from "node:fs/promises";
import { resolve, extname, sep } from "node:path";
import { randomBytes } from "node:crypto";
const UPLOAD_DIR = resolve(process.cwd(), "uploads");
const MAX_SIZE = 5 * 1024 * 1024;
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
const MAGIC_BYTES: Record<string, number[][]> = {
"image/jpeg": [[0xFF, 0xD8, 0xFF]],
"image/png": [[0x89, 0x50, 0x4E, 0x47]],
"image/gif": [[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]],
"image/webp": [[0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50]],
};
function validateMagicBytes(buf: Buffer, mime: string): boolean {
const sigs = MAGIC_BYTES[mime];
if (!sigs) return false;
return sigs.some((sig) => sig.every((byte, i) => byte === -1 || buf[i] === byte));
}
function sanitizeFilename(name: string): string {
return name.replace(/[\/\\:*?"<>|\x00-\x1F]/g, "_").slice(0, 200);
}
function uid() {
return randomBytes(16).toString("hex");
}
export default async function attachmentRoutes(app: FastifyInstance) {
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
app.post(
"/attachments",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
async (req, reply) => {
const data = await req.file();
if (!data) {
reply.status(400).send({ error: "No file uploaded" });
return;
}
const ext = extname(data.filename).toLowerCase();
if (!ALLOWED_EXT.has(ext) || !ALLOWED_MIME.has(data.mimetype)) {
reply.status(400).send({ error: "Only jpg, png, gif, webp images are allowed" });
return;
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of data.file) {
size += chunk.length;
if (size > MAX_SIZE) {
reply.status(400).send({ error: "File too large (max 5MB)" });
return;
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
if (!validateMagicBytes(buffer, data.mimetype)) {
reply.status(400).send({ error: "File content does not match declared type" });
return;
}
const safeName = sanitizeFilename(data.filename);
const storedName = `${uid()}${ext}`;
const filePath = resolve(UPLOAD_DIR, storedName);
if (!filePath.startsWith(UPLOAD_DIR)) {
reply.status(400).send({ error: "Invalid file path" });
return;
}
await writeFile(filePath, buffer);
let attachment;
try {
attachment = await prisma.attachment.create({
data: {
filename: safeName,
path: storedName,
size,
mimeType: data.mimetype,
uploaderId: req.user!.id,
},
});
} catch (err) {
await unlink(filePath).catch(() => {});
throw err;
}
reply.status(201).send(attachment);
}
);
app.get<{ Params: { id: string } }>(
"/attachments/:id",
{ config: { rateLimit: { max: 200, timeWindow: "1 minute" } } },
async (req, reply) => {
const attachment = await prisma.attachment.findUnique({ where: { id: req.params.id } });
if (!attachment) {
reply.status(404).send({ error: "Attachment not found" });
return;
}
const filePath = resolve(UPLOAD_DIR, attachment.path);
let realFile: string;
try {
realFile = await realpath(filePath);
} catch {
reply.status(404).send({ error: "File missing" });
return;
}
const realUpload = await realpath(UPLOAD_DIR);
if (!realFile.startsWith(realUpload + sep)) {
reply.status(404).send({ error: "File missing" });
return;
}
const safeName = sanitizeFilename(attachment.filename).replace(/"/g, "");
reply
.header("Content-Type", attachment.mimeType)
.header("Content-Disposition", `inline; filename="${safeName}"`)
.header("Cache-Control", "public, max-age=31536000, immutable")
.header("X-Content-Type-Options", "nosniff")
.send(createReadStream(filePath));
}
);
app.delete<{ Params: { id: string } }>(
"/attachments/:id",
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const attachment = await prisma.attachment.findUnique({ where: { id: req.params.id } });
if (!attachment) {
reply.status(404).send({ error: "Attachment not found" });
return;
}
if (attachment.uploaderId !== req.user!.id && !req.adminId) {
reply.status(403).send({ error: "Not your attachment" });
return;
}
const filePath = resolve(UPLOAD_DIR, attachment.path);
let realFile: string;
try {
realFile = await realpath(filePath);
} catch {
await prisma.attachment.delete({ where: { id: attachment.id } });
reply.status(204).send();
return;
}
const realUpload = await realpath(UPLOAD_DIR);
if (!realFile.startsWith(realUpload + sep)) {
reply.status(400).send({ error: "Invalid file path" });
return;
}
await unlink(realFile).catch(() => {});
await prisma.attachment.delete({ where: { id: attachment.id } });
reply.status(204).send();
}
);
}

View File

@@ -0,0 +1,175 @@
import { FastifyInstance } from "fastify";
import { existsSync, mkdirSync, createReadStream } from "node:fs";
import { unlink, writeFile, realpath } from "node:fs/promises";
import { resolve, extname, sep } from "node:path";
import { randomBytes } from "node:crypto";
import prisma from "../lib/prisma.js";
const UPLOAD_DIR = resolve(process.cwd(), "uploads", "avatars");
const MAX_SIZE = 2 * 1024 * 1024;
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const MAGIC_BYTES: Record<string, number[][]> = {
"image/jpeg": [[0xFF, 0xD8, 0xFF]],
"image/png": [[0x89, 0x50, 0x4E, 0x47]],
"image/webp": [[0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50]],
};
function validateMagicBytes(buf: Buffer, mime: string): boolean {
const sigs = MAGIC_BYTES[mime];
if (!sigs) return false;
return sigs.some((sig) => sig.every((byte, i) => byte === -1 || buf[i] === byte));
}
export default async function avatarRoutes(app: FastifyInstance) {
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
app.post(
"/me/avatar",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
async (req, reply) => {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
if (!user) {
reply.status(403).send({ error: "Not authenticated" });
return;
}
// allow passkey users and admin-linked users
if (user.authMethod !== "PASSKEY") {
const isTeamMember = await prisma.adminUser.findUnique({ where: { linkedUserId: user.id }, select: { id: true } });
if (!isTeamMember) {
reply.status(403).send({ error: "Save your identity with a passkey to upload avatars" });
return;
}
}
const data = await req.file();
if (!data) {
reply.status(400).send({ error: "No file uploaded" });
return;
}
const ext = extname(data.filename).toLowerCase();
if (!ALLOWED_EXT.has(ext) || !ALLOWED_MIME.has(data.mimetype)) {
reply.status(400).send({ error: "Only jpg, png, webp images are allowed" });
return;
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of data.file) {
size += chunk.length;
if (size > MAX_SIZE) {
reply.status(400).send({ error: "File too large (max 2MB)" });
return;
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
if (!validateMagicBytes(buffer, data.mimetype)) {
reply.status(400).send({ error: "File content does not match declared type" });
return;
}
// delete old avatar if exists (with traversal check)
if (user.avatarPath) {
const oldPath = resolve(UPLOAD_DIR, user.avatarPath);
try {
const realOld = await realpath(oldPath);
const realUpload = await realpath(UPLOAD_DIR);
if (realOld.startsWith(realUpload + sep)) {
await unlink(realOld).catch(() => {});
}
} catch {}
}
const storedName = `${randomBytes(16).toString("hex")}${ext}`;
const filePath = resolve(UPLOAD_DIR, storedName);
if (!filePath.startsWith(UPLOAD_DIR)) {
reply.status(400).send({ error: "Invalid file path" });
return;
}
await writeFile(filePath, buffer);
await prisma.user.update({
where: { id: user.id },
data: { avatarPath: storedName },
});
reply.send({ avatarUrl: `/api/v1/avatars/${user.id}` });
}
);
app.delete(
"/me/avatar",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
if (!user || !user.avatarPath) {
reply.status(204).send();
return;
}
const filePath = resolve(UPLOAD_DIR, user.avatarPath);
try {
const realFile = await realpath(filePath);
const realUpload = await realpath(UPLOAD_DIR);
if (realFile.startsWith(realUpload + sep)) {
await unlink(realFile).catch(() => {});
}
} catch {}
await prisma.user.update({
where: { id: user.id },
data: { avatarPath: null },
});
reply.status(204).send();
}
);
app.get<{ Params: { userId: string } }>(
"/avatars/:userId",
{ config: { rateLimit: { max: 200, timeWindow: "1 minute" } } },
async (req, reply) => {
const user = await prisma.user.findUnique({
where: { id: req.params.userId },
select: { avatarPath: true },
});
if (!user?.avatarPath) {
reply.status(404).send({ error: "No avatar" });
return;
}
const filePath = resolve(UPLOAD_DIR, user.avatarPath);
let realFile: string;
try {
realFile = await realpath(filePath);
} catch {
reply.status(404).send({ error: "File missing" });
return;
}
const realUpload = await realpath(UPLOAD_DIR);
if (!realFile.startsWith(realUpload + sep)) {
reply.status(404).send({ error: "File missing" });
return;
}
const ext = extname(user.avatarPath).toLowerCase();
const mimeMap: Record<string, string> = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp",
};
reply
.header("Content-Type", mimeMap[ext] || "application/octet-stream")
.header("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
.header("X-Content-Type-Options", "nosniff")
.send(createReadStream(filePath));
}
);
}

View File

@@ -1,35 +1,103 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client"; import { z } from "zod";
import prisma from "../lib/prisma.js";
import { encrypt, blindIndex } from "../services/encryption.js";
import { masterKey, blindIndexKey } from "../config.js";
const prisma = new PrismaClient(); const PUSH_DOMAINS = [
"fcm.googleapis.com", "updates.push.services.mozilla.com",
"notify.windows.com", "push.apple.com", "web.push.apple.com",
];
function isValidPushEndpoint(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") return false;
return PUSH_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith("." + d));
} catch { return false; }
}
const subscribeBody = z.object({
endpoint: z.string().url().refine(isValidPushEndpoint, "Must be a known push service endpoint"),
keys: z.object({
p256dh: z.string().min(1).max(200),
auth: z.string().min(1).max(200),
}),
});
const DEFAULT_STATUS_CONFIG: { status: string; label: string; color: string; position: number; enabled: boolean }[] = [
{ status: "OPEN", label: "Open", color: "#F59E0B", position: 0, enabled: true },
{ status: "UNDER_REVIEW", label: "Under Review", color: "#06B6D4", position: 1, enabled: true },
{ status: "PLANNED", label: "Planned", color: "#3B82F6", position: 2, enabled: true },
{ status: "IN_PROGRESS", label: "In Progress", color: "#EAB308", position: 3, enabled: true },
{ status: "DONE", label: "Done", color: "#22C55E", position: 4, enabled: true },
{ status: "DECLINED", label: "Declined", color: "#EF4444", position: 5, enabled: true },
];
async function getStatusConfig(boardId: string) {
const config = await prisma.boardStatus.findMany({
where: { boardId },
orderBy: { position: "asc" },
});
if (config.length === 0) return DEFAULT_STATUS_CONFIG;
return config.map((s) => ({
status: s.status,
label: s.label,
color: s.color,
position: s.position,
enabled: s.enabled,
}));
}
export default async function boardRoutes(app: FastifyInstance) { export default async function boardRoutes(app: FastifyInstance) {
app.get("/boards", async (_req, reply) => { app.get("/boards", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (_req, reply) => {
const boards = await prisma.board.findMany({ const boards = await prisma.board.findMany({
where: { isArchived: false },
include: { include: {
_count: { select: { posts: true } }, _count: { select: { posts: true } },
}, },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
// use aggregation queries instead of loading all posts into memory
const boardIds = boards.map((b) => b.id);
const [openCounts, lastActivities] = await Promise.all([
prisma.post.groupBy({
by: ["boardId"],
where: { boardId: { in: boardIds }, status: { in: ["OPEN", "UNDER_REVIEW"] } },
_count: true,
}),
prisma.post.groupBy({
by: ["boardId"],
where: { boardId: { in: boardIds } },
_max: { updatedAt: true },
}),
]);
const openMap = new Map(openCounts.map((r) => [r.boardId, r._count]));
const activityMap = new Map(lastActivities.map((r) => [r.boardId, r._max.updatedAt]));
const result = boards.map((b) => ({ const result = boards.map((b) => ({
id: b.id, id: b.id,
slug: b.slug, slug: b.slug,
name: b.name, name: b.name,
description: b.description, description: b.description,
externalUrl: b.externalUrl, externalUrl: b.externalUrl,
iconName: b.iconName,
iconColor: b.iconColor,
voteBudget: b.voteBudget, voteBudget: b.voteBudget,
voteBudgetReset: b.voteBudgetReset, voteBudgetReset: b.voteBudgetReset,
allowMultiVote: b.allowMultiVote, allowMultiVote: b.allowMultiVote,
postCount: b._count.posts, postCount: b._count.posts,
openCount: openMap.get(b.id) ?? 0,
lastActivity: activityMap.get(b.id)?.toISOString() ?? null,
archived: b.isArchived,
createdAt: b.createdAt, createdAt: b.createdAt,
})); }));
reply.send(result); reply.send(result);
}); });
app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", async (req, reply) => { app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => {
const board = await prisma.board.findUnique({ const board = await prisma.board.findUnique({
where: { slug: req.params.boardSlug }, where: { slug: req.params.boardSlug },
include: { include: {
@@ -46,19 +114,99 @@ export default async function boardRoutes(app: FastifyInstance) {
return; return;
} }
const statuses = await getStatusConfig(board.id);
reply.send({ reply.send({
id: board.id, id: board.id,
slug: board.slug, slug: board.slug,
name: board.name, name: board.name,
description: board.description, description: board.description,
externalUrl: board.externalUrl, externalUrl: board.externalUrl,
iconName: board.iconName,
iconColor: board.iconColor,
isArchived: board.isArchived, isArchived: board.isArchived,
voteBudget: board.voteBudget, voteBudget: board.voteBudget,
voteBudgetReset: board.voteBudgetReset, voteBudgetReset: board.voteBudgetReset,
allowMultiVote: board.allowMultiVote, allowMultiVote: board.allowMultiVote,
postCount: board._count.posts, postCount: board._count.posts,
statuses: statuses.filter((s) => s.enabled),
createdAt: board.createdAt, createdAt: board.createdAt,
updatedAt: board.updatedAt, updatedAt: board.updatedAt,
}); });
}); });
app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug/statuses", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, 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 statuses = await getStatusConfig(board.id);
reply.send({ statuses: statuses.filter((s) => s.enabled) });
});
// Check board subscription status
app.get<{ Params: { slug: string } }>(
"/boards/:slug/subscription",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.slug } });
if (!board) return reply.status(404).send({ error: "Board not found" });
const sub = await prisma.pushSubscription.findFirst({
where: { userId: req.user!.id, boardId: board.id, postId: null },
});
reply.send({ subscribed: !!sub });
}
);
// Subscribe to board
app.post<{ Params: { slug: string } }>(
"/boards/:slug/subscribe",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.slug } });
if (!board) return reply.status(404).send({ error: "Board not found" });
const body = subscribeBody.parse(req.body);
const endpointEnc = encrypt(body.endpoint, masterKey);
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
const keysP256dh = encrypt(body.keys.p256dh, masterKey);
const keysAuth = encrypt(body.keys.auth, masterKey);
await prisma.pushSubscription.upsert({
where: { endpointIdx },
create: {
endpoint: endpointEnc,
endpointIdx,
keysP256dh,
keysAuth,
userId: req.user!.id,
boardId: board.id,
postId: null,
},
update: { keysP256dh, keysAuth, boardId: board.id, postId: null },
});
reply.send({ subscribed: true });
}
);
// Unsubscribe from board
app.delete<{ Params: { slug: string } }>(
"/boards/:slug/subscribe",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.slug } });
if (!board) return reply.status(404).send({ error: "Board not found" });
await prisma.pushSubscription.deleteMany({
where: { userId: req.user!.id, boardId: board.id, postId: null },
});
reply.send({ subscribed: false });
}
);
} }

View File

@@ -0,0 +1,26 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
export default async function changelogRoutes(app: FastifyInstance) {
const handler = async (req: any, reply: any) => {
const boardSlug = req.params?.boardSlug || (req.query as any)?.board || null;
const where: any = { publishedAt: { lte: new Date() } };
if (boardSlug) {
where.board = { slug: boardSlug };
}
const entries = await prisma.changelogEntry.findMany({
where,
include: { board: { select: { slug: true, name: true } } },
orderBy: { publishedAt: "desc" },
take: 50,
});
reply.send({ entries });
};
const opts = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } };
app.get("/changelog", opts, handler);
app.get("/b/:boardSlug/changelog", opts, handler);
}

View File

@@ -1,28 +1,54 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import prisma from "../lib/prisma.js";
import { verifyChallenge } from "../services/altcha.js"; import { verifyChallenge } from "../services/altcha.js";
import { decrypt, blindIndex } from "../services/encryption.js";
import { masterKey, blindIndexKey } from "../config.js";
import { notifyPostSubscribers, notifyUserReply } from "../services/push.js";
const prisma = new PrismaClient(); const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
function cleanAuthor(author: { id: string; displayName: string | null; avatarPath?: string | null } | null) {
if (!author) return null;
let name: string | null = null;
if (author.displayName) { try { name = decrypt(author.displayName, masterKey); } catch {} }
return {
id: author.id,
displayName: name,
avatarUrl: author.avatarPath ? `/api/v1/avatars/${author.id}` : null,
};
}
const createCommentSchema = z.object({ const createCommentSchema = z.object({
body: z.string().min(1).max(5000), body: z.string().min(1).max(2000),
altcha: z.string(), altcha: z.string().optional(),
replyToId: z.string().max(30).optional(),
attachmentIds: z.array(z.string()).max(10).optional(),
}); });
const updateCommentSchema = z.object({ const updateCommentSchema = z.object({
body: z.string().min(1).max(5000), body: z.string().min(1).max(2000),
}); });
export default async function commentRoutes(app: FastifyInstance) { export default async function commentRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string; id: string }; Querystring: { page?: string } }>( app.get<{ Params: { boardSlug: string; id: string }; Querystring: { page?: string } }>(
"/boards/:boardSlug/posts/:id/comments", "/boards/:boardSlug/posts/:id/comments",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const page = Math.max(1, parseInt(req.query.page ?? "1", 10)); const parsed = parseInt(req.query.page ?? "1", 10);
const page = Math.max(1, Math.min(500, Number.isNaN(parsed) ? 1 : parsed));
const limit = 50; const limit = 50;
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 } }); const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) { if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" }); reply.status(404).send({ error: "Post not found" });
return; return;
} }
@@ -31,29 +57,43 @@ export default async function commentRoutes(app: FastifyInstance) {
prisma.comment.findMany({ prisma.comment.findMany({
where: { postId: post.id }, where: { postId: post.id },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
skip: (page - 1) * limit, skip: Math.min((page - 1) * limit, 50000),
take: limit, take: limit,
include: { include: {
author: { select: { id: true, displayName: true } }, author: { select: { id: true, displayName: true, avatarPath: true } },
reactions: { reactions: {
select: { emoji: true, userId: true }, select: { emoji: true, userId: true },
}, },
replyTo: {
select: {
id: true,
body: true,
isAdmin: true,
author: { select: { id: true, displayName: true, avatarPath: true } },
},
},
}, },
}), }),
prisma.comment.count({ where: { postId: post.id } }), prisma.comment.count({ where: { postId: post.id } }),
]); ]);
const grouped = comments.map((c) => { const grouped = comments.map((c) => {
const reactionMap: Record<string, { count: number; userIds: string[] }> = {}; const reactionMap: Record<string, { count: number }> = {};
for (const r of c.reactions) { for (const r of c.reactions) {
if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, userIds: [] }; if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0 };
reactionMap[r.emoji].count++; reactionMap[r.emoji].count++;
reactionMap[r.emoji].userIds.push(r.userId);
} }
return { return {
id: c.id, id: c.id,
body: c.body, body: c.body,
author: c.author, isAdmin: c.isAdmin,
author: cleanAuthor(c.author),
replyTo: c.replyTo ? {
id: c.replyTo.id,
body: c.replyTo.body.slice(0, 200),
isAdmin: c.replyTo.isAdmin,
authorName: cleanAuthor(c.replyTo.author)?.displayName ?? `Anonymous #${(c.replyTo.author?.id ?? "0000").slice(-4)}`,
} : null,
reactions: reactionMap, reactions: reactionMap,
createdAt: c.createdAt, createdAt: c.createdAt,
updatedAt: c.updatedAt, updatedAt: c.updatedAt,
@@ -66,84 +106,316 @@ export default async function commentRoutes(app: FastifyInstance) {
app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof createCommentSchema> }>( app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof createCommentSchema> }>(
"/boards/:boardSlug/posts/:id/comments", "/boards/:boardSlug/posts/:id/comments",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 20, timeWindow: "1 hour" } } },
async (req, reply) => { 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;
}
if (board.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
const post = await prisma.post.findUnique({ where: { id: req.params.id } }); const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) { if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" }); reply.status(404).send({ error: "Post not found" });
return; return;
} }
if (post.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const body = createCommentSchema.parse(req.body); const body = createCommentSchema.parse(req.body);
const valid = await verifyChallenge(body.altcha);
if (!valid) { // admins skip ALTCHA, regular users must provide it
reply.status(400).send({ error: "Invalid challenge response" }); if (!req.adminId) {
return; if (!body.altcha) {
reply.status(400).send({ error: "Challenge response required" });
return;
}
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
} }
// validate replyToId belongs to the same post
if (body.replyToId) {
const target = await prisma.comment.findUnique({
where: { id: body.replyToId },
select: { postId: true },
});
if (!target || target.postId !== post.id) {
reply.status(400).send({ error: "Invalid reply target" });
return;
}
}
const cleanBody = body.body.replace(INVISIBLE_RE, '');
const isAdmin = !!req.adminId;
const comment = await prisma.comment.create({ const comment = await prisma.comment.create({
data: { data: {
body: body.body, body: cleanBody,
postId: post.id, postId: post.id,
authorId: req.user!.id, authorId: req.user!.id,
replyToId: body.replyToId ?? null,
isAdmin,
adminUserId: req.adminId ?? null,
}, },
include: { include: {
author: { select: { id: true, displayName: true } }, author: { select: { id: true, displayName: true, avatarPath: true } },
replyTo: {
select: {
id: true,
body: true,
isAdmin: true,
authorId: true,
author: { select: { id: true, displayName: true, avatarPath: true } },
},
},
}, },
}); });
await prisma.activityEvent.create({ if (body.attachmentIds?.length) {
data: { await prisma.attachment.updateMany({
type: "comment_created", where: {
boardId: post.boardId, id: { in: body.attachmentIds },
postId: post.id, uploaderId: req.user!.id,
metadata: {}, postId: null,
}, commentId: null,
}); },
data: { commentId: comment.id },
});
}
reply.status(201).send(comment); await Promise.all([
prisma.activityEvent.create({
data: {
type: isAdmin ? "admin_responded" : "comment_created",
boardId: post.boardId,
postId: post.id,
metadata: {},
},
}),
prisma.post.update({
where: { id: post.id },
data: { lastActivityAt: new Date() },
}),
]);
// in-app notification: notify post author about new comment (unless they wrote it)
if (post.authorId !== req.user!.id) {
await prisma.notification.create({
data: {
type: isAdmin ? "admin_response" : "comment",
title: isAdmin ? "Official response" : "New comment",
body: `${isAdmin ? "Admin commented on" : "Someone commented on"} "${post.title}"`,
postId: post.id,
userId: post.authorId,
},
});
}
// in-app notification: notify the replied-to user
if (comment.replyTo && comment.replyTo.authorId !== req.user!.id) {
await prisma.notification.create({
data: {
type: "reply",
title: isAdmin ? "Admin replied to you" : "New reply",
body: `${isAdmin ? "Admin" : "Someone"} replied to your comment on "${post.title}"`,
postId: post.id,
userId: comment.replyTo.authorId,
},
});
}
// push notify post subscribers for admin comments
if (isAdmin) {
await notifyPostSubscribers(post.id, {
title: "Official response",
body: body.body.slice(0, 100),
url: `/post/${post.id}`,
tag: `response-${post.id}`,
});
}
// push notify the quoted user if this is a reply
if (comment.replyTo && comment.replyTo.authorId !== req.user!.id) {
await notifyUserReply(comment.replyTo.authorId, {
title: isAdmin ? "Admin replied to your comment" : "Someone replied to your comment",
body: body.body.slice(0, 100),
url: `/post/${post.id}`,
tag: `reply-${comment.id}`,
});
}
// @mention notifications
const mentionRe = /@([a-zA-Z0-9_]{3,30})\b/g;
const mentions = [...new Set(Array.from(cleanBody.matchAll(mentionRe), (m) => m[1]))];
if (mentions.length > 0) {
const mentioned: string[] = [];
for (const name of mentions.slice(0, 10)) {
const idx = blindIndex(name, blindIndexKey);
const found = await prisma.user.findUnique({ where: { usernameIdx: idx }, select: { id: true } });
if (found && found.id !== req.user!.id) mentioned.push(found.id);
}
if (mentioned.length > 0) {
await prisma.notification.createMany({
data: mentioned.map((userId) => ({
type: "mention",
title: "You were mentioned",
body: `Mentioned in a comment on "${post.title}"`,
postId: post.id,
userId,
})),
});
for (const uid of mentioned) {
notifyUserReply(uid, {
title: "You were mentioned",
body: cleanBody.slice(0, 100),
url: `/post/${post.id}`,
tag: `mention-${comment.id}`,
});
}
}
}
reply.status(201).send({
id: comment.id,
body: comment.body,
isAdmin: comment.isAdmin,
author: cleanAuthor(comment.author),
replyTo: comment.replyTo ? {
id: comment.replyTo.id,
body: comment.replyTo.body.slice(0, 200),
isAdmin: comment.replyTo.isAdmin,
authorName: cleanAuthor(comment.replyTo.author)?.displayName ?? `Anonymous #${(comment.replyTo.author?.id ?? "0000").slice(-4)}`,
} : null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
});
} }
); );
app.put<{ Params: { id: string }; Body: z.infer<typeof updateCommentSchema> }>( app.put<{ Params: { id: string }; Body: z.infer<typeof updateCommentSchema> }>(
"/comments/:id", "/comments/:id",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) { if (!comment) {
reply.status(404).send({ error: "Comment not found" }); reply.status(404).send({ error: "Comment not found" });
return; return;
} }
if (comment.authorId !== req.user!.id) { // admins can edit their own admin comments, users can edit their own comments
const isOwnComment = comment.authorId === req.user!.id;
const isOwnAdminComment = req.adminId && comment.isAdmin && comment.adminUserId === req.adminId;
if (!isOwnComment && !isOwnAdminComment) {
reply.status(403).send({ error: "Not your comment" }); reply.status(403).send({ error: "Not your comment" });
return; return;
} }
if (comment.isEditLocked) {
reply.status(403).send({ error: "Editing is locked on this comment" });
return;
}
const parentPost = await prisma.post.findUnique({
where: { id: comment.postId },
select: { isThreadLocked: true },
});
if (parentPost?.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const body = updateCommentSchema.parse(req.body); const body = updateCommentSchema.parse(req.body);
const cleanBody = body.body.replace(INVISIBLE_RE, '');
if (cleanBody !== comment.body) {
await prisma.editHistory.create({
data: {
commentId: comment.id,
editedBy: req.user!.id,
previousBody: comment.body,
},
});
}
const updated = await prisma.comment.update({ const updated = await prisma.comment.update({
where: { id: comment.id }, where: { id: comment.id },
data: { body: body.body }, data: { body: cleanBody },
}); });
reply.send(updated); reply.send({
id: updated.id, body: updated.body, isAdmin: updated.isAdmin,
createdAt: updated.createdAt, updatedAt: updated.updatedAt,
});
}
);
app.get<{ Params: { id: string } }>(
"/comments/:id/edits",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
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 edits = await prisma.editHistory.findMany({
where: { commentId: comment.id },
orderBy: { createdAt: "desc" },
take: 50,
include: {
editor: { select: { id: true, displayName: true, avatarPath: true } },
},
});
reply.send(edits.map((e) => ({
id: e.id,
previousBody: e.previousBody,
editedBy: cleanAuthor(e.editor),
createdAt: e.createdAt,
})));
} }
); );
app.delete<{ Params: { id: string } }>( app.delete<{ Params: { id: string } }>(
"/comments/:id", "/comments/:id",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) { if (!comment) {
reply.status(404).send({ error: "Comment not found" }); reply.status(404).send({ error: "Comment not found" });
return; return;
} }
if (comment.authorId !== req.user!.id) { const isOwnComment = comment.authorId === req.user!.id;
const isOwnAdminComment = req.adminId && comment.isAdmin && comment.adminUserId === req.adminId;
if (!isOwnComment && !isOwnAdminComment) {
reply.status(403).send({ error: "Not your comment" }); reply.status(403).send({ error: "Not your comment" });
return; return;
} }
const attachments = await prisma.attachment.findMany({
where: { commentId: comment.id },
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.comment.delete({ where: { id: comment.id } }); await prisma.comment.delete({ where: { id: comment.id } });
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
reply.status(204).send(); reply.status(204).send();
} }
); );

View File

@@ -0,0 +1,74 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
import { z } from "zod";
const VALID_STATUSES = ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS", "DONE", "DECLINED"] as const;
const embedQuery = z.object({
sort: z.enum(["newest", "top"]).default("top"),
status: z.enum(VALID_STATUSES).optional(),
limit: z.coerce.number().int().min(1).max(30).default(10),
});
export default async function embedRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string }; Querystring: Record<string, string> }>(
"/embed/:boardSlug/posts",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
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" });
return;
}
const q = embedQuery.safeParse(req.query);
if (!q.success) {
reply.status(400).send({ error: "Invalid parameters" });
return;
}
const { sort, status, limit } = q.data;
const where: Record<string, unknown> = { boardId: board.id };
if (status) where.status = status;
const orderBy = sort === "top"
? { voteCount: "desc" as const }
: { createdAt: "desc" as const };
const posts = await prisma.post.findMany({
where,
orderBy: [{ isPinned: "desc" }, orderBy],
take: limit,
select: {
id: true,
title: true,
type: true,
status: true,
voteCount: true,
isPinned: true,
createdAt: true,
_count: { select: { comments: true } },
},
});
// cache for 60s
reply.header("Cache-Control", "public, max-age=60");
reply.send({
board: { name: board.name, slug: board.slug },
posts: posts.map((p) => ({
id: p.id,
title: p.title,
type: p.type,
status: p.status,
voteCount: p.voteCount,
isPinned: p.isPinned,
commentCount: p._count.comments,
createdAt: p.createdAt,
})),
});
}
);
}

View File

@@ -1,12 +1,105 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import RSS from "rss"; import RSS from "rss";
import prisma from "../lib/prisma.js";
import { decrypt } from "../services/encryption.js";
import { masterKey, config } from "../config.js";
const prisma = new PrismaClient(); function decryptName(v: string | null): string | null {
if (!v) return null;
try { return decrypt(v, masterKey); } catch { return null; }
}
function stripHtml(s: string): string {
return s.replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c] || c));
}
function excerpt(description: Record<string, unknown>, maxLen = 300): string {
const text = Object.values(description)
.filter((v) => typeof v === "string")
.join(" ")
.replace(/\s+/g, " ")
.trim();
const trimmed = text.length > maxLen ? text.slice(0, maxLen) + "..." : text;
return stripHtml(trimmed);
}
interface FeedItem {
title: string;
description: string;
author: string;
url: string;
date: Date;
guid: string;
categories?: string[];
}
async function buildBoardFeedItems(boardId: string, boardSlug: string, baseUrl: string, itemCount = 50): Promise<FeedItem[]> {
const [posts, statusChanges, adminComments] = await Promise.all([
prisma.post.findMany({
where: { boardId },
orderBy: { createdAt: "desc" },
take: itemCount,
include: { author: { select: { id: true, displayName: true, avatarPath: true } } },
}),
prisma.statusChange.findMany({
where: { post: { boardId } },
orderBy: { createdAt: "desc" },
take: Math.ceil(itemCount / 2),
include: { post: { select: { id: true, title: true } } },
}),
prisma.comment.findMany({
where: { post: { boardId }, isAdmin: true },
orderBy: { createdAt: "desc" },
take: Math.ceil(itemCount / 2),
include: { post: { select: { id: true, title: true } } },
}),
]);
const items: FeedItem[] = [];
for (const post of posts) {
const typeLabel = post.type === "BUG_REPORT" ? "Bug Report" : "Feature Request";
items.push({
title: `[${typeLabel}] ${post.title}`,
description: `${excerpt(post.description as Record<string, unknown>)} (${post.voteCount} votes)`,
author: stripHtml(decryptName(post.author?.displayName ?? null) ?? `Anonymous #${(post.author?.id ?? "0000").slice(-4)}`),
url: `${baseUrl}/b/${boardSlug}/post/${post.id}`,
date: post.createdAt,
guid: post.id,
categories: [typeLabel, ...(post.category ? [post.category] : [])],
});
}
for (const sc of statusChanges) {
items.push({
title: `Status: ${sc.post.title} - ${sc.fromStatus} to ${sc.toStatus}`,
description: `"${sc.post.title}" moved from ${sc.fromStatus} to ${sc.toStatus}`,
author: "Admin",
url: `${baseUrl}/b/${boardSlug}/post/${sc.postId}`,
date: sc.createdAt,
guid: `${sc.postId}-status-${sc.id}`,
});
}
for (const ac of adminComments) {
items.push({
title: `Official response: ${ac.post.title}`,
description: stripHtml(ac.body.length > 300 ? ac.body.slice(0, 300) + "..." : ac.body),
author: "Admin",
url: `${baseUrl}/b/${boardSlug}/post/${ac.postId}`,
date: ac.createdAt,
guid: `${ac.postId}-response-${ac.id}`,
});
}
items.sort((a, b) => b.date.getTime() - a.date.getTime());
return items.slice(0, itemCount);
}
export default async function feedRoutes(app: FastifyInstance) { export default async function feedRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string } }>( app.get<{ Params: { boardSlug: string } }>(
"/boards/:boardSlug/feed.rss", "/boards/:boardSlug/feed.rss",
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) { if (!board) {
@@ -14,54 +107,59 @@ export default async function feedRoutes(app: FastifyInstance) {
return; return;
} }
const posts = await prisma.post.findMany({ if (!board.rssEnabled) {
where: { boardId: board.id }, reply.status(404).send({ error: "RSS feed disabled for this board" });
orderBy: { createdAt: "desc" }, return;
take: 50, }
});
const baseUrl = config.WEBAUTHN_ORIGIN;
const items = await buildBoardFeedItems(board.id, board.slug, baseUrl, board.rssFeedCount);
const feed = new RSS({ const feed = new RSS({
title: `${board.name} - Echoboard`, title: `${board.name} - Echoboard`,
description: board.description ?? "", description: board.description ?? "",
feed_url: `${req.protocol}://${req.hostname}/api/v1/boards/${board.slug}/feed.rss`, feed_url: `${baseUrl}/api/v1/boards/${board.slug}/feed.rss`,
site_url: `${req.protocol}://${req.hostname}`, site_url: baseUrl,
}); });
for (const post of posts) { for (const item of items) {
feed.item({ feed.item(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 })); reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true }));
} }
); );
app.get("/feed.rss", async (req, reply) => { app.get("/feed.rss", { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => {
const posts = await prisma.post.findMany({ const boards = await prisma.board.findMany({
orderBy: { createdAt: "desc" }, where: { isArchived: false, rssEnabled: true },
take: 50, select: { id: true, slug: true, name: true, rssFeedCount: true },
include: { board: { select: { slug: true, name: true } } }, take: 20,
}); });
const baseUrl = config.WEBAUTHN_ORIGIN;
const allItems: FeedItem[] = [];
const boardResults = await Promise.all(
boards.map((board) =>
buildBoardFeedItems(board.id, board.slug, baseUrl, Math.min(board.rssFeedCount, 50))
.then((items) => items.map((item) => ({ ...item, title: `[${board.name}] ${item.title}` })))
)
);
for (const items of boardResults) {
allItems.push(...items);
}
allItems.sort((a, b) => b.date.getTime() - a.date.getTime());
const feed = new RSS({ const feed = new RSS({
title: "Echoboard - All Feedback", title: "Echoboard - All Feedback",
feed_url: `${req.protocol}://${req.hostname}/api/v1/feed.rss`, feed_url: `${baseUrl}/api/v1/feed.rss`,
site_url: `${req.protocol}://${req.hostname}`, site_url: baseUrl,
}); });
for (const post of posts) { for (const item of allItems.slice(0, 50)) {
feed.item({ feed.item(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 })); reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true }));

View File

@@ -1,19 +1,22 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { resolve } from "node:path";
import { unlink } from "node:fs/promises";
import { z } from "zod"; import { z } from "zod";
import { hashToken, encrypt, decrypt } from "../services/encryption.js"; import prisma from "../lib/prisma.js";
import { masterKey } from "../config.js"; import { hashToken, encrypt, decrypt, blindIndex } from "../services/encryption.js";
import { masterKey, blindIndexKey } from "../config.js";
const prisma = new PrismaClient(); import { verifyChallenge } from "../services/altcha.js";
import { blockToken } from "../lib/token-blocklist.js";
const updateMeSchema = z.object({ const updateMeSchema = z.object({
displayName: z.string().max(50).optional().nullable(), displayName: z.string().max(50).optional().nullable(),
darkMode: z.enum(["system", "light", "dark"]).optional(), darkMode: z.enum(["system", "light", "dark"]).optional(),
altcha: z.string().optional(),
}); });
export default async function identityRoutes(app: FastifyInstance) { export default async function identityRoutes(app: FastifyInstance) {
app.post("/identity", async (_req, reply) => { app.post("/identity", { config: { rateLimit: { max: 5, timeWindow: "1 hour" } } }, async (_req, reply) => {
const token = randomBytes(32).toString("hex"); const token = randomBytes(32).toString("hex");
const hash = hashToken(token); const hash = hashToken(token);
@@ -27,7 +30,7 @@ export default async function identityRoutes(app: FastifyInstance) {
httpOnly: true, httpOnly: true,
sameSite: "strict", sameSite: "strict",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365, maxAge: 60 * 60 * 24 * 90,
}) })
.status(201) .status(201)
.send({ .send({
@@ -37,15 +40,57 @@ export default async function identityRoutes(app: FastifyInstance) {
}); });
}); });
app.put<{ Body: z.infer<typeof updateMeSchema> }>( app.get(
"/me", "/me",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser] },
async (req, reply) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
include: { recoveryCode: { select: { expiresAt: true } } },
});
if (!user) {
reply.status(404).send({ error: "User not found" });
return;
}
reply.send({
id: user.id,
displayName: user.displayName ? decrypt(user.displayName, masterKey) : null,
username: user.username ? decrypt(user.username, masterKey) : null,
isPasskeyUser: user.authMethod === "PASSKEY",
avatarUrl: user.avatarPath ? `/api/v1/avatars/${user.id}` : null,
darkMode: user.darkMode,
hasRecoveryCode: !!user.recoveryCode && user.recoveryCode.expiresAt > new Date(),
recoveryCodeExpiresAt: user.recoveryCode?.expiresAt ?? null,
createdAt: user.createdAt,
});
}
);
app.put<{ Body: z.infer<typeof updateMeSchema> }>(
"/me",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const body = updateMeSchema.parse(req.body); const body = updateMeSchema.parse(req.body);
const data: Record<string, any> = {}; const data: Record<string, any> = {};
if (body.displayName !== undefined && body.displayName !== null) {
if (!body.altcha) {
reply.status(400).send({ error: "Verification required for display name changes" });
return;
}
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
}
if (body.displayName !== undefined) { if (body.displayName !== undefined) {
data.displayName = body.displayName ? encrypt(body.displayName, masterKey) : null; const cleanName = body.displayName
? body.displayName.replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g, '')
: null;
data.displayName = cleanName ? encrypt(cleanName, masterKey) : null;
} }
if (body.darkMode !== undefined) { if (body.darkMode !== undefined) {
data.darkMode = body.darkMode; data.darkMode = body.darkMode;
@@ -59,62 +104,276 @@ export default async function identityRoutes(app: FastifyInstance) {
reply.send({ reply.send({
id: updated.id, id: updated.id,
displayName: updated.displayName ? decrypt(updated.displayName, masterKey) : null, displayName: updated.displayName ? decrypt(updated.displayName, masterKey) : null,
username: updated.username ? decrypt(updated.username, masterKey) : null,
isPasskeyUser: updated.authMethod === "PASSKEY",
avatarUrl: updated.avatarPath ? `/api/v1/avatars/${updated.id}` : null,
darkMode: updated.darkMode, darkMode: updated.darkMode,
authMethod: updated.authMethod, createdAt: updated.createdAt,
}); });
} }
); );
app.get( app.get(
"/me/posts", "/me/posts",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const posts = await prisma.post.findMany({ const page = Math.max(1, Math.min(500, parseInt((req.query as any).page ?? '1', 10) || 1));
where: { authorId: req.user!.id },
orderBy: { createdAt: "desc" },
include: {
board: { select: { slug: true, name: true } },
_count: { select: { comments: true } },
},
});
reply.send(posts.map((p) => ({ const [posts, total] = await Promise.all([
id: p.id, prisma.post.findMany({
type: p.type, where: { authorId: req.user!.id },
title: p.title, orderBy: { createdAt: "desc" },
status: p.status, take: 50,
voteCount: p.voteCount, skip: (page - 1) * 50,
commentCount: p._count.comments, include: {
board: p.board, board: { select: { slug: true, name: true } },
createdAt: p.createdAt, _count: { select: { comments: true } },
}))); },
}),
prisma.post.count({ where: { authorId: req.user!.id } }),
]);
reply.send({
posts: 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,
})),
total,
page,
pages: Math.ceil(total / 50),
});
} }
); );
app.delete( app.get(
"/me", "/me/profile",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
await prisma.user.delete({ where: { id: req.user!.id } }); const userId = req.user!.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { reply.status(404).send({ error: "User not found" }); return; }
const [postCount, commentCount, voteCount, votesReceived] = await Promise.all([
prisma.post.count({ where: { authorId: userId } }),
prisma.comment.count({ where: { authorId: userId } }),
prisma.vote.count({ where: { voterId: userId } }),
prisma.vote.aggregate({
_sum: { weight: true },
where: { post: { authorId: userId } },
}),
]);
const votedPosts = await prisma.vote.findMany({
where: { voterId: userId },
orderBy: { createdAt: "desc" },
take: 20,
include: {
post: {
select: {
id: true,
title: true,
status: true,
voteCount: true,
board: { select: { slug: true, name: true } },
_count: { select: { comments: true } },
},
},
},
});
reply.send({
id: user.id,
displayName: user.displayName ? decrypt(user.displayName, masterKey) : null,
username: user.username ? decrypt(user.username, masterKey) : null,
isPasskeyUser: user.authMethod === "PASSKEY",
avatarUrl: user.avatarPath ? `/api/v1/avatars/${user.id}` : null,
createdAt: user.createdAt,
stats: {
posts: postCount,
comments: commentCount,
votesGiven: voteCount,
votesReceived: votesReceived._sum.weight ?? 0,
},
votedPosts: votedPosts
.filter((v) => v.post)
.map((v) => ({
id: v.post.id,
title: v.post.title,
status: v.post.status,
voteCount: v.post.voteCount,
commentCount: v.post._count.comments,
board: v.post.board,
votedAt: v.createdAt,
})),
});
}
);
app.delete<{ Body: { altcha: string } }>(
"/me",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 3, timeWindow: "1 hour" } } },
async (req, reply) => {
const { altcha } = (req.body as any) || {};
if (!altcha) {
reply.status(400).send({ error: "Verification required to delete account" });
return;
}
const challengeValid = await verifyChallenge(altcha);
if (!challengeValid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
const userId = req.user!.id;
// collect user's avatar path before deletion
const userForAvatar = await prisma.user.findUnique({
where: { id: userId },
select: { avatarPath: true },
});
// collect attachment paths before deletion
const attachments = await prisma.attachment.findMany({
where: { uploaderId: userId },
select: { path: true },
});
// ensure a sentinel "deleted user" exists for orphaned content
const sentinelId = "deleted-user-sentinel";
await prisma.user.upsert({
where: { id: sentinelId },
create: { id: sentinelId, authMethod: "COOKIE", darkMode: "system" },
update: {},
});
await prisma.$transaction(async (tx) => {
// 1. remove votes and recalculate voteCount on affected posts
const votes = await tx.vote.findMany({
where: { voterId: userId },
select: { postId: true, weight: true },
});
await tx.vote.deleteMany({ where: { voterId: userId } });
for (const vote of votes) {
await tx.post.update({
where: { id: vote.postId },
data: { voteCount: { decrement: vote.weight } },
});
}
// 2. remove reactions
await tx.reaction.deleteMany({ where: { userId } });
// 3. delete push subscriptions
await tx.pushSubscription.deleteMany({ where: { userId } });
// 4. anonymize comments - body becomes "[deleted]", author set to sentinel
await tx.comment.updateMany({
where: { authorId: userId },
data: { body: "[deleted]", authorId: sentinelId },
});
// 5. anonymize posts - title/description replaced, author set to sentinel
const userPosts = await tx.post.findMany({
where: { authorId: userId },
select: { id: true },
});
for (const post of userPosts) {
await tx.post.update({
where: { id: post.id },
data: {
title: "[deleted by author]",
description: {},
authorId: sentinelId,
},
});
}
// 6. wipe passkeys and recovery codes
await tx.passkey.deleteMany({ where: { userId } });
await tx.recoveryCode.deleteMany({ where: { userId } });
// 7. remove attachment records
await tx.attachment.deleteMany({ where: { uploaderId: userId } });
// 8. purge user record
await tx.user.delete({ where: { id: userId } });
});
// clean up files on disk after successful transaction
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
if (userForAvatar?.avatarPath) {
await unlink(resolve(uploadDir, "avatars", userForAvatar.avatarPath)).catch(() => {});
}
const passkeyToken = req.cookies?.echoboard_passkey;
if (passkeyToken) await blockToken(passkeyToken);
reply reply
.clearCookie("echoboard_token", { path: "/" }) .clearCookie("echoboard_token", { path: "/" })
.clearCookie("echoboard_passkey", { path: "/" })
.clearCookie("echoboard_admin", { path: "/" })
.send({ ok: true }); .send({ ok: true });
} }
); );
app.get<{ Querystring: { q?: string } }>(
"/users/search",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const q = (req.query.q ?? "").trim().toLowerCase();
if (q.length < 2 || q.length > 30) {
reply.send({ users: [] });
return;
}
const users = await prisma.user.findMany({
where: { usernameIdx: { not: null } },
select: { id: true, username: true, avatarPath: true },
take: 200,
});
const matches = users
.map((u) => {
let name: string | null = null;
if (u.username) {
try { name = decrypt(u.username, masterKey); } catch {}
}
return {
id: u.id,
username: name,
avatarUrl: u.avatarPath ? `/api/v1/avatars/${u.id}` : null,
};
})
.filter((u) => u.username && u.username.toLowerCase().startsWith(q))
.slice(0, 10);
reply.send({ users: matches });
}
);
app.get( app.get(
"/me/export", "/me/export",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 5, timeWindow: "1 hour" } } },
async (req, reply) => { async (req, reply) => {
const userId = req.user!.id; const userId = req.user!.id;
const [user, posts, comments, votes, reactions] = await Promise.all([ const [user, posts, comments, votes, reactions, pushSubs, notifications] = await Promise.all([
prisma.user.findUnique({ where: { id: userId } }), prisma.user.findUnique({ where: { id: userId } }),
prisma.post.findMany({ where: { authorId: userId } }), prisma.post.findMany({ where: { authorId: userId } }),
prisma.comment.findMany({ where: { authorId: userId } }), prisma.comment.findMany({ where: { authorId: userId } }),
prisma.vote.findMany({ where: { voterId: userId } }), prisma.vote.findMany({ where: { voterId: userId } }),
prisma.reaction.findMany({ where: { userId } }), prisma.reaction.findMany({ where: { userId } }),
prisma.pushSubscription.findMany({
where: { userId },
select: { boardId: true, postId: true, createdAt: true },
}),
prisma.notification.findMany({ where: { userId } }),
]); ]);
const decryptedUser = user ? { const decryptedUser = user ? {
@@ -128,10 +387,31 @@ export default async function identityRoutes(app: FastifyInstance) {
reply.send({ reply.send({
user: decryptedUser, user: decryptedUser,
posts, posts: posts.map((p) => ({
comments, id: p.id, type: p.type, title: p.title, status: p.status,
voteCount: p.voteCount, category: p.category,
boardId: p.boardId, createdAt: p.createdAt,
})),
comments: comments.map((c) => ({
id: c.id, body: c.body, postId: c.postId, isAdmin: c.isAdmin, createdAt: c.createdAt,
})),
votes: votes.map((v) => ({ postId: v.postId, weight: v.weight, createdAt: v.createdAt })), 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 })), reactions: reactions.map((r) => ({ commentId: r.commentId, emoji: r.emoji, createdAt: r.createdAt })),
notifications: notifications.map((n) => ({
id: n.id, type: n.type, title: n.title, body: n.body,
postId: n.postId, read: n.read, createdAt: n.createdAt,
})),
pushSubscriptions: pushSubs,
dataManifest: {
trackedModels: ["User", "Passkey", "PushSubscription", "Notification", "RecoveryCode"],
fields: {
User: ["id", "authMethod", "displayName", "username", "avatarPath", "darkMode", "createdAt"],
Passkey: ["id", "credentialId", "credentialDeviceType", "transports", "createdAt"],
PushSubscription: ["id", "boardId", "postId", "createdAt"],
Notification: ["id", "type", "title", "body", "postId", "read", "createdAt"],
RecoveryCode: ["id", "expiresAt", "createdAt"],
},
},
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
}); });
} }

View File

@@ -0,0 +1,43 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
export default async function notificationRoutes(app: FastifyInstance) {
app.get(
"/notifications",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const notifications = await prisma.notification.findMany({
where: { userId: req.user!.id },
orderBy: { createdAt: "desc" },
take: 30,
include: {
post: {
select: {
id: true,
title: true,
board: { select: { slug: true } },
},
},
},
});
const unread = await prisma.notification.count({
where: { userId: req.user!.id, read: false },
});
reply.send({ notifications, unread });
}
);
app.put(
"/notifications/read",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
await prisma.notification.updateMany({
where: { userId: req.user!.id, read: false },
data: { read: true },
});
reply.send({ ok: true });
}
);
}

View File

@@ -1,5 +1,4 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { import {
generateRegistrationOptions, generateRegistrationOptions,
verifyRegistrationResponse, verifyRegistrationResponse,
@@ -9,28 +8,45 @@ import {
import type { import type {
RegistrationResponseJSON, RegistrationResponseJSON,
AuthenticationResponseJSON, AuthenticationResponseJSON,
} from "@simplewebauthn/server"; } from "@simplewebauthn/types";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { z } from "zod"; import { z } from "zod";
import prisma from "../lib/prisma.js";
import { config, masterKey, blindIndexKey } from "../config.js"; import { config, masterKey, blindIndexKey } from "../config.js";
import { encrypt, decrypt, blindIndex } from "../services/encryption.js"; import { encrypt, decrypt, blindIndex } from "../services/encryption.js";
import { blockToken } from "../lib/token-blocklist.js";
const prisma = new PrismaClient(); // challenge store keyed by purpose:userId for proper isolation
const challenges = new Map<string, { challenge: string; expires: number; username?: string }>();
const challenges = new Map<string, { challenge: string; expires: number }>(); const MAX_CHALLENGES = 10000;
function storeChallenge(userId: string, challenge: string) { function storeChallenge(key: string, challenge: string, username?: string) {
challenges.set(userId, { challenge, expires: Date.now() + 5 * 60 * 1000 }); if (challenges.size >= MAX_CHALLENGES) {
const now = Date.now();
for (const [k, v] of challenges) {
if (v.expires < now) challenges.delete(k);
}
if (challenges.size >= MAX_CHALLENGES) {
const iter = challenges.keys();
for (let i = 0; i < 100; i++) {
const k = iter.next();
if (k.done) break;
challenges.delete(k.value);
}
}
}
challenges.set(key, { challenge, expires: Date.now() + 60 * 1000, username });
} }
function getChallenge(userId: string): string | null { function getChallenge(key: string): { challenge: string; username?: string } | null {
const entry = challenges.get(userId); const entry = challenges.get(key);
if (!entry || entry.expires < Date.now()) { if (!entry || entry.expires < Date.now()) {
challenges.delete(userId); challenges.delete(key);
return null; return null;
} }
challenges.delete(userId); challenges.delete(key);
return entry.challenge; return { challenge: entry.challenge, username: entry.username };
} }
export function cleanExpiredChallenges() { export function cleanExpiredChallenges() {
@@ -45,9 +61,11 @@ const registerBody = z.object({
}); });
export default async function passkeyRoutes(app: FastifyInstance) { export default async function passkeyRoutes(app: FastifyInstance) {
const passkeyRateLimit = { rateLimit: { max: 5, timeWindow: "1 minute" } };
app.post<{ Body: z.infer<typeof registerBody> }>( app.post<{ Body: z.infer<typeof registerBody> }>(
"/auth/passkey/register/options", "/auth/passkey/register/options",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: passkeyRateLimit },
async (req, reply) => { async (req, reply) => {
const { username } = registerBody.parse(req.body); const { username } = registerBody.parse(req.body);
const user = req.user!; const user = req.user!;
@@ -76,23 +94,30 @@ export default async function passkeyRoutes(app: FastifyInstance) {
}, },
}); });
storeChallenge(user.id, options.challenge); storeChallenge("register:" + user.id, options.challenge, username);
reply.send(options); reply.send(options);
} }
); );
app.post<{ Body: { response: RegistrationResponseJSON; username: string } }>( app.post<{ Body: { response: RegistrationResponseJSON; username: string } }>(
"/auth/passkey/register/verify", "/auth/passkey/register/verify",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: passkeyRateLimit },
async (req, reply) => { async (req, reply) => {
const user = req.user!; const user = req.user!;
const { response, username } = req.body; const { response, username } = req.body;
const expectedChallenge = getChallenge(user.id); const stored = getChallenge("register:" + user.id);
if (!expectedChallenge) { if (!stored) {
reply.status(400).send({ error: "Challenge expired" }); reply.status(400).send({ error: "Challenge expired" });
return; return;
} }
const expectedChallenge = stored.challenge;
const validName = registerBody.safeParse({ username });
if (!validName.success || username !== stored.username) {
reply.status(400).send({ error: "Invalid username" });
return;
}
let verification; let verification;
try { try {
@@ -102,8 +127,8 @@ export default async function passkeyRoutes(app: FastifyInstance) {
expectedOrigin: config.WEBAUTHN_ORIGIN, expectedOrigin: config.WEBAUTHN_ORIGIN,
expectedRPID: config.WEBAUTHN_RP_ID, expectedRPID: config.WEBAUTHN_RP_ID,
}); });
} catch (err: any) { } catch {
reply.status(400).send({ error: err.message }); reply.status(400).send({ error: "Registration verification failed" });
return; return;
} }
@@ -116,11 +141,13 @@ export default async function passkeyRoutes(app: FastifyInstance) {
const credIdStr = Buffer.from(credential.id).toString("base64url"); const credIdStr = Buffer.from(credential.id).toString("base64url");
const pubKeyEncrypted = encrypt(Buffer.from(credential.publicKey).toString("base64"), masterKey);
await prisma.passkey.create({ await prisma.passkey.create({
data: { data: {
credentialId: encrypt(credIdStr, masterKey), credentialId: encrypt(credIdStr, masterKey),
credentialIdIdx: blindIndex(credIdStr, blindIndexKey), credentialIdIdx: blindIndex(credIdStr, blindIndexKey),
credentialPublicKey: Buffer.from(credential.publicKey), credentialPublicKey: Buffer.from(pubKeyEncrypted),
counter: BigInt(credential.counter), counter: BigInt(credential.counter),
credentialDeviceType, credentialDeviceType,
credentialBackedUp, credentialBackedUp,
@@ -145,6 +172,7 @@ export default async function passkeyRoutes(app: FastifyInstance) {
app.post( app.post(
"/auth/passkey/login/options", "/auth/passkey/login/options",
{ config: passkeyRateLimit },
async (_req, reply) => { async (_req, reply) => {
const options = await generateAuthenticationOptions({ const options = await generateAuthenticationOptions({
rpID: config.WEBAUTHN_RP_ID, rpID: config.WEBAUTHN_RP_ID,
@@ -158,6 +186,7 @@ export default async function passkeyRoutes(app: FastifyInstance) {
app.post<{ Body: { response: AuthenticationResponseJSON } }>( app.post<{ Body: { response: AuthenticationResponseJSON } }>(
"/auth/passkey/login/verify", "/auth/passkey/login/verify",
{ config: passkeyRateLimit },
async (req, reply) => { async (req, reply) => {
const { response } = req.body; const { response } = req.body;
@@ -166,24 +195,25 @@ export default async function passkeyRoutes(app: FastifyInstance) {
const passkey = await prisma.passkey.findUnique({ where: { credentialIdIdx: credIdx } }); const passkey = await prisma.passkey.findUnique({ where: { credentialIdIdx: credIdx } });
if (!passkey) { if (!passkey) {
reply.status(400).send({ error: "Passkey not found" }); reply.status(400).send({ error: "Authentication failed" });
return; return;
} }
const expectedChallenge = getChallenge("login:" + response.response.clientDataJSON); let clientData: { challenge?: string };
// we stored with the challenge value, try to find it try {
let challenge: string | null = null; clientData = JSON.parse(
for (const [key, val] of challenges) { Buffer.from(response.response.clientDataJSON, "base64url").toString()
if (key.startsWith("login:") && val.expires > Date.now()) { );
challenge = val.challenge; } catch {
challenges.delete(key); reply.status(400).send({ error: "Invalid client data" });
break; return;
}
} }
if (!challenge) { const stored = getChallenge("login:" + clientData.challenge);
if (!stored) {
reply.status(400).send({ error: "Challenge expired" }); reply.status(400).send({ error: "Challenge expired" });
return; return;
} }
const challenge = stored.challenge;
let verification; let verification;
try { try {
@@ -194,15 +224,15 @@ export default async function passkeyRoutes(app: FastifyInstance) {
expectedRPID: config.WEBAUTHN_RP_ID, expectedRPID: config.WEBAUTHN_RP_ID,
credential: { credential: {
id: decrypt(passkey.credentialId, masterKey), id: decrypt(passkey.credentialId, masterKey),
publicKey: new Uint8Array(passkey.credentialPublicKey), publicKey: new Uint8Array(Buffer.from(decrypt(passkey.credentialPublicKey.toString(), masterKey), "base64")),
counter: Number(passkey.counter), counter: Number(passkey.counter),
transports: passkey.transports transports: passkey.transports
? JSON.parse(decrypt(passkey.transports, masterKey)) ? JSON.parse(decrypt(passkey.transports, masterKey))
: undefined, : undefined,
}, },
}); });
} catch (err: any) { } catch {
reply.status(400).send({ error: err.message }); reply.status(400).send({ error: "Authentication failed" });
return; return;
} }
@@ -211,36 +241,113 @@ export default async function passkeyRoutes(app: FastifyInstance) {
return; return;
} }
await prisma.passkey.update({ try {
where: { id: passkey.id }, await prisma.$transaction(async (tx) => {
data: { counter: BigInt(verification.authenticationInfo.newCounter) }, const current = await tx.passkey.findUnique({
}); where: { id: passkey.id },
select: { counter: true },
});
if (current && current.counter >= BigInt(verification.authenticationInfo.newCounter)) {
throw new Error("counter_rollback");
}
await tx.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
});
});
} catch (err: any) {
if (err.message === "counter_rollback") {
req.log.warn({ passkeyId: passkey.id, userId: passkey.userId }, "passkey counter rollback detected - possible credential cloning, disabling passkey");
await prisma.passkey.delete({ where: { id: passkey.id } }).catch(() => {});
}
reply.status(400).send({ error: "Authentication failed" });
return;
}
const token = jwt.sign( const token = jwt.sign(
{ sub: passkey.userId, type: "passkey" }, { sub: passkey.userId, type: "passkey", aud: "echoboard:user", iss: "echoboard" },
config.JWT_SECRET, config.JWT_SECRET,
{ expiresIn: "30d" } { expiresIn: "24h" }
); );
reply.send({ verified: true, token }); reply
.setCookie("echoboard_passkey", token, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24,
})
.send({ verified: true });
}
);
app.get(
"/me/passkeys",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const passkeys = await prisma.passkey.findMany({
where: { userId: req.user!.id },
select: {
id: true,
credentialDeviceType: true,
credentialBackedUp: true,
createdAt: true,
},
orderBy: { createdAt: "asc" },
});
reply.send(passkeys);
}
);
app.delete<{ Params: { id: string } }>(
"/me/passkeys/:id",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const passkey = await prisma.passkey.findUnique({
where: { id: req.params.id },
});
if (!passkey || passkey.userId !== req.user!.id) {
reply.status(404).send({ error: "Passkey not found" });
return;
}
const count = await prisma.passkey.count({ where: { userId: req.user!.id } });
if (count <= 1) {
reply.status(400).send({ error: "Cannot remove your only passkey" });
return;
}
await prisma.passkey.delete({ where: { id: passkey.id } });
reply.status(204).send();
} }
); );
app.post( app.post(
"/auth/passkey/logout", "/auth/passkey/logout",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (_req, reply) => { async (req, reply) => {
const passkeyToken = req.cookies?.echoboard_passkey;
if (passkeyToken) await blockToken(passkeyToken);
reply reply
.clearCookie("echoboard_token", { path: "/" }) .clearCookie("echoboard_token", { path: "/" })
.clearCookie("echoboard_passkey", { path: "/" })
.clearCookie("echoboard_admin", { path: "/" })
.send({ ok: true }); .send({ ok: true });
} }
); );
app.get<{ Params: { name: string } }>( app.get<{ Params: { name: string } }>(
"/auth/passkey/check-username/:name", "/auth/passkey/check-username/:name",
{ config: { rateLimit: { max: 5, timeWindow: "5 minutes" } } },
async (req, reply) => { async (req, reply) => {
const start = Date.now();
const hash = blindIndex(req.params.name, blindIndexKey); const hash = blindIndex(req.params.name, blindIndexKey);
const existing = await prisma.user.findUnique({ where: { usernameIdx: hash } }); const existing = await prisma.user.findUnique({ where: { usernameIdx: hash } });
// constant-time response to prevent timing-based enumeration
const elapsed = Date.now() - start;
if (elapsed < 100) await new Promise((r) => setTimeout(r, 100 - elapsed));
reply.send({ available: !existing }); reply.send({ available: !existing });
} }
); );

View File

@@ -1,38 +1,163 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient, PostType, PostStatus, Prisma } from "@prisma/client"; import { PostType, Prisma } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import prisma from "../lib/prisma.js";
import { verifyChallenge } from "../services/altcha.js"; import { verifyChallenge } from "../services/altcha.js";
import { fireWebhook } from "../services/webhooks.js";
import { decrypt } from "../services/encryption.js";
import { masterKey } from "../config.js";
import { shouldCount } from "../lib/view-tracker.js";
import { notifyBoardSubscribers } from "../services/push.js";
const prisma = new PrismaClient(); const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
function decryptName(encrypted: string | null): string | null {
if (!encrypted) return null;
try { return decrypt(encrypted, masterKey); } catch { return null; }
}
function cleanAuthor(author: { id: string; displayName: string | null; avatarPath?: string | null } | null) {
if (!author) return null;
return {
id: author.id,
displayName: decryptName(author.displayName),
avatarUrl: author.avatarPath ? `/api/v1/avatars/${author.id}` : null,
};
}
const bugReportSchema = z.object({
stepsToReproduce: z.string().min(1),
expectedBehavior: z.string().min(1),
actualBehavior: z.string().min(1),
environment: z.string().optional(),
additionalContext: z.string().optional(),
});
const featureRequestSchema = z.object({
useCase: z.string().min(1),
proposedSolution: z.string().optional(),
alternativesConsidered: z.string().optional(),
additionalContext: z.string().optional(),
});
const allowedDescKeys = new Set(["stepsToReproduce", "expectedBehavior", "actualBehavior", "environment", "useCase", "proposedSolution", "alternativesConsidered", "additionalContext"]);
const descriptionRecord = z.record(z.string().max(5000)).refine(
(obj) => Object.keys(obj).length <= 10 && Object.keys(obj).every((k) => allowedDescKeys.has(k)),
{ message: "Unknown description fields" }
);
const templateDescRecord = z.record(z.string().max(5000)).refine(
(obj) => Object.keys(obj).length <= 30,
{ message: "Too many description fields" }
);
const createPostSchema = z.object({ const createPostSchema = z.object({
type: z.nativeEnum(PostType), type: z.nativeEnum(PostType),
title: z.string().min(3).max(200), title: z.string().min(5).max(200),
description: z.any(), description: z.record(z.string().max(5000)),
category: z.string().optional(), category: z.string().optional(),
templateId: z.string().optional(),
attachmentIds: z.array(z.string()).max(10).optional(),
altcha: z.string(), altcha: z.string(),
}).superRefine((data, ctx) => {
if (data.templateId) {
// template posts use flexible description keys
const tResult = templateDescRecord.safeParse(data.description);
if (!tResult.success) {
for (const issue of tResult.error.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: ["description", ...issue.path],
});
}
}
} else {
// standard posts use strict schema
const dr = descriptionRecord.safeParse(data.description);
if (!dr.success) {
for (const issue of dr.error.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: ["description", ...issue.path],
});
}
}
const result = data.type === PostType.BUG_REPORT
? bugReportSchema.safeParse(data.description)
: featureRequestSchema.safeParse(data.description);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: ["description", ...issue.path],
});
}
}
}
const raw = JSON.stringify(data.description);
if (raw.length < 20) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Description too short (at least 20 characters required)",
path: ["description"],
});
}
if (raw.length > 5000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Description too long (max 5000 characters total)",
path: ["description"],
});
}
}); });
const updatePostSchema = z.object({ const updatePostSchema = z.object({
title: z.string().min(3).max(200).optional(), title: z.string().min(5).max(200).optional(),
description: z.any().optional(), description: z.record(z.string().max(5000)).optional(),
category: z.string().optional().nullable(), category: z.string().optional().nullable(),
altcha: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.description) {
const raw = JSON.stringify(data.description);
if (raw.length < 20) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Description too short (at least 20 characters required)",
path: ["description"],
});
}
if (raw.length > 5000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Description too long (max 5000 characters total)",
path: ["description"],
});
}
}
}); });
const VALID_STATUSES = ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS", "DONE", "DECLINED"] as const;
const querySchema = z.object({ const querySchema = z.object({
type: z.nativeEnum(PostType).optional(), type: z.nativeEnum(PostType).optional(),
category: z.string().optional(), category: z.string().max(50).optional(),
status: z.nativeEnum(PostStatus).optional(), status: z.enum(VALID_STATUSES).optional(),
sort: z.enum(["newest", "oldest", "top", "trending"]).default("newest"), sort: z.enum(["newest", "oldest", "top", "updated"]).default("newest"),
search: z.string().optional(), search: z.string().max(200).optional(),
page: z.coerce.number().min(1).default(1), page: z.coerce.number().int().min(1).max(500).default(1),
limit: z.coerce.number().min(1).max(100).default(20), limit: z.coerce.number().int().min(1).max(100).default(20),
}); });
export default async function postRoutes(app: FastifyInstance) { export default async function postRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string }; Querystring: Record<string, string> }>( app.get<{ Params: { boardSlug: string }; Querystring: Record<string, string> }>(
"/boards/:boardSlug/posts", "/boards/:boardSlug/posts",
{ preHandler: [app.optionalUser] }, { preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) { if (!board) {
@@ -46,13 +171,29 @@ export default async function postRoutes(app: FastifyInstance) {
if (q.type) where.type = q.type; if (q.type) where.type = q.type;
if (q.category) where.category = q.category; if (q.category) where.category = q.category;
if (q.status) where.status = q.status; if (q.status) where.status = q.status;
if (q.search) where.title = { contains: q.search, mode: "insensitive" }; if (q.search) {
const likeTerm = `%${q.search}%`;
const matchIds = await prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM "Post"
WHERE "boardId" = ${board.id}
AND (
word_similarity(${q.search}, title) > 0.15
OR to_tsvector('english', title) @@ plainto_tsquery('english', ${q.search})
OR description::text ILIKE ${likeTerm}
)
`;
if (matchIds.length === 0) {
reply.send({ posts: [], total: 0, page: q.page, pages: 0, staleDays: board.staleDays });
return;
}
where.id = { in: matchIds.map((m) => m.id) };
}
let orderBy: Prisma.PostOrderByWithRelationInput; let orderBy: Prisma.PostOrderByWithRelationInput;
switch (q.sort) { switch (q.sort) {
case "oldest": orderBy = { createdAt: "asc" }; break; case "oldest": orderBy = { createdAt: "asc" }; break;
case "top": orderBy = { voteCount: "desc" }; break; case "top": orderBy = { voteCount: "desc" }; break;
case "trending": orderBy = { voteCount: "desc" }; break; case "updated": orderBy = { updatedAt: "desc" }; break;
default: orderBy = { createdAt: "desc" }; default: orderBy = { createdAt: "desc" };
} }
@@ -64,70 +205,266 @@ export default async function postRoutes(app: FastifyInstance) {
take: q.limit, take: q.limit,
include: { include: {
_count: { select: { comments: true } }, _count: { select: { comments: true } },
author: { select: { id: true, displayName: true } }, author: { select: { id: true, displayName: true, avatarPath: true } },
tags: { include: { tag: true } },
}, },
}), }),
prisma.post.count({ where }), prisma.post.count({ where }),
]); ]);
const userVotes = new Map<string, number>();
if (req.user) {
const votes = await prisma.vote.findMany({
where: { voterId: req.user.id, postId: { in: posts.map((p) => p.id) } },
select: { postId: true, weight: true },
});
for (const v of votes) userVotes.set(v.postId, v.weight);
}
const staleCutoff = board.staleDays > 0
? new Date(Date.now() - board.staleDays * 86400000)
: null;
reply.send({ reply.send({
posts: posts.map((p) => ({ posts: posts.map((p) => ({
id: p.id, id: p.id,
type: p.type, type: p.type,
title: p.title, title: p.title,
description: p.description,
status: p.status, status: p.status,
statusReason: p.statusReason,
category: p.category, category: p.category,
voteCount: p.voteCount, voteCount: p.voteCount,
viewCount: p.viewCount,
isPinned: p.isPinned, isPinned: p.isPinned,
isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false,
commentCount: p._count.comments, commentCount: p._count.comments,
author: p.author, author: cleanAuthor(p.author),
tags: p.tags.map((pt) => pt.tag),
onBehalfOf: p.onBehalfOf,
voted: userVotes.has(p.id),
voteWeight: userVotes.get(p.id) ?? 0,
createdAt: p.createdAt, createdAt: p.createdAt,
updatedAt: p.updatedAt, updatedAt: p.updatedAt,
})), })),
total, total,
page: q.page, page: q.page,
pages: Math.ceil(total / q.limit), pages: Math.ceil(total / q.limit),
staleDays: board.staleDays,
}); });
} }
); );
app.get<{ Params: { boardSlug: string; id: string } }>( app.get<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id", "/boards/:boardSlug/posts/:id",
{ preHandler: [app.optionalUser] }, { preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => { 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({ const post = await prisma.post.findUnique({
where: { id: req.params.id }, where: { id: req.params.id },
include: { include: {
author: { select: { id: true, displayName: true } }, author: { select: { id: true, displayName: true, avatarPath: true } },
_count: { select: { comments: true, votes: true } }, _count: { select: { comments: true, votes: true, editHistory: true } },
adminResponses: {
include: { admin: { select: { id: true, email: true } } },
orderBy: { createdAt: "asc" },
},
statusChanges: { orderBy: { createdAt: "asc" } }, statusChanges: { orderBy: { createdAt: "asc" } },
tags: { include: { tag: true } },
attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
}, },
}); });
if (!post) { if (!post || post.boardId !== board.id) {
// check if this post was merged into another
const merge = await prisma.postMerge.findFirst({
where: { sourcePostId: req.params.id },
orderBy: { createdAt: "desc" },
});
if (merge) {
const targetPost = await prisma.post.findUnique({ where: { id: merge.targetPostId }, select: { boardId: true } });
if (!targetPost || targetPost.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" });
return;
}
reply.send({ merged: true, targetPostId: merge.targetPostId });
return;
}
reply.status(404).send({ error: "Post not found" }); reply.status(404).send({ error: "Post not found" });
return; return;
} }
const viewKey = req.user?.id ?? req.ip;
if (shouldCount(post.id, viewKey)) {
prisma.post.update({ where: { id: post.id }, data: { viewCount: { increment: 1 } } }).catch(() => {});
}
let voted = false; let voted = false;
let voteWeight = 0;
let userImportance: string | null = null;
if (req.user) { if (req.user) {
const existing = await prisma.vote.findUnique({ const existing = await prisma.vote.findUnique({
where: { postId_voterId: { postId: post.id, voterId: req.user.id } }, where: { postId_voterId: { postId: post.id, voterId: req.user.id } },
}); });
voted = !!existing; voted = !!existing;
voteWeight = existing?.weight ?? 0;
userImportance = existing?.importance ?? null;
} }
reply.send({ ...post, voted }); const importanceVotes = await prisma.vote.groupBy({
by: ["importance"],
where: { postId: post.id, importance: { not: null } },
_count: { importance: true },
});
const importanceCounts: Record<string, number> = {
critical: 0, important: 0, nice_to_have: 0, minor: 0,
};
for (const row of importanceVotes) {
if (row.importance && row.importance in importanceCounts) {
importanceCounts[row.importance] = row._count.importance;
}
}
reply.send({
id: post.id,
type: post.type,
title: post.title,
description: post.description,
status: post.status,
statusReason: post.statusReason,
category: post.category,
voteCount: post.voteCount,
viewCount: post.viewCount,
isPinned: post.isPinned,
isEditLocked: post.isEditLocked,
isThreadLocked: post.isThreadLocked,
isVotingLocked: post.isVotingLocked,
onBehalfOf: post.onBehalfOf,
commentCount: post._count.comments,
voteTotal: post._count.votes,
author: cleanAuthor(post.author),
tags: post.tags.map((pt) => pt.tag),
attachments: post.attachments,
statusChanges: post.statusChanges.map((sc) => ({
id: sc.id,
fromStatus: sc.fromStatus,
toStatus: sc.toStatus,
reason: sc.reason,
createdAt: sc.createdAt,
})),
voted,
voteWeight,
userImportance,
importanceCounts,
editCount: post._count.editHistory,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
});
}
);
app.get<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id/timeline",
{ preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
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 [statusChanges, comments] = await Promise.all([
prisma.statusChange.findMany({
where: { postId: post.id },
orderBy: { createdAt: "asc" },
take: 500,
}),
prisma.comment.findMany({
where: { postId: post.id },
include: {
author: { select: { id: true, displayName: true, avatarPath: true } },
adminUser: { select: { displayName: true, teamTitle: true } },
reactions: { select: { emoji: true, userId: true } },
attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
_count: { select: { editHistory: true } },
replyTo: {
select: {
id: true,
body: true,
isAdmin: true,
adminUserId: true,
author: { select: { id: true, displayName: true, avatarPath: true } },
adminUser: { select: { displayName: true } },
},
},
},
orderBy: { createdAt: "asc" },
take: 500,
}),
]);
const entries = [
...statusChanges.map((sc) => ({
id: sc.id,
type: "status_change" as const,
authorName: "System",
content: "",
oldStatus: sc.fromStatus,
newStatus: sc.toStatus,
reason: sc.reason ?? null,
createdAt: sc.createdAt,
isAdmin: true,
})),
...comments.map((c) => {
const emojiMap: Record<string, { count: number; userIds: string[] }> = {};
for (const r of c.reactions) {
if (!emojiMap[r.emoji]) emojiMap[r.emoji] = { count: 0, userIds: [] };
emojiMap[r.emoji].count++;
emojiMap[r.emoji].userIds.push(r.userId);
}
return {
id: c.id,
type: "comment" as const,
authorId: c.author?.id ?? null,
authorName: c.isAdmin ? (decryptName(c.adminUser?.displayName ?? null) ?? "Admin") : (decryptName(c.author?.displayName ?? null) ?? `Anonymous #${(c.author?.id ?? "0000").slice(-4)}`),
authorTitle: c.isAdmin && c.adminUser?.teamTitle ? decryptName(c.adminUser.teamTitle) : null,
authorAvatarUrl: c.author?.avatarPath ? `/api/v1/avatars/${c.author.id}` : null,
content: c.body,
createdAt: c.createdAt,
isAdmin: c.isAdmin,
replyTo: c.replyTo ? {
id: c.replyTo.id,
body: c.replyTo.body.slice(0, 200),
isAdmin: c.replyTo.isAdmin,
authorName: c.replyTo.isAdmin ? (decryptName(c.replyTo.adminUser?.displayName ?? null) ?? "Admin") : (decryptName(c.replyTo.author?.displayName ?? null) ?? `Anonymous #${(c.replyTo.author?.id ?? "0000").slice(-4)}`),
} : null,
reactions: Object.entries(emojiMap).map(([emoji, data]) => ({
emoji,
count: data.count,
hasReacted: req.user ? data.userIds.includes(req.user.id) : false,
})),
attachments: c.attachments,
editCount: c._count.editHistory,
isEditLocked: c.isEditLocked,
};
}),
].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
reply.send({ entries, isCurrentAdmin: !!req.adminId });
} }
); );
app.post<{ Params: { boardSlug: string }; Body: z.infer<typeof createPostSchema> }>( app.post<{ Params: { boardSlug: string }; Body: z.infer<typeof createPostSchema> }>(
"/boards/:boardSlug/posts", "/boards/:boardSlug/posts",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
async (req, reply) => { async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board || board.isArchived) { if (!board || board.isArchived) {
@@ -143,17 +480,59 @@ export default async function postRoutes(app: FastifyInstance) {
return; return;
} }
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const normalizedTitle = cleanTitle.trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase();
const recentPosts = await prisma.post.findMany({
where: {
boardId: board.id,
authorId: req.user!.id,
createdAt: { gte: dayAgo },
},
select: { title: true },
take: 100,
});
const isDuplicate = recentPosts.some(p => {
const norm = p.title.replace(INVISIBLE_RE, '').trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase();
return norm === normalizedTitle;
});
if (isDuplicate) {
reply.status(409).send({ error: "You already posted something similar within the last 24 hours" });
return;
}
if (body.templateId) {
const tmpl = await prisma.boardTemplate.findUnique({ where: { id: body.templateId } });
if (!tmpl || tmpl.boardId !== board.id) {
reply.status(400).send({ error: "Invalid template" });
return;
}
}
const post = await prisma.post.create({ const post = await prisma.post.create({
data: { data: {
type: body.type, type: body.type,
title: body.title, title: cleanTitle,
description: body.description, description: body.description,
category: body.category, category: body.category,
templateId: body.templateId,
boardId: board.id, boardId: board.id,
authorId: req.user!.id, authorId: req.user!.id,
}, },
}); });
if (body.attachmentIds?.length) {
await prisma.attachment.updateMany({
where: {
id: { in: body.attachmentIds },
uploaderId: req.user!.id,
postId: null,
commentId: null,
},
data: { postId: post.id },
});
}
await prisma.activityEvent.create({ await prisma.activityEvent.create({
data: { data: {
type: "post_created", type: "post_created",
@@ -163,16 +542,40 @@ export default async function postRoutes(app: FastifyInstance) {
}, },
}); });
reply.status(201).send(post); fireWebhook("post_created", {
postId: post.id,
title: post.title,
type: post.type,
boardId: board.id,
boardSlug: board.slug,
});
notifyBoardSubscribers(board.id, {
title: `New post in ${board.name}`,
body: cleanTitle.slice(0, 100),
url: `/b/${board.slug}/post/${post.id}`,
tag: `board-${board.id}-new`,
});
reply.status(201).send({
id: post.id, type: post.type, title: post.title, description: post.description,
status: post.status, category: post.category, createdAt: post.createdAt,
});
} }
); );
app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof updatePostSchema> }>( app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof updatePostSchema> }>(
"/boards/:boardSlug/posts/:id", "/boards/:boardSlug/posts/:id",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { 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 } }); const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) { if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" }); reply.status(404).send({ error: "Post not found" });
return; return;
} }
@@ -181,26 +584,136 @@ export default async function postRoutes(app: FastifyInstance) {
return; return;
} }
if (post.isEditLocked) {
reply.status(403).send({ error: "Editing is locked on this post" });
return;
}
const body = updatePostSchema.parse(req.body); const body = updatePostSchema.parse(req.body);
// admins skip ALTCHA, regular users must provide it
if (!req.adminId) {
if (!body.altcha) {
reply.status(400).send({ error: "Challenge response required" });
return;
}
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
}
if (body.description && !post.templateId) {
const dr = descriptionRecord.safeParse(body.description);
if (!dr.success) {
reply.status(400).send({ error: "Unknown description fields" });
return;
}
const schema = post.type === PostType.BUG_REPORT ? bugReportSchema : featureRequestSchema;
const result = schema.safeParse(body.description);
if (!result.success) {
reply.status(400).send({ error: "Invalid description for this post type" });
return;
}
}
if (body.description && post.templateId) {
const tr = templateDescRecord.safeParse(body.description);
if (!tr.success) {
reply.status(400).send({ error: "Too many description fields" });
return;
}
const tmpl = await prisma.boardTemplate.findUnique({ where: { id: post.templateId } });
if (!tmpl) {
reply.status(400).send({ error: "Template no longer exists" });
return;
}
const templateKeys = new Set((tmpl.fields as any[]).map((f: any) => f.key));
const submitted = Object.keys(body.description);
const unknown = submitted.filter((k) => !templateKeys.has(k));
if (unknown.length > 0) {
reply.status(400).send({ error: "Unknown template fields: " + unknown.join(", ") });
return;
}
}
const titleChanged = body.title !== undefined && body.title !== post.title;
const descChanged = body.description !== undefined && JSON.stringify(body.description) !== JSON.stringify(post.description);
if (titleChanged || descChanged) {
await prisma.editHistory.create({
data: {
postId: post.id,
editedBy: req.user!.id,
previousTitle: post.title,
previousDescription: post.description as any,
},
});
}
const updated = await prisma.post.update({ const updated = await prisma.post.update({
where: { id: post.id }, where: { id: post.id },
data: { data: {
...(body.title !== undefined && { title: body.title }), ...(body.title !== undefined && { title: body.title.replace(INVISIBLE_RE, '') }),
...(body.description !== undefined && { description: body.description }), ...(body.description !== undefined && { description: body.description }),
...(body.category !== undefined && { category: body.category }), ...(body.category !== undefined && { category: body.category }),
}, },
}); });
reply.send(updated); reply.send({
id: updated.id, type: updated.type, title: updated.title,
description: updated.description, status: updated.status,
category: updated.category, updatedAt: updated.updatedAt,
});
}
);
app.get<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id/edits",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
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 edits = await prisma.editHistory.findMany({
where: { postId: post.id },
orderBy: { createdAt: "desc" },
take: 50,
include: {
editor: { select: { id: true, displayName: true, avatarPath: true } },
},
});
reply.send(edits.map((e) => ({
id: e.id,
previousTitle: e.previousTitle,
previousDescription: e.previousDescription,
editedBy: cleanAuthor(e.editor),
createdAt: e.createdAt,
})));
} }
); );
app.delete<{ Params: { boardSlug: string; id: string } }>( app.delete<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id", "/boards/:boardSlug/posts/:id",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => { 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 } }); const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) { if (!post || post.boardId !== board.id) {
reply.status(404).send({ error: "Post not found" }); reply.status(404).send({ error: "Post not found" });
return; return;
} }
@@ -209,7 +722,36 @@ export default async function postRoutes(app: FastifyInstance) {
return; return;
} }
const commentIds = (await prisma.comment.findMany({
where: { postId: post.id },
select: { id: true },
})).map((c) => c.id);
const attachments = await prisma.attachment.findMany({
where: {
OR: [
{ postId: post.id },
...(commentIds.length ? [{ commentId: { in: commentIds } }] : []),
],
},
select: { id: true, path: true },
});
if (attachments.length) {
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
}
await prisma.postMerge.deleteMany({
where: { OR: [{ sourcePostId: post.id }, { targetPostId: post.id }] },
});
await prisma.post.delete({ where: { id: post.id } }); await prisma.post.delete({ where: { id: post.id } });
const uploadDir = resolve(process.cwd(), "uploads");
for (const att of attachments) {
await unlink(resolve(uploadDir, att.path)).catch(() => {});
}
reply.status(204).send(); reply.status(204).send();
} }
); );

View File

@@ -1,47 +1,69 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client"; import { z } from "zod";
import prisma from "../lib/prisma.js";
import { generateChallenge } from "../services/altcha.js"; import { generateChallenge } from "../services/altcha.js";
import { config } from "../config.js"; import { config } from "../config.js";
const prisma = new PrismaClient(); const challengeQuery = z.object({
difficulty: z.enum(["normal", "light"]).default("normal"),
});
export default async function privacyRoutes(app: FastifyInstance) { export default async function privacyRoutes(app: FastifyInstance) {
app.get("/altcha/challenge", async (req, reply) => { app.get("/altcha/challenge", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
const difficulty = req.query && (req.query as any).difficulty === "light" ? "light" : "normal"; const { difficulty } = challengeQuery.parse(req.query);
const challenge = await generateChallenge(difficulty as "normal" | "light"); const challenge = await generateChallenge(difficulty);
reply.send(challenge); reply.send(challenge);
}); });
app.get("/privacy/data-manifest", async (_req, reply) => { app.get("/privacy/data-manifest", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => {
reply.send({ reply.send({
dataCollected: { anonymousUser: [
anonymous: { { field: "Token hash", purpose: "SHA-256 hashed, used for session identity", retention: "Until user deletes", deletable: true },
cookieToken: "SHA-256 hashed, used for session identity", { field: "Display name", purpose: "AES-256-GCM encrypted, optional cosmetic name", retention: "Until user deletes", deletable: true },
displayName: "AES-256-GCM encrypted, optional", { field: "Dark mode preference", purpose: "Theme setting (system/light/dark)", retention: "Until user deletes", deletable: true },
posts: "Stored with author reference, deletable", { field: "Posts", purpose: "Stored with author reference", retention: "Until user deletes (anonymized)", deletable: true },
comments: "Stored with author reference, deletable", { field: "Comments", purpose: "Stored with author reference", retention: "Until user deletes (anonymized)", deletable: true },
votes: "Stored with voter reference, deletable", { field: "Votes", purpose: "Stored with voter reference", retention: "Until user deletes", deletable: true },
reactions: "Stored with user reference, deletable", { field: "Reactions", purpose: "Emoji reactions on comments", retention: "Until user deletes", deletable: true },
}, ],
passkey: { passkeyUser: [
username: "AES-256-GCM encrypted with blind index", { field: "Username", purpose: "AES-256-GCM encrypted with HMAC-SHA256 blind index", retention: "Until user deletes", deletable: true },
credentialId: "AES-256-GCM encrypted with blind index", { field: "Display name", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true },
publicKey: "Encrypted at rest", { field: "Avatar image", purpose: "Optional profile photo stored on disk", retention: "Until user deletes", deletable: true },
}, { field: "Credential ID", purpose: "AES-256-GCM encrypted with blind index", retention: "Until user deletes", deletable: true },
{ field: "Public key", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true },
{ field: "Counter", purpose: "Replay detection (not PII)", retention: "Until user deletes", deletable: true },
{ field: "Device type", purpose: "singleDevice or multiDevice (not PII)", retention: "Until user deletes", deletable: true },
{ field: "Backed up flag", purpose: "Whether passkey is synced (not PII)", retention: "Until user deletes", deletable: true },
{ field: "Transports", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true },
],
cookieInfo: "This site uses exactly one cookie. It contains a random identifier used to connect you to your posts. It is not used for tracking, analytics, or advertising. No other cookies are set, ever.",
dataLocation: "Self-hosted PostgreSQL, encrypted at rest",
thirdParties: [],
neverStored: [
"Email address", "IP address", "Browser fingerprint", "User-agent string",
"Referrer URL", "Geolocation", "Device identifiers", "Behavioral data",
"Session replays", "Third-party tracking identifiers",
],
securityHeaders: {
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-src 'none'; object-src 'none'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"X-DNS-Prefetch-Control": "off",
}, },
retention: { retention: {
activityEvents: `${config.DATA_RETENTION_ACTIVITY_DAYS} days`, activityEvents: `${config.DATA_RETENTION_ACTIVITY_DAYS} days`,
orphanedUsers: `${config.DATA_RETENTION_ORPHAN_USER_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) => { app.get("/categories", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => {
const cats = await prisma.category.findMany({ const cats = await prisma.category.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });

View File

@@ -1,16 +1,32 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import prisma from "../lib/prisma.js";
import { encrypt, blindIndex } from "../services/encryption.js"; import { encrypt, blindIndex } from "../services/encryption.js";
import { masterKey, blindIndexKey } from "../config.js"; import { masterKey, blindIndexKey } from "../config.js";
const prisma = new PrismaClient(); const PUSH_DOMAINS = [
"fcm.googleapis.com",
"updates.push.services.mozilla.com",
"notify.windows.com",
"push.apple.com",
"web.push.apple.com",
];
function isValidPushEndpoint(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") return false;
return PUSH_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith("." + d));
} catch {
return false;
}
}
const subscribeBody = z.object({ const subscribeBody = z.object({
endpoint: z.string().url(), endpoint: z.string().url().refine(isValidPushEndpoint, "Must be a known push service endpoint"),
keys: z.object({ keys: z.object({
p256dh: z.string(), p256dh: z.string().max(200),
auth: z.string(), auth: z.string().max(200),
}), }),
boardId: z.string().optional(), boardId: z.string().optional(),
postId: z.string().optional(), postId: z.string().optional(),
@@ -21,15 +37,44 @@ const unsubscribeBody = z.object({
}); });
export default async function pushRoutes(app: FastifyInstance) { export default async function pushRoutes(app: FastifyInstance) {
app.get(
"/push/vapid",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (_req, reply) => {
const publicKey = process.env.VAPID_PUBLIC_KEY ?? "";
reply.send({ publicKey });
}
);
app.post<{ Body: z.infer<typeof subscribeBody> }>( app.post<{ Body: z.infer<typeof subscribeBody> }>(
"/push/subscribe", "/push/subscribe",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const body = subscribeBody.parse(req.body); const body = subscribeBody.parse(req.body);
if (body.boardId) {
const board = await prisma.board.findUnique({ where: { id: body.boardId }, select: { id: true } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
}
if (body.postId) {
const post = await prisma.post.findUnique({ where: { id: body.postId }, select: { id: true } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
}
const endpointIdx = blindIndex(body.endpoint, blindIndexKey); const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
const existing = await prisma.pushSubscription.findUnique({ where: { endpointIdx } }); const existing = await prisma.pushSubscription.findUnique({ where: { endpointIdx } });
if (existing) { if (existing) {
if (existing.userId !== req.user!.id) {
reply.status(403).send({ error: "Not your subscription" });
return;
}
await prisma.pushSubscription.update({ await prisma.pushSubscription.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
@@ -41,6 +86,12 @@ export default async function pushRoutes(app: FastifyInstance) {
return; return;
} }
const subCount = await prisma.pushSubscription.count({ where: { userId: req.user!.id } });
if (subCount >= 50) {
reply.status(400).send({ error: "Maximum 50 push subscriptions" });
return;
}
await prisma.pushSubscription.create({ await prisma.pushSubscription.create({
data: { data: {
endpoint: encrypt(body.endpoint, masterKey), endpoint: encrypt(body.endpoint, masterKey),
@@ -59,7 +110,7 @@ export default async function pushRoutes(app: FastifyInstance) {
app.delete<{ Body: z.infer<typeof unsubscribeBody> }>( app.delete<{ Body: z.infer<typeof unsubscribeBody> }>(
"/push/subscribe", "/push/subscribe",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const body = unsubscribeBody.parse(req.body); const body = unsubscribeBody.parse(req.body);
const endpointIdx = blindIndex(body.endpoint, blindIndexKey); const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
@@ -79,7 +130,7 @@ export default async function pushRoutes(app: FastifyInstance) {
app.get( app.get(
"/push/subscriptions", "/push/subscriptions",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const subs = await prisma.pushSubscription.findMany({ const subs = await prisma.pushSubscription.findMany({
where: { userId: req.user!.id }, where: { userId: req.user!.id },

View File

@@ -1,17 +1,15 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import prisma from "../lib/prisma.js";
const prisma = new PrismaClient();
const reactionBody = z.object({ const reactionBody = z.object({
emoji: z.string().min(1).max(8), emoji: z.string().min(1).max(8).regex(/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u, "Invalid emoji"),
}); });
export default async function reactionRoutes(app: FastifyInstance) { export default async function reactionRoutes(app: FastifyInstance) {
app.post<{ Params: { id: string }; Body: z.infer<typeof reactionBody> }>( app.post<{ Params: { id: string }; Body: z.infer<typeof reactionBody> }>(
"/comments/:id/reactions", "/comments/:id/reactions",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } }); const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) { if (!comment) {
@@ -19,6 +17,19 @@ export default async function reactionRoutes(app: FastifyInstance) {
return; return;
} }
const post = await prisma.post.findUnique({
where: { id: comment.postId },
select: { isThreadLocked: true, board: { select: { isArchived: true } } },
});
if (post?.board?.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
if (post?.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const { emoji } = reactionBody.parse(req.body); const { emoji } = reactionBody.parse(req.body);
const existing = await prisma.reaction.findUnique({ const existing = await prisma.reaction.findUnique({
@@ -35,6 +46,14 @@ export default async function reactionRoutes(app: FastifyInstance) {
await prisma.reaction.delete({ where: { id: existing.id } }); await prisma.reaction.delete({ where: { id: existing.id } });
reply.send({ toggled: false }); reply.send({ toggled: false });
} else { } else {
const distinctCount = await prisma.reaction.count({
where: { commentId: comment.id, userId: req.user!.id },
});
if (distinctCount >= 10) {
reply.status(400).send({ error: "Too many reactions" });
return;
}
await prisma.reaction.create({ await prisma.reaction.create({
data: { data: {
emoji, emoji,
@@ -49,8 +68,33 @@ export default async function reactionRoutes(app: FastifyInstance) {
app.delete<{ Params: { id: string; emoji: string } }>( app.delete<{ Params: { id: string; emoji: string } }>(
"/comments/:id/reactions/:emoji", "/comments/:id/reactions/:emoji",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const emoji = req.params.emoji;
if (!emoji || emoji.length > 8 || !/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u.test(emoji)) {
reply.status(400).send({ error: "Invalid emoji" });
return;
}
const comment = await prisma.comment.findUnique({ where: { id: req.params.id }, select: { id: true, postId: true } });
if (!comment) {
reply.status(404).send({ error: "Comment not found" });
return;
}
const post = await prisma.post.findUnique({
where: { id: comment.postId },
select: { isThreadLocked: true, board: { select: { isArchived: true } } },
});
if (post?.board?.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
if (post?.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const deleted = await prisma.reaction.deleteMany({ const deleted = await prisma.reaction.deleteMany({
where: { where: {
commentId: req.params.id, commentId: req.params.id,

View File

@@ -0,0 +1,142 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "node:crypto";
import bcrypt from "bcrypt";
import prisma from "../lib/prisma.js";
import { blindIndex, hashToken } from "../services/encryption.js";
import { blindIndexKey } from "../config.js";
import { verifyChallenge } from "../services/altcha.js";
import { generateRecoveryPhrase } from "../lib/wordlist.js";
const EXPIRY_DAYS = 90;
const recoverBody = z.object({
phrase: z.string().regex(/^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/),
altcha: z.string(),
});
async function getFailedAttempts(ip: string): Promise<number> {
const cutoff = new Date(Date.now() - 15 * 60 * 1000);
const count = await prisma.blockedToken.count({
where: { tokenHash: { startsWith: `recovery:${ip}:` }, createdAt: { gt: cutoff } },
});
return count;
}
async function recordFailedAttempt(ip: string): Promise<void> {
await prisma.blockedToken.create({
data: {
tokenHash: `recovery:${ip}:${Date.now()}`,
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
},
});
}
export default async function recoveryRoutes(app: FastifyInstance) {
// check if user has a recovery code
app.get(
"/me/recovery-code",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const code = await prisma.recoveryCode.findUnique({
where: { userId: req.user!.id },
select: { expiresAt: true },
});
if (code && code.expiresAt > new Date()) {
reply.send({ hasCode: true, expiresAt: code.expiresAt });
} else {
reply.send({ hasCode: false });
}
}
);
// generate a new recovery code
app.post(
"/me/recovery-code",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 3, timeWindow: "1 hour" } } },
async (req, reply) => {
if (req.user!.authMethod === "PASSKEY") {
reply.status(400).send({ error: "Passkey users don't need recovery codes" });
return;
}
const phrase = generateRecoveryPhrase();
const codeHash = await bcrypt.hash(phrase, 12);
const phraseIdx = blindIndex(phrase, blindIndexKey);
const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await prisma.recoveryCode.upsert({
where: { userId: req.user!.id },
create: { codeHash, phraseIdx, userId: req.user!.id, expiresAt },
update: { codeHash, phraseIdx, expiresAt },
});
reply.send({ phrase, expiresAt });
}
);
// recover identity using a code (unauthenticated)
app.post(
"/auth/recover",
{ config: { rateLimit: { max: 3, timeWindow: "15 minutes" } } },
async (req, reply) => {
const body = recoverBody.parse(req.body);
const valid = await verifyChallenge(body.altcha);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
// exponential backoff per IP (persistent)
const attempts = await getFailedAttempts(req.ip);
if (attempts >= 3) {
const delay = Math.min(1000 * Math.pow(2, attempts - 3), 30000);
await new Promise((r) => setTimeout(r, delay));
}
const phrase = body.phrase.toLowerCase().trim();
const idx = blindIndex(phrase, blindIndexKey);
const record = await prisma.recoveryCode.findUnique({
where: { phraseIdx: idx },
include: { user: { select: { id: true } } },
});
if (!record || record.expiresAt < new Date()) {
await recordFailedAttempt(req.ip);
reply.status(401).send({ error: "Invalid or expired recovery code" });
return;
}
const matches = await bcrypt.compare(phrase, record.codeHash);
if (!matches) {
await recordFailedAttempt(req.ip);
reply.status(401).send({ error: "Invalid or expired recovery code" });
return;
}
// success - issue new session token and delete used code
const token = randomBytes(32).toString("hex");
const hash = hashToken(token);
await prisma.$transaction([
prisma.user.update({
where: { id: record.userId },
data: { tokenHash: hash },
}),
prisma.recoveryCode.delete({ where: { id: record.id } }),
]);
reply
.setCookie("echoboard_token", token, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 90,
})
.send({ recovered: true });
}
);
}

View File

@@ -0,0 +1,54 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
const ROADMAP_STATUSES = ["PLANNED", "IN_PROGRESS", "DONE"] as const;
export default async function roadmapRoutes(app: FastifyInstance) {
const handler = async (req: any, reply: any) => {
const boardSlug = req.params?.boardSlug || (req.query as any)?.board || null;
const where: any = {
status: { in: [...ROADMAP_STATUSES] },
board: { isArchived: false },
};
if (boardSlug) {
where.board.slug = boardSlug;
}
const posts = await prisma.post.findMany({
where,
select: {
id: true,
title: true,
type: true,
status: true,
category: true,
voteCount: true,
createdAt: true,
board: { select: { slug: true, name: true } },
_count: { select: { comments: true } },
tags: { include: { tag: true } },
},
orderBy: [{ voteCount: "desc" }, { createdAt: "desc" }],
take: 200,
});
const mapped = posts.map((p) => ({
...p,
tags: p.tags.map((pt) => pt.tag),
}));
const columns = {
PLANNED: mapped.filter((p) => p.status === "PLANNED"),
IN_PROGRESS: mapped.filter((p) => p.status === "IN_PROGRESS"),
DONE: mapped.filter((p) => p.status === "DONE"),
};
reply.send({ columns });
};
const opts = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } };
app.get("/roadmap", opts, handler);
app.get("/b/:boardSlug/roadmap", opts, handler);
}

View File

@@ -0,0 +1,109 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
import { z } from "zod";
import { decrypt } from "../services/encryption.js";
import { masterKey } from "../config.js";
const searchQuery = z.object({
q: z.string().min(1).max(200),
});
function decryptName(encrypted: string | null): string | null {
if (!encrypted) return null;
try { return decrypt(encrypted, masterKey); } catch { return null; }
}
export default async function searchRoutes(app: FastifyInstance) {
app.get<{ Querystring: { q: string } }>("/search", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
const { q } = searchQuery.parse(req.query);
const term = q.trim();
const likeTerm = `%${term}%`;
const [boardMatches, postMatches] = await Promise.all([
prisma.$queryRaw<{ id: string; slug: string; name: string; icon_name: string | null; icon_color: string | null; description: string | null; post_count: number }[]>`
SELECT id, slug, name, "iconName" as icon_name, "iconColor" as icon_color, description,
(SELECT COUNT(*)::int FROM "Post" WHERE "boardId" = "Board".id) as post_count
FROM "Board"
WHERE "isArchived" = false
AND (
word_similarity(${term}, name) > 0.15
OR word_similarity(${term}, COALESCE(description, '')) > 0.15
OR to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', ${term})
)
ORDER BY (
word_similarity(${term}, name) +
CASE WHEN to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', ${term})
THEN 1.0 ELSE 0.0 END
) DESC
LIMIT 5
`,
prisma.$queryRaw<{ id: string }[]>`
SELECT p.id
FROM "Post" p
JOIN "Board" b ON b.id = p."boardId"
WHERE
word_similarity(${term}, p.title) > 0.15
OR to_tsvector('english', p.title) @@ plainto_tsquery('english', ${term})
OR p.description::text ILIKE ${likeTerm}
ORDER BY (
word_similarity(${term}, p.title) +
CASE WHEN to_tsvector('english', p.title) @@ plainto_tsquery('english', ${term})
THEN 1.0 ELSE 0.0 END
) DESC, p."voteCount" DESC
LIMIT 15
`,
]);
const postIds = postMatches.map((p) => p.id);
const posts = postIds.length
? await prisma.post.findMany({
where: { id: { in: postIds } },
include: {
board: { select: { slug: true, name: true, iconName: true, iconColor: true } },
author: { select: { id: true, displayName: true, avatarPath: true } },
_count: { select: { comments: true } },
},
})
: [];
// preserve relevance ordering from raw SQL
const postOrder = new Map(postIds.map((id, i) => [id, i]));
posts.sort((a, b) => (postOrder.get(a.id) ?? 0) - (postOrder.get(b.id) ?? 0));
const boards = boardMatches.map((b) => ({
type: "board" as const,
id: b.id,
title: b.name,
slug: b.slug,
iconName: b.icon_name,
iconColor: b.icon_color,
description: b.description,
postCount: b.post_count,
}));
const postResults = posts.map((p) => ({
type: "post" as const,
id: p.id,
title: p.title,
postType: p.type,
status: p.status,
voteCount: p.voteCount,
commentCount: p._count.comments,
boardSlug: p.board.slug,
boardName: p.board.name,
boardIconName: p.board.iconName,
boardIconColor: p.board.iconColor,
author: p.author
? {
id: p.author.id,
displayName: decryptName(p.author.displayName),
avatarUrl: p.author.avatarPath ? `/api/v1/avatars/${p.author.id}` : null,
}
: null,
createdAt: p.createdAt,
}));
reply.send({ boards, posts: postResults });
});
}

View File

@@ -0,0 +1,54 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
import { z } from "zod";
const similarQuery = z.object({
title: z.string().min(5).max(200),
boardId: z.string().min(1),
});
export default async function similarRoutes(app: FastifyInstance) {
app.get<{ Querystring: Record<string, string> }>(
"/similar",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const q = similarQuery.safeParse(req.query);
if (!q.success) {
reply.send({ posts: [] });
return;
}
const { title, boardId } = q.data;
const board = await prisma.board.findUnique({ where: { id: boardId }, select: { id: true } });
if (!board) {
reply.send({ posts: [] });
return;
}
// use pg_trgm similarity to find posts with similar titles
const similar = await prisma.$queryRaw<
{ id: string; title: string; status: string; vote_count: number; similarity: number }[]
>`
SELECT id, title, status, "voteCount" as vote_count,
similarity(title, ${title}) as similarity
FROM "Post"
WHERE "boardId" = ${boardId}
AND similarity(title, ${title}) > 0.25
AND status NOT IN ('DONE', 'DECLINED')
ORDER BY similarity DESC
LIMIT 5
`;
reply.send({
posts: similar.map((p) => ({
id: p.id,
title: p.title,
status: p.status,
voteCount: p.vote_count,
similarity: Math.round(p.similarity * 100),
})),
});
}
);
}

View File

@@ -0,0 +1,21 @@
import { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
export default async function templateRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string } }>(
"/boards/:boardSlug/templates",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) return reply.status(404).send({ error: "Board not found" });
const templates = await prisma.boardTemplate.findMany({
where: { boardId: board.id },
orderBy: { position: "asc" },
select: { id: true, name: true, fields: true, isDefault: true, position: true },
});
reply.send({ templates });
}
);
}

View File

@@ -1,19 +1,134 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import prisma from "../lib/prisma.js";
import { verifyChallenge } from "../services/altcha.js"; import { verifyChallenge } from "../services/altcha.js";
import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js"; import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js";
const prisma = new PrismaClient();
const voteBody = z.object({ const voteBody = z.object({
altcha: z.string(), altcha: z.string(),
}); });
const importanceBody = z.object({
importance: z.enum(["critical", "important", "nice_to_have", "minor"]),
});
export default async function voteRoutes(app: FastifyInstance) { export default async function voteRoutes(app: FastifyInstance) {
app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof voteBody> }>( app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof voteBody> }>(
"/boards/:boardSlug/posts/:id/vote", "/boards/:boardSlug/posts/:id/vote",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 hour" } } },
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;
}
if (board.isArchived) {
reply.status(403).send({ error: "Board is archived" });
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;
}
if (post.authorId === req.user!.id) {
reply.status(403).send({ error: "Cannot vote on your own post" });
return;
}
if (post.isVotingLocked) {
reply.status(403).send({ error: "Voting is locked on this post" });
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 period = getCurrentPeriod(board.voteBudgetReset);
try {
await prisma.$transaction(async (tx) => {
const existing = await tx.vote.findUnique({
where: { postId_voterId: { postId: post.id, voterId: req.user!.id } },
});
if (existing && !board.allowMultiVote) throw new Error("ALREADY_VOTED");
const remaining = await getRemainingBudget(req.user!.id, board.id, tx);
if (remaining <= 0) throw new Error("BUDGET_EXHAUSTED");
if (existing && board.allowMultiVote) {
if (existing.weight >= 3) throw new Error("MAX_VOTES");
await tx.vote.update({
where: { id: existing.id },
data: { weight: { increment: 1 } },
});
} else {
await tx.vote.create({
data: {
postId: post.id,
voterId: req.user!.id,
budgetPeriod: period,
},
});
}
await tx.post.update({
where: { id: post.id },
data: { voteCount: { increment: 1 }, lastActivityAt: new Date() },
});
}, { isolationLevel: "Serializable" });
} catch (err: any) {
if (err.message === "ALREADY_VOTED") {
reply.status(409).send({ error: "Already voted" });
return;
}
if (err.message === "BUDGET_EXHAUSTED") {
reply.status(429).send({ error: "Vote budget exhausted" });
return;
}
if (err.message === "MAX_VOTES") {
reply.status(409).send({ error: "Max 3 votes per post" });
return;
}
throw err;
}
await prisma.activityEvent.create({
data: {
type: "vote_cast",
boardId: board.id,
postId: post.id,
metadata: {},
},
});
const newCount = post.voteCount + 1;
const milestones = [10, 50, 100, 250, 500];
if (milestones.includes(newCount)) {
await prisma.activityEvent.create({
data: {
type: "vote_milestone",
boardId: board.id,
postId: post.id,
metadata: { milestoneCount: newCount, title: post.title },
},
});
}
reply.send({ ok: true, voteCount: newCount });
}
);
app.delete<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id/vote",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 hour" } } },
async (req, reply) => { async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) { if (!board) {
@@ -27,66 +142,35 @@ export default async function voteRoutes(app: FastifyInstance) {
return; return;
} }
const body = voteBody.parse(req.body); if (post.isVotingLocked) {
const valid = await verifyChallenge(body.altcha); reply.status(403).send({ error: "Voting is locked on this post" });
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return; return;
} }
const existing = await prisma.vote.findUnique({ await prisma.$transaction(async (tx) => {
where: { postId_voterId: { postId: post.id, voterId: req.user!.id } }, const vote = await tx.vote.findUnique({
}); where: { postId_voterId: { postId: req.params.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 { if (!vote) throw new Error("NO_VOTE");
await prisma.vote.create({
data: {
postId: post.id,
voterId: req.user!.id,
budgetPeriod: period,
},
});
}
await prisma.post.update({ await tx.vote.delete({ where: { id: vote.id } });
where: { id: post.id }, await tx.$executeRaw`UPDATE "Post" SET "voteCount" = GREATEST(0, "voteCount" - ${vote.weight}) WHERE "id" = ${vote.postId}`;
data: { voteCount: { increment: 1 } }, }).catch((err) => {
if (err.message === "NO_VOTE") {
reply.status(404).send({ error: "No vote found" });
return;
}
throw err;
}); });
if (reply.sent) return;
await prisma.activityEvent.create({ reply.send({ ok: true });
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 } }>( app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof importanceBody> }>(
"/boards/:boardSlug/posts/:id/vote", "/boards/:boardSlug/posts/:id/vote/importance",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) { if (!board) {
@@ -94,6 +178,18 @@ export default async function voteRoutes(app: FastifyInstance) {
return; 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;
}
if (post.isVotingLocked) {
reply.status(403).send({ error: "Voting is locked" });
return;
}
const body = importanceBody.parse(req.body);
const vote = await prisma.vote.findUnique({ const vote = await prisma.vote.findUnique({
where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } }, where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } },
}); });
@@ -103,11 +199,9 @@ export default async function voteRoutes(app: FastifyInstance) {
return; return;
} }
const weight = vote.weight; await prisma.vote.update({
await prisma.vote.delete({ where: { id: vote.id } }); where: { id: vote.id },
await prisma.post.update({ data: { importance: body.importance },
where: { id: req.params.id },
data: { voteCount: { decrement: weight } },
}); });
reply.send({ ok: true }); reply.send({ ok: true });
@@ -116,7 +210,7 @@ export default async function voteRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string } }>( app.get<{ Params: { boardSlug: string } }>(
"/boards/:boardSlug/budget", "/boards/:boardSlug/budget",
{ preHandler: [app.requireUser] }, { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } }); const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) { if (!board) {

View File

@@ -1,14 +1,19 @@
import Fastify from "fastify"; import Fastify, { FastifyError } from "fastify";
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit"; import rateLimit from "@fastify/rate-limit";
import fastifyStatic from "@fastify/static"; import fastifyStatic from "@fastify/static";
import multipart from "@fastify/multipart";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { createHmac } from "node:crypto";
import { config } from "./config.js";
import prisma from "./lib/prisma.js";
import securityPlugin from "./middleware/security.js"; import securityPlugin from "./middleware/security.js";
import authPlugin from "./middleware/auth.js"; import authPlugin from "./middleware/auth.js";
import { loadPlugins } from "./plugins/loader.js"; import { loadPlugins, startupPlugins, shutdownPlugins, getActivePluginInfo, getPluginAdminRoutes } from "./plugins/loader.js";
import { seedAllBoardTemplates } from "./lib/default-templates.js";
import boardRoutes from "./routes/boards.js"; import boardRoutes from "./routes/boards.js";
import postRoutes from "./routes/posts.js"; import postRoutes from "./routes/posts.js";
@@ -26,6 +31,25 @@ import adminPostRoutes from "./routes/admin/posts.js";
import adminBoardRoutes from "./routes/admin/boards.js"; import adminBoardRoutes from "./routes/admin/boards.js";
import adminCategoryRoutes from "./routes/admin/categories.js"; import adminCategoryRoutes from "./routes/admin/categories.js";
import adminStatsRoutes from "./routes/admin/stats.js"; import adminStatsRoutes from "./routes/admin/stats.js";
import searchRoutes from "./routes/search.js";
import roadmapRoutes from "./routes/roadmap.js";
import similarRoutes from "./routes/similar.js";
import adminTagRoutes from "./routes/admin/tags.js";
import adminNoteRoutes from "./routes/admin/notes.js";
import adminChangelogRoutes from "./routes/admin/changelog.js";
import adminWebhookRoutes from "./routes/admin/webhooks.js";
import changelogRoutes from "./routes/changelog.js";
import notificationRoutes from "./routes/notifications.js";
import embedRoutes from "./routes/embed.js";
import adminStatusRoutes from "./routes/admin/statuses.js";
import adminExportRoutes from "./routes/admin/export.js";
import adminTemplateRoutes from "./routes/admin/templates.js";
import templateRoutes from "./routes/templates.js";
import attachmentRoutes from "./routes/attachments.js";
import avatarRoutes from "./routes/avatars.js";
import recoveryRoutes from "./routes/recovery.js";
import settingsRoutes from "./routes/admin/settings.js";
import adminTeamRoutes from "./routes/admin/team.js";
export async function createServer() { export async function createServer() {
const app = Fastify({ const app = Fastify({
@@ -35,25 +59,70 @@ export async function createServer() {
return { return {
method: req.method, method: req.method,
url: req.url, url: req.url,
remoteAddress: req.ip,
}; };
}, },
}, },
}, },
}); });
await app.register(cookie, { secret: process.env.TOKEN_SECRET }); const cookieSecret = createHmac("sha256", config.TOKEN_SECRET).update("echoboard:cookie").digest("hex");
await app.register(cookie, { secret: cookieSecret });
await app.register(cors, { await app.register(cors, {
origin: true, origin: process.env.NODE_ENV === "production"
? config.WEBAUTHN_ORIGIN
: ["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"],
credentials: true, credentials: true,
}); });
const allowedOrigins = new Set(
process.env.NODE_ENV === "production"
? [config.WEBAUTHN_ORIGIN]
: [config.WEBAUTHN_ORIGIN, "http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]
);
app.addHook("onRequest", async (req, reply) => {
if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
const origin = req.headers.origin;
// Server-to-server webhook calls don't send Origin headers
if (!origin && req.url.startsWith('/api/v1/plugins/') && req.url.includes('/webhook')) return;
if (!origin || !allowedOrigins.has(origin)) {
return reply.status(403).send({ error: "Forbidden" });
}
}
});
await app.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
await app.register(rateLimit, { await app.register(rateLimit, {
max: 100, max: 100,
timeWindow: "1 minute", timeWindow: "1 minute",
}); });
app.setErrorHandler((error: FastifyError, req, reply) => {
req.log.error(error);
// zod validation errors
if (error.validation) {
reply.status(400).send({ error: "Validation failed" });
return;
}
// fastify rate limit
if (error.statusCode === 429) {
reply.status(429).send({ error: "Too many requests" });
return;
}
const status = error.statusCode ?? 500;
reply.status(status).send({
error: status >= 500 ? "Internal server error" : error.message,
});
});
await app.register(securityPlugin); await app.register(securityPlugin);
await app.register(authPlugin); await app.register(authPlugin);
app.decorate("prisma", prisma);
// api routes under /api/v1 // api routes under /api/v1
await app.register(async (api) => { await app.register(async (api) => {
await api.register(boardRoutes); await api.register(boardRoutes);
@@ -72,6 +141,25 @@ export async function createServer() {
await api.register(adminBoardRoutes); await api.register(adminBoardRoutes);
await api.register(adminCategoryRoutes); await api.register(adminCategoryRoutes);
await api.register(adminStatsRoutes); await api.register(adminStatsRoutes);
await api.register(searchRoutes);
await api.register(roadmapRoutes);
await api.register(similarRoutes);
await api.register(adminTagRoutes);
await api.register(adminNoteRoutes);
await api.register(adminChangelogRoutes);
await api.register(adminWebhookRoutes);
await api.register(changelogRoutes);
await api.register(notificationRoutes);
await api.register(embedRoutes);
await api.register(adminStatusRoutes);
await api.register(adminExportRoutes);
await api.register(adminTemplateRoutes);
await api.register(templateRoutes);
await api.register(attachmentRoutes);
await api.register(avatarRoutes);
await api.register(recoveryRoutes);
await api.register(settingsRoutes);
await api.register(adminTeamRoutes);
}, { prefix: "/api/v1" }); }, { prefix: "/api/v1" });
// serve static frontend build in production // serve static frontend build in production
@@ -88,6 +176,29 @@ export async function createServer() {
} }
await loadPlugins(app); await loadPlugins(app);
await startupPlugins();
// seed default templates for boards that have none
await seedAllBoardTemplates(prisma);
// register plugin discovery endpoint and admin routes
await app.register(async (api) => {
api.get("/plugins/active", {
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
}, async () => getActivePluginInfo());
// register plugin-provided admin routes
for (const route of getPluginAdminRoutes()) {
api.get(`/plugins${route.path}`, { preHandler: [app.requireAdmin] }, async () => ({
label: route.label,
component: route.component,
}));
}
}, { prefix: "/api/v1" });
app.addHook("onClose", async () => {
await shutdownPlugins();
});
return app; return app;
} }

View File

@@ -1,6 +1,23 @@
import { createChallenge, verifySolution } from "altcha-lib"; import { createChallenge, verifySolution } from "altcha-lib";
import { createHash } from "node:crypto";
import { config } from "../config.js"; import { config } from "../config.js";
// replay protection: track consumed challenge hashes (fingerprint -> timestamp)
const usedChallenges = new Map<string, number>();
const EXPIRY_MS = 300000;
// clean up expired entries every 5 minutes
setInterval(() => {
const cutoff = Date.now() - EXPIRY_MS;
for (const [fp, ts] of usedChallenges) {
if (ts < cutoff) usedChallenges.delete(fp);
}
}, EXPIRY_MS);
function challengeFingerprint(payload: string): string {
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
}
export async function generateChallenge(difficulty: "normal" | "light" = "normal") { export async function generateChallenge(difficulty: "normal" | "light" = "normal") {
const maxNumber = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER; const maxNumber = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER;
const challenge = await createChallenge({ const challenge = await createChallenge({
@@ -13,8 +30,30 @@ export async function generateChallenge(difficulty: "normal" | "light" = "normal
export async function verifyChallenge(payload: string): Promise<boolean> { export async function verifyChallenge(payload: string): Promise<boolean> {
try { try {
const fp = challengeFingerprint(payload);
// reject replayed challenges
if (usedChallenges.has(fp)) return false;
const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY); const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY);
return ok; if (!ok) return false;
// evict expired entries if map is large
if (usedChallenges.size >= 50000) {
const cutoff = Date.now() - EXPIRY_MS;
for (const [key, ts] of usedChallenges) {
if (ts < cutoff) usedChallenges.delete(key);
}
}
// hard cap - drop oldest entries if still over limit
if (usedChallenges.size >= 50000) {
const sorted = [...usedChallenges.entries()].sort((a, b) => a[1] - b[1]);
const toRemove = sorted.slice(0, sorted.length - 40000);
for (const [key] of toRemove) usedChallenges.delete(key);
}
usedChallenges.set(fp, Date.now());
return true;
} catch { } catch {
return false; return false;
} }

View File

@@ -21,6 +21,15 @@ export function decrypt(encoded: string, key: Buffer): string {
return decipher.update(ciphertext) + decipher.final("utf8"); return decipher.update(ciphertext) + decipher.final("utf8");
} }
export function decryptWithFallback(encoded: string, currentKey: Buffer, previousKey: Buffer | null): string {
try {
return decrypt(encoded, currentKey);
} catch {
if (previousKey) return decrypt(encoded, previousKey);
throw new Error("Decryption failed with all available keys");
}
}
export function blindIndex(value: string, key: Buffer): string { export function blindIndex(value: string, key: Buffer): string {
return createHmac("sha256", key).update(value.toLowerCase()).digest("hex"); return createHmac("sha256", key).update(value.toLowerCase()).digest("hex");
} }

View File

@@ -0,0 +1,145 @@
import prisma from "../lib/prisma.js";
import { encrypt, decryptWithFallback } from "./encryption.js";
import { masterKey, previousMasterKey } from "../config.js";
export async function reEncryptIfNeeded() {
if (!previousMasterKey) return;
console.log("Key rotation: checking for records encrypted with previous key...");
let reEncrypted = 0;
let failures = 0;
// re-encrypt user fields
const users = await prisma.user.findMany({
where: { OR: [{ displayName: { not: null } }, { username: { not: null } }] },
select: { id: true, displayName: true, username: true },
});
for (const user of users) {
const updates: Record<string, string | null> = {};
let needsUpdate = false;
if (user.displayName) {
try {
const plain = decryptWithFallback(user.displayName, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== user.displayName) {
updates.displayName = reEnc;
needsUpdate = true;
}
} catch { failures++; }
}
if (user.username) {
try {
const plain = decryptWithFallback(user.username, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== user.username) {
updates.username = reEnc;
needsUpdate = true;
}
} catch { failures++; }
}
if (needsUpdate) {
await prisma.user.update({ where: { id: user.id }, data: updates });
reEncrypted++;
}
}
// re-encrypt passkey fields
const passkeys = await prisma.passkey.findMany({
select: { id: true, credentialId: true, credentialPublicKey: true, transports: true },
});
for (const pk of passkeys) {
const updates: Record<string, any> = {};
let needsUpdate = false;
for (const field of ["credentialId", "transports"] as const) {
const val = pk[field] as string;
if (val) {
try {
const plain = decryptWithFallback(val, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== val) {
updates[field] = reEnc;
needsUpdate = true;
}
} catch { failures++; }
}
}
// re-encrypt public key (stored as encrypted string in Bytes field)
if (pk.credentialPublicKey) {
try {
const pubKeyStr = pk.credentialPublicKey.toString();
const plain = decryptWithFallback(pubKeyStr, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== pubKeyStr) {
updates.credentialPublicKey = Buffer.from(reEnc);
needsUpdate = true;
}
} catch { failures++; }
}
if (needsUpdate) {
await prisma.passkey.update({ where: { id: pk.id }, data: updates });
reEncrypted++;
}
}
// re-encrypt push subscription fields
const pushSubs = await prisma.pushSubscription.findMany({
select: { id: true, endpoint: true, keysP256dh: true, keysAuth: true },
});
for (const sub of pushSubs) {
const updates: Record<string, string> = {};
let needsUpdate = false;
for (const field of ["endpoint", "keysP256dh", "keysAuth"] as const) {
const val = sub[field];
if (val) {
try {
const plain = decryptWithFallback(val, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== val) {
updates[field] = reEnc;
needsUpdate = true;
}
} catch { failures++; }
}
}
if (needsUpdate) {
await prisma.pushSubscription.update({ where: { id: sub.id }, data: updates });
reEncrypted++;
}
}
// re-encrypt webhook secrets
const webhooks = await prisma.webhook.findMany({
select: { id: true, secret: true },
});
for (const wh of webhooks) {
try {
const plain = decryptWithFallback(wh.secret, masterKey, previousMasterKey);
const reEnc = encrypt(plain, masterKey);
if (reEnc !== wh.secret) {
await prisma.webhook.update({ where: { id: wh.id }, data: { secret: reEnc } });
reEncrypted++;
}
} catch {}
}
if (failures > 0) {
console.warn(`Key rotation: ${failures} records failed to re-encrypt`);
}
if (reEncrypted > 0) {
console.log(`Key rotation: re-encrypted ${reEncrypted} records`);
} else {
console.log("Key rotation: all records already use current key");
}
}

View File

@@ -0,0 +1,40 @@
import { Prisma } from "@prisma/client";
const TRACKED_MODELS = ["User", "Passkey", "PushSubscription"] as const;
const MANIFEST_FIELDS: Record<string, string[]> = {
User: [
"id", "authMethod", "tokenHash", "username", "usernameIdx",
"displayName", "avatarPath", "darkMode", "createdAt", "updatedAt",
"posts", "comments", "votes", "reactions", "passkeys", "notifications", "pushSubscriptions", "adminLink", "attachments", "edits", "recoveryCode",
],
Passkey: [
"id", "credentialId", "credentialIdIdx", "credentialPublicKey",
"counter", "credentialDeviceType", "credentialBackedUp", "transports",
"userId", "user", "createdAt",
],
PushSubscription: [
"id", "endpoint", "endpointIdx", "keysP256dh", "keysAuth",
"userId", "user", "boardId", "board", "postId", "post", "failureCount", "createdAt",
],
};
export function validateManifest() {
for (const modelName of TRACKED_MODELS) {
const dmmfModel = Prisma.dmmf.datamodel.models.find((m) => m.name === modelName);
if (!dmmfModel) continue;
const schemaFields = dmmfModel.fields.map((f) => f.name);
const manifestFields = MANIFEST_FIELDS[modelName] || [];
for (const field of schemaFields) {
if (!manifestFields.includes(field)) {
console.error(
`Data manifest violation: field "${modelName}.${field}" exists in schema but is not declared in the data manifest. ` +
`Add it to the manifest or the app will not start.`
);
process.exit(1);
}
}
}
}

View File

@@ -1,11 +1,9 @@
import webpush from "web-push"; import webpush from "web-push";
import { PrismaClient } from "@prisma/client"; import prisma from "../lib/prisma.js";
import { config } from "../config.js"; import { config } from "../config.js";
import { decrypt } from "./encryption.js"; import { decrypt } from "./encryption.js";
import { masterKey } from "../config.js"; import { masterKey } from "../config.js";
const prisma = new PrismaClient();
if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY && config.VAPID_CONTACT) { if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY && config.VAPID_CONTACT) {
webpush.setVapidDetails( webpush.setVapidDetails(
config.VAPID_CONTACT, config.VAPID_CONTACT,
@@ -21,7 +19,7 @@ interface PushPayload {
tag?: string; tag?: string;
} }
export async function sendNotification(sub: { endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload) { export async function sendNotification(sub: { id: string; endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload): Promise<"ok" | "gone" | "failed"> {
try { try {
await webpush.sendNotification( await webpush.sendNotification(
{ {
@@ -33,39 +31,58 @@ export async function sendNotification(sub: { endpoint: string; keysP256dh: stri
}, },
JSON.stringify(payload) JSON.stringify(payload)
); );
return true; return "ok";
} catch (err: any) { } catch (err: any) {
if (err.statusCode === 404 || err.statusCode === 410) { if (err.statusCode === 404 || err.statusCode === 410) {
return false; return "gone";
} }
throw err; // transient failure - increment counter
await prisma.pushSubscription.update({
where: { id: sub.id },
data: { failureCount: { increment: 1 } },
}).catch(() => {});
return "failed";
}
}
const MAX_FANOUT = 5000;
const BATCH_SIZE = 50;
async function processResults(subs: { id: string; endpoint: string; keysP256dh: string; keysAuth: string }[], event: PushPayload) {
if (subs.length > MAX_FANOUT) {
console.warn(`push fanout capped: ${subs.length} subscribers truncated to ${MAX_FANOUT}`);
}
const capped = subs.slice(0, MAX_FANOUT);
const gone: string[] = [];
for (let i = 0; i < capped.length; i += BATCH_SIZE) {
const batch = capped.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map((sub) => sendNotification(sub, event).then((r) => ({ id: sub.id, result: r })))
);
for (const r of results) {
if (r.status === "fulfilled" && r.value.result === "gone") {
gone.push(r.value.id);
}
}
}
if (gone.length > 0) {
await prisma.pushSubscription.deleteMany({ where: { id: { in: gone } } });
} }
} }
export async function notifyPostSubscribers(postId: string, event: PushPayload) { export async function notifyPostSubscribers(postId: string, event: PushPayload) {
const subs = await prisma.pushSubscription.findMany({ where: { postId } }); const subs = await prisma.pushSubscription.findMany({ where: { postId } });
const failed: string[] = []; await processResults(subs, event);
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) { export async function notifyBoardSubscribers(boardId: string, event: PushPayload) {
const subs = await prisma.pushSubscription.findMany({ where: { boardId, postId: null } }); const subs = await prisma.pushSubscription.findMany({ where: { boardId, postId: null } });
const failed: string[] = []; await processResults(subs, event);
}
for (const sub of subs) {
const ok = await sendNotification(sub, event); export async function notifyUserReply(userId: string, event: PushPayload) {
if (!ok) failed.push(sub.id); const subs = await prisma.pushSubscription.findMany({ where: { userId } });
} await processResults(subs, event);
if (failed.length > 0) {
await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } });
}
} }

View File

@@ -0,0 +1,104 @@
import prisma from "../lib/prisma.js";
import { createHmac } from "node:crypto";
import { resolve as dnsResolve } from "node:dns/promises";
import { request as httpsRequest } from "node:https";
import { decrypt } from "./encryption.js";
import { masterKey } from "../config.js";
function isPrivateIp(ip: string): boolean {
// normalize IPv6-mapped IPv4 (::ffff:127.0.0.1 -> 127.0.0.1)
const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
if (normalized === "127.0.0.1" || normalized === "::1") return true;
if (normalized.startsWith("0.")) return true; // 0.0.0.0/8
if (normalized.startsWith("10.") || normalized.startsWith("192.168.")) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(normalized)) return true;
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(normalized)) return true; // 100.64.0.0/10 CGNAT
if (normalized.startsWith("198.18.") || normalized.startsWith("198.19.")) return true; // 198.18.0.0/15
if (/^24[0-9]\./.test(normalized) || normalized.startsWith("255.")) return true; // 240.0.0.0/4
if (normalized === "169.254.169.254" || normalized.startsWith("169.254.")) return true; // link-local
if (normalized.startsWith("fe80:") || normalized.startsWith("fc00:") || normalized.startsWith("fd")) return true;
return false;
}
export function isAllowedUrl(raw: string): boolean {
try {
const u = new URL(raw);
if (u.protocol !== "https:") return false;
const host = u.hostname;
if (host === "localhost" || isPrivateIp(host)) return false;
if (host.endsWith(".internal") || host.endsWith(".local")) return false;
return true;
} catch {
return false;
}
}
export async function resolvedIpIsAllowed(hostname: string): Promise<boolean> {
try {
const addresses = await dnsResolve(hostname);
for (const addr of addresses) {
if (isPrivateIp(addr)) return false;
}
return true;
} catch {
return false;
}
}
export async function fireWebhook(event: string, payload: Record<string, unknown>) {
const webhooks = await prisma.webhook.findMany({
where: { active: true, events: { has: event } },
});
for (const wh of webhooks) {
if (!isAllowedUrl(wh.url)) continue;
const url = new URL(wh.url);
let addresses: string[];
try {
addresses = await dnsResolve(url.hostname);
if (addresses.some((addr) => isPrivateIp(addr))) continue;
} catch {
continue;
}
let secret: string;
try {
secret = decrypt(wh.secret, masterKey);
} catch (err: any) {
console.warn(`webhook ${wh.id}: secret decryption failed - ${err?.message ?? "unknown error"}`);
continue;
}
const body = JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload });
const signature = createHmac("sha256", secret).update(body).digest("hex");
// connect directly to the resolved IP, use original hostname for TLS SNI
// and Host header - this closes the DNS rebinding window
const req = httpsRequest({
hostname: addresses[0],
port: url.port || 443,
path: url.pathname + url.search,
method: "POST",
headers: {
"Host": url.hostname,
"Content-Type": "application/json",
"X-Echoboard-Signature": signature,
"X-Echoboard-Event": event,
},
servername: url.hostname,
timeout: 10000,
}, (res) => {
res.resume(); // drain response
});
req.setTimeout(10000, () => {
req.destroy(new Error("timeout"));
});
req.on("error", (err) => {
console.warn(`webhook delivery failed for ${wh.id}: ${err.message}`);
});
req.write(body);
req.end();
}
}

View File

@@ -9,15 +9,21 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource/space-grotesk": "^5.0.0",
"@fontsource/sora": "^5.0.0", "@fontsource/sora": "^5.0.0",
"@fontsource/space-grotesk": "^5.0.0",
"@simplewebauthn/browser": "^11.0.0", "@simplewebauthn/browser": "^11.0.0",
"@tabler/icons-react": "^3.40.0",
"dompurify": "^3.3.3",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-router-dom": "^7.0.0" "react-markdown": "^10.1.0",
"react-router-dom": "^7.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",

View File

@@ -0,0 +1,92 @@
(function() {
'use strict';
var scripts = document.querySelectorAll('script[data-echoboard]');
for (var i = 0; i < scripts.length; i++) {
var s = scripts[i];
if (s.getAttribute('data-rendered')) continue;
s.setAttribute('data-rendered', '1');
var board = s.getAttribute('data-board');
var baseUrl = s.getAttribute('data-url') || s.src.replace(/\/embed\.js.*$/, '');
var theme = s.getAttribute('data-theme') || 'dark';
var limit = s.getAttribute('data-limit') || '10';
var sort = s.getAttribute('data-sort') || 'top';
var height = parseInt(s.getAttribute('data-height') || '500', 10) || 500;
var mode = s.getAttribute('data-mode') || 'inline';
var label = s.getAttribute('data-label') || 'Feedback';
var position = s.getAttribute('data-position') || 'right';
if (!board) continue;
var params = '?theme=' + encodeURIComponent(theme) +
'&limit=' + encodeURIComponent(limit) +
'&sort=' + encodeURIComponent(sort);
var src = baseUrl + '/embed/' + encodeURIComponent(board) + params;
if (mode === 'button') {
// Floating feedback button mode
var isRight = position !== 'left';
var isDark = theme === 'dark';
var accent = '#F59E0B';
// Button
var btn = document.createElement('button');
btn.textContent = label;
btn.style.cssText = 'position:fixed;bottom:24px;' + (isRight ? 'right' : 'left') + ':24px;' +
'z-index:999998;padding:10px 20px;border:none;border-radius:24px;cursor:pointer;' +
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;font-weight:600;' +
'background:' + accent + ';color:#161616;box-shadow:0 4px 16px rgba(0,0,0,0.3);' +
'transition:transform 0.2s ease,box-shadow 0.2s ease;';
btn.onmouseenter = function() { btn.style.transform = 'scale(1.05)'; btn.style.boxShadow = '0 6px 24px rgba(0,0,0,0.4)'; };
btn.onmouseleave = function() { btn.style.transform = 'scale(1)'; btn.style.boxShadow = '0 4px 16px rgba(0,0,0,0.3)'; };
// Popup container
var popup = document.createElement('div');
popup.style.cssText = 'position:fixed;bottom:80px;' + (isRight ? 'right' : 'left') + ':24px;' +
'z-index:999999;width:380px;max-width:calc(100vw - 48px);height:' + height + 'px;max-height:calc(100vh - 120px);' +
'border-radius:12px;overflow:hidden;' +
'box-shadow:0 8px 40px rgba(0,0,0,0.4);border:1px solid ' + (isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)') + ';' +
'display:none;transform:translateY(12px);opacity:0;transition:transform 0.25s ease,opacity 0.25s ease;';
var popupIframe = document.createElement('iframe');
popupIframe.style.cssText = 'width:100%;height:100%;border:none;';
popupIframe.setAttribute('title', 'Echoboard - ' + board);
popupIframe.setAttribute('sandbox', 'allow-scripts');
popup.appendChild(popupIframe);
var open = false;
btn.onclick = function() {
if (!open) {
if (!popupIframe.src) popupIframe.src = src;
popup.style.display = 'block';
// force reflow
popup.offsetHeight;
popup.style.transform = 'translateY(0)';
popup.style.opacity = '1';
open = true;
} else {
popup.style.transform = 'translateY(12px)';
popup.style.opacity = '0';
setTimeout(function() { popup.style.display = 'none'; }, 250);
open = false;
}
};
document.body.appendChild(btn);
document.body.appendChild(popup);
} else {
// Inline embed mode (default)
var iframe = document.createElement('iframe');
iframe.src = src;
iframe.style.width = '100%';
iframe.style.height = height + 'px';
iframe.style.border = 'none';
iframe.style.borderRadius = '8px';
iframe.style.overflow = 'hidden';
iframe.setAttribute('loading', 'lazy');
iframe.setAttribute('title', 'Echoboard - ' + board);
iframe.setAttribute('sandbox', 'allow-scripts');
s.parentNode.insertBefore(iframe, s.nextSibling);
}
}
})();

22
packages/web/public/sw.js Normal file
View File

@@ -0,0 +1,22 @@
self.addEventListener("push", (event) => {
let data = { title: "Echoboard", body: "New activity on your watched content" };
try {
data = event.data.json();
} catch {}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon || "/favicon.ico",
data: { url: data.url },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const raw = event.notification.data?.url || "/";
const safe = new URL(raw, self.location.origin);
const url = safe.origin === self.location.origin ? safe.href : "/";
event.waitUntil(clients.openWindow(url));
});

View File

@@ -1,8 +1,14 @@
import { useState } from 'react' import { useState, useEffect, useRef } from 'react'
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom' import { BrowserRouter, Routes, Route, useLocation, useNavigate, Link } from 'react-router-dom'
import { IconShieldCheck, IconArrowRight, IconX, IconSearch, IconChevronUp, IconMessageCircle, IconBug, IconBulb, IconCommand, IconArrowNarrowRight } from '@tabler/icons-react'
import { AuthProvider, useAuthState } from './hooks/useAuth' import { AuthProvider, useAuthState } from './hooks/useAuth'
import { AdminProvider, useAdminState, useAdmin } from './hooks/useAdmin'
import { ThemeProvider, useThemeState } from './hooks/useTheme' import { ThemeProvider, useThemeState } from './hooks/useTheme'
import { TranslationProvider, useTranslationState } from './i18n'
import { BrandingProvider } from './hooks/useBranding'
import { ConfirmProvider } from './hooks/useConfirm'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import AdminSidebar from './components/AdminSidebar'
import MobileNav from './components/MobileNav' import MobileNav from './components/MobileNav'
import ThemeToggle from './components/ThemeToggle' import ThemeToggle from './components/ThemeToggle'
import IdentityBanner from './components/IdentityBanner' import IdentityBanner from './components/IdentityBanner'
@@ -19,37 +25,495 @@ import AdminLogin from './pages/admin/AdminLogin'
import AdminDashboard from './pages/admin/AdminDashboard' import AdminDashboard from './pages/admin/AdminDashboard'
import AdminPosts from './pages/admin/AdminPosts' import AdminPosts from './pages/admin/AdminPosts'
import AdminBoards from './pages/admin/AdminBoards' import AdminBoards from './pages/admin/AdminBoards'
import AdminCategories from './pages/admin/AdminCategories'
import AdminDataRetention from './pages/admin/AdminDataRetention'
import AdminTags from './pages/admin/AdminTags'
import RoadmapPage from './pages/RoadmapPage'
import ChangelogPage from './pages/ChangelogPage'
import AdminChangelog from './pages/admin/AdminChangelog'
import AdminWebhooks from './pages/admin/AdminWebhooks'
import AdminEmbed from './pages/admin/AdminEmbed'
import AdminStatuses from './pages/admin/AdminStatuses'
import AdminExport from './pages/admin/AdminExport'
import AdminTemplates from './pages/admin/AdminTemplates'
import AdminSettings from './pages/admin/AdminSettings'
import AdminTeam from './pages/admin/AdminTeam'
import AdminJoin from './pages/admin/AdminJoin'
import ProfilePage from './pages/ProfilePage'
import RecoverPage from './pages/RecoverPage'
import EmbedBoard from './pages/EmbedBoard'
import { api } from './lib/api'
import BoardIcon from './components/BoardIcon'
import StatusBadge from './components/StatusBadge'
import Avatar from './components/Avatar'
interface SearchBoard {
type: 'board'
id: string
title: string
slug: string
iconName: string | null
iconColor: string | null
description: string | null
postCount: number
}
interface SearchPost {
type: 'post'
id: string
title: string
postType: 'FEATURE_REQUEST' | 'BUG_REPORT'
status: string
voteCount: number
commentCount: number
boardSlug: string
boardName: string
boardIconName: string | null
boardIconColor: string | null
author: { id: string; displayName: string | null; avatarUrl: string | null } | null
createdAt: string
}
function searchTimeAgo(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`
return `${Math.floor(days / 30)}mo ago`
}
function SearchPage() {
const [query, setQuery] = useState('')
const [boards, setBoards] = useState<SearchBoard[]>([])
const [posts, setPosts] = useState<SearchPost[]>([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
useEffect(() => {
if (!query.trim()) { setBoards([]); setPosts([]); setSearched(false); return }
const t = setTimeout(async () => {
setLoading(true)
try {
const res = await api.get<{ boards: SearchBoard[]; posts: SearchPost[] }>(`/search?q=${encodeURIComponent(query)}`)
setBoards(res.boards)
setPosts(res.posts)
} catch { setBoards([]); setPosts([]) }
setSearched(true)
setLoading(false)
}, 200)
return () => clearTimeout(t)
}, [query])
const totalResults = boards.length + posts.length
const isMac = navigator.platform?.includes('Mac')
return (
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<h1 className="sr-only">Search</h1>
{/* Search input */}
<div className="relative mb-6">
<IconSearch
size={18} stroke={2}
style={{ position: 'absolute', left: 14, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-tertiary)', pointerEvents: 'none' }}
/>
<input
className="input"
style={{ paddingLeft: 42, fontSize: 'var(--text-base)' }}
placeholder="Search posts, feedback, and boards..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
aria-label="Search posts, feedback, and boards"
/>
</div>
{/* Status line */}
<div aria-live="polite" style={{ minHeight: 20 }}>
{loading && <p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>Searching...</p>}
{!loading && searched && (
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
{totalResults === 0 ? 'No results found - try different keywords or check for typos' : `${totalResults} result${totalResults === 1 ? '' : 's'} found`}
</p>
)}
</div>
{/* Empty state - before searching */}
{!query.trim() && !searched && (
<div className="flex flex-col items-center py-16 fade-in" style={{ textAlign: 'center' }}>
<div
className="flex items-center justify-center mb-5"
style={{
width: 72, height: 72,
borderRadius: 'var(--radius-lg)',
background: 'var(--accent-subtle)',
}}
>
<IconSearch size={32} stroke={1.5} style={{ color: 'var(--accent)' }} />
</div>
<h2
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-xl)' }}
>
Search across all boards
</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6, maxWidth: 380 }}>
Find posts, bug reports, feature requests, and boards. Typos are forgiven - the search is fuzzy.
</p>
<div
className="flex items-center gap-2 mt-6"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
>
<span
className="inline-flex items-center gap-1 px-2 py-1"
style={{
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
fontFamily: 'var(--font-mono, monospace)',
}}
>
{isMac ? <IconCommand size={11} stroke={2} /> : 'Ctrl+'}K
</span>
<span>for quick search anywhere</span>
</div>
</div>
)}
{/* No results state */}
{!loading && searched && totalResults === 0 && (
<div className="flex flex-col items-center py-12 fade-in" style={{ textAlign: 'center' }}>
<div
className="flex items-center justify-center mb-4"
style={{
width: 56, height: 56,
borderRadius: 'var(--radius-lg)',
background: 'var(--surface-hover)',
}}
>
<IconSearch size={24} stroke={1.5} style={{ color: 'var(--text-tertiary)' }} />
</div>
<p className="font-medium mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>
Nothing matched "{query}"
</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
Try broader terms or check the spelling
</p>
</div>
)}
{/* Board results */}
{boards.length > 0 && (
<div className="mb-6 mt-4">
<h3
className="font-medium mb-3"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
>
Boards
</h3>
<div className="flex flex-col gap-2">
{boards.map((b, i) => (
<Link
key={b.id}
to={`/b/${b.slug}`}
className="card card-interactive flex items-center gap-4 stagger-in"
style={{ padding: '14px 16px', '--stagger': i } as React.CSSProperties}
>
<BoardIcon name={b.title} iconName={b.iconName} iconColor={b.iconColor} size={36} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{b.title}
</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{b.postCount} post{b.postCount !== 1 ? 's' : ''}
</span>
</div>
{b.description && (
<p className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
{b.description}
</p>
)}
</div>
<IconArrowNarrowRight size={16} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
</Link>
))}
</div>
</div>
)}
{/* Post results */}
{posts.length > 0 && (
<div className="mt-4">
<h3
className="font-medium mb-3"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
>
Posts
</h3>
<div className="flex flex-col gap-2">
{posts.map((p, i) => (
<Link
key={p.id}
to={`/b/${p.boardSlug}/post/${p.id}`}
className="card card-interactive stagger-in"
style={{ padding: '14px 16px', '--stagger': i } as React.CSSProperties}
>
<div className="flex items-start gap-3">
{/* Vote count pill */}
<div
className="flex flex-col items-center shrink-0"
style={{
minWidth: 40,
padding: '6px 8px',
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
}}
>
<IconChevronUp size={14} stroke={2.5} style={{ color: 'var(--text-tertiary)' }} />
<span className="font-semibold" style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
{p.voteCount}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5"
style={{
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
background: p.postType === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
color: p.postType === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
}}
>
{p.postType === 'BUG_REPORT'
? <IconBug size={11} stroke={2} aria-hidden="true" />
: <IconBulb size={11} stroke={2} aria-hidden="true" />
}
{p.postType === 'BUG_REPORT' ? 'Bug' : 'Feature'}
</span>
<StatusBadge status={p.status} />
</div>
<h4
className="font-medium truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
{p.title}
</h4>
<div className="flex items-center gap-3 mt-2 flex-wrap" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
{/* Author */}
<span className="inline-flex items-center gap-1.5">
<Avatar
userId={p.author?.id ?? '0000'}
name={p.author?.displayName ?? null}
avatarUrl={p.author?.avatarUrl}
size={16}
/>
{p.author?.displayName ?? `Anonymous #${(p.author?.id ?? '0000').slice(-4)}`}
</span>
{/* Board */}
<span className="inline-flex items-center gap-1.5">
<BoardIcon name={p.boardName} iconName={p.boardIconName} iconColor={p.boardIconColor} size={16} />
{p.boardName}
</span>
{/* Comments */}
<span className="inline-flex items-center gap-1">
<IconMessageCircle size={12} stroke={2} />
{p.commentCount}
</span>
{/* Time */}
<time dateTime={p.createdAt}>{searchTimeAgo(p.createdAt)}</time>
</div>
</div>
</div>
</Link>
))}
</div>
</div>
)}
</div>
)
}
function RequireAdmin({ children }: { children: React.ReactNode }) {
const [ok, setOk] = useState<boolean | null>(null)
const nav = useNavigate()
useEffect(() => {
api.get('/admin/boards')
.then(() => setOk(true))
.catch(() => nav('/admin/login', { replace: true }))
}, [nav])
if (!ok) return null
return <>{children}</>
}
function NewPostRedirect() {
const nav = useNavigate()
useEffect(() => { nav('/') }, [nav])
return null
}
function AdminBanner() {
const admin = useAdmin()
const location = useLocation()
const isAdminPage = location.pathname.startsWith('/admin')
if (!admin.isAdmin || isAdminPage) return null
return (
<div
className="flex items-center gap-3 px-4 py-2 slide-down"
style={{
position: 'sticky',
top: 0,
zIndex: 50,
background: 'rgba(6, 182, 212, 0.08)',
backdropFilter: 'blur(12px)',
borderBottom: '1px solid rgba(6, 182, 212, 0.15)',
fontSize: 'var(--text-xs)',
}}
>
<IconShieldCheck size={14} stroke={2} style={{ color: 'var(--admin-accent)', flexShrink: 0 }} />
<span style={{ color: 'var(--admin-accent)', fontWeight: 600 }}>Admin mode</span>
<div className="flex items-center gap-2 ml-auto">
<Link
to="/admin"
className="inline-flex items-center gap-1 px-2.5 py-1 rounded"
style={{
color: 'var(--admin-accent)',
background: 'rgba(6, 182, 212, 0.1)',
borderRadius: 'var(--radius-sm)',
transition: 'background var(--duration-fast) ease-out',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.2)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.1)' }}
onFocus={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.2)' }}
onBlur={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.1)' }}
>
Admin panel <IconArrowRight size={11} stroke={2.5} />
</Link>
<button
onClick={admin.exitAdminMode}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded"
style={{
color: 'var(--text-tertiary)',
borderRadius: 'var(--radius-sm)',
transition: 'color var(--duration-fast) ease-out',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--text)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--text)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
>
<IconX size={11} stroke={2} /> Exit
</button>
</div>
</div>
)
}
function RouteAnnouncer() {
const location = useLocation()
const [title, setTitle] = useState('')
const timerRef = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
setTitle(document.title || location.pathname)
}, 300)
return () => clearTimeout(timerRef.current)
}, [location.pathname])
return (
<div
aria-live="assertive"
aria-atomic="true"
role="status"
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
}}
>
{title}
</div>
)
}
function Layout() { function Layout() {
const location = useLocation() const location = useLocation()
const admin = useAdmin()
const [passkeyMode, setPasskeyMode] = useState<'register' | 'login' | null>(null) const [passkeyMode, setPasskeyMode] = useState<'register' | 'login' | null>(null)
const isAdmin = location.pathname.startsWith('/admin') const isAdminPage = location.pathname.startsWith('/admin')
const showAdminMode = admin.isAdmin && !isAdminPage
return ( return (
<> <>
<a href="#main" className="sr-only">Skip to main content</a>
<RouteAnnouncer />
<CommandPalette /> <CommandPalette />
<div className="flex min-h-screen" style={{ background: 'var(--bg)' }}> <div className={`flex min-h-screen ${showAdminMode ? 'admin-mode' : ''}`} style={{ background: 'var(--bg)' }}>
{!isAdmin && <Sidebar />} {isAdminPage ? <AdminSidebar /> : <Sidebar />}
<main className="flex-1 pb-20 md:pb-0"> <main id="main" className="flex-1 pb-20 md:pb-0">
<AdminBanner />
<Routes> <Routes>
<Route path="/" element={<BoardIndex />} /> <Route path="/" element={<BoardIndex />} />
<Route path="/b/:boardSlug" element={<BoardFeed />} /> <Route path="/b/:boardSlug" element={<BoardFeed />} />
<Route path="/b/:boardSlug/post/:postId" element={<PostDetail />} /> <Route path="/b/:boardSlug/post/:postId" element={<PostDetail />} />
<Route path="/b/:boardSlug/new" element={<BoardFeed />} /> <Route path="/b/:boardSlug/new" element={<BoardFeed />} />
<Route path="/b/:boardSlug/roadmap" element={<RoadmapPage />} />
<Route path="/b/:boardSlug/changelog" element={<ChangelogPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/new" element={<NewPostRedirect />} />
<Route path="/activity" element={<ActivityFeed />} /> <Route path="/activity" element={<ActivityFeed />} />
<Route path="/settings" element={<IdentitySettings />} /> <Route path="/settings" element={<IdentitySettings />} />
<Route path="/my-posts" element={<MySubmissions />} /> <Route path="/my-posts" element={<MySubmissions />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/privacy" element={<PrivacyPage />} /> <Route path="/privacy" element={<PrivacyPage />} />
<Route path="/recover" element={<RecoverPage />} />
<Route path="/roadmap" element={<RoadmapPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
<Route path="/admin/login" element={<AdminLogin />} /> <Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin" element={<AdminDashboard />} /> <Route path="/admin" element={<RequireAdmin><AdminDashboard /></RequireAdmin>} />
<Route path="/admin/posts" element={<AdminPosts />} /> <Route path="/admin/posts" element={<RequireAdmin><AdminPosts /></RequireAdmin>} />
<Route path="/admin/boards" element={<AdminBoards />} /> <Route path="/admin/boards" element={<RequireAdmin><AdminBoards /></RequireAdmin>} />
<Route path="/admin/categories" element={<RequireAdmin><AdminCategories /></RequireAdmin>} />
<Route path="/admin/tags" element={<RequireAdmin><AdminTags /></RequireAdmin>} />
<Route path="/admin/changelog" element={<RequireAdmin><AdminChangelog /></RequireAdmin>} />
<Route path="/admin/webhooks" element={<RequireAdmin><AdminWebhooks /></RequireAdmin>} />
<Route path="/admin/embed" element={<RequireAdmin><AdminEmbed /></RequireAdmin>} />
<Route path="/admin/statuses" element={<RequireAdmin><AdminStatuses /></RequireAdmin>} />
<Route path="/admin/export" element={<RequireAdmin><AdminExport /></RequireAdmin>} />
<Route path="/admin/templates" element={<RequireAdmin><AdminTemplates /></RequireAdmin>} />
<Route path="/admin/data-retention" element={<RequireAdmin><AdminDataRetention /></RequireAdmin>} />
<Route path="/admin/team" element={<RequireAdmin><AdminTeam /></RequireAdmin>} />
<Route path="/admin/join/:token" element={<AdminJoin />} />
<Route path="/admin/settings" element={<RequireAdmin><AdminSettings /></RequireAdmin>} />
</Routes> </Routes>
</main> </main>
</div> </div>
{!isAdmin && <MobileNav />} {!isAdminPage && <MobileNav />}
<ThemeToggle /> <ThemeToggle />
{!isAdmin && ( {!isAdminPage && (
<IdentityBanner onRegister={() => setPasskeyMode('register')} /> <IdentityBanner onRegister={() => setPasskeyMode('register')} />
)} )}
<PasskeyModal <PasskeyModal
@@ -63,15 +527,38 @@ function Layout() {
export default function App() { export default function App() {
const auth = useAuthState() const auth = useAuthState()
const theme = useThemeState() const admin = useAdminState()
const i18n = useTranslationState()
const theme = useThemeState((t) => {
if (auth.user?.isPasskeyUser) {
api.put('/me', { darkMode: t }).catch(() => {})
}
})
useEffect(() => {
if (auth.user?.isPasskeyUser && auth.user.darkMode) {
theme.set(auth.user.darkMode as 'dark' | 'light' | 'system')
}
}, [auth.user?.isPasskeyUser])
return ( return (
<ThemeProvider value={theme}> <BrandingProvider>
<AuthProvider value={auth}> <ConfirmProvider>
<BrowserRouter> <TranslationProvider value={i18n}>
<Layout /> <ThemeProvider value={theme}>
</BrowserRouter> <AuthProvider value={auth}>
</AuthProvider> <AdminProvider value={admin}>
</ThemeProvider> <BrowserRouter>
<Routes>
<Route path="/embed/:boardSlug" element={<EmbedBoard />} />
<Route path="*" element={<Layout />} />
</Routes>
</BrowserRouter>
</AdminProvider>
</AuthProvider>
</ThemeProvider>
</TranslationProvider>
</ConfirmProvider>
</BrandingProvider>
) )
} }

View File

@@ -2,41 +2,154 @@
@layer base { @layer base {
:root { :root {
--bg: #141420; /* Neutral dark palette */
--surface: #1c1c2e; --bg: #161616;
--surface-hover: #24243a; --surface: #1e1e1e;
--border: rgba(245, 240, 235, 0.08); --surface-hover: #272727;
--border-hover: rgba(245, 240, 235, 0.15); --surface-raised: #2a2a2a;
--text: #f5f0eb; --border: rgba(255, 255, 255, 0.08);
--text-secondary: rgba(245, 240, 235, 0.6); --border-hover: rgba(255, 255, 255, 0.15);
--text-tertiary: rgba(245, 240, 235, 0.35); --border-accent: rgba(245, 158, 11, 0.3);
/* Text */
--text: #f0f0f0;
--text-secondary: rgba(240, 240, 240, 0.72);
--text-tertiary: rgba(240, 240, 240, 0.71);
/* Accent - amber/gold */
--accent: #F59E0B; --accent: #F59E0B;
--accent-hover: #D97706; --accent-hover: #FBBF24;
--accent-subtle: rgba(245, 158, 11, 0.15); --accent-dim: #D97706;
--admin-accent: #06B6D4; --accent-subtle: rgba(245, 158, 11, 0.12);
--admin-subtle: rgba(6, 182, 212, 0.15); --accent-glow: rgba(245, 158, 11, 0.25);
/* Admin - cyan */
--admin-accent: #08C4E4;
--admin-subtle: rgba(8, 196, 228, 0.12);
/* Semantic */
--success: #22C55E; --success: #22C55E;
--warning: #EAB308; --warning: #EAB308;
--error: #EF4444; --error: #F98A8A;
--info: #3B82F6; --info: #6DB5FC;
/* Status badge colors (bright for dark bg) */
--status-open: #F59E0B;
--status-review: #22D3EE;
--status-planned: #6DB5FC;
--status-progress: #EAB308;
--status-done: #22C55E;
--status-declined: #F98A8A;
/* Typography */
--font-heading: 'Space Grotesk', system-ui, sans-serif; --font-heading: 'Space Grotesk', system-ui, sans-serif;
--font-body: 'Sora', system-ui, sans-serif; --font-body: 'Sora', system-ui, sans-serif;
/* Type scale - Perfect Fourth (1.333) */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.313rem;
--text-xl: 1.75rem;
--text-2xl: 2.375rem;
--text-3xl: 3.125rem;
/* Spacing scale */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--space-3xl: 64px;
/* Radii - soft friendly */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
/* Shadows - soft layered */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.15);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.15);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.35), 0 8px 16px rgba(0, 0, 0, 0.2);
--shadow-glow: 0 0 20px rgba(245, 158, 11, 0.15);
/* Transitions */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
/* Layout */
--sidebar-expanded: 300px;
--sidebar-collapsed: 64px;
--content-max: 920px;
} }
html.light { html.light {
--bg: #faf9f6; --bg: #f7f8fa;
--surface: #ffffff; --surface: #ffffff;
--surface-hover: #f0eeea; --surface-hover: #f0f1f3;
--border: rgba(20, 20, 32, 0.08); --surface-raised: #f5f6f8;
--border-hover: rgba(20, 20, 32, 0.15); --border: rgba(0, 0, 0, 0.08);
--text: #1a1a2e; --border-hover: rgba(0, 0, 0, 0.15);
--text-secondary: rgba(26, 26, 46, 0.6); --border-accent: rgba(112, 73, 9, 0.3);
--text-tertiary: rgba(26, 26, 46, 0.35);
--accent: #D97706; --text: #1a1a1a;
--accent-hover: #B45309; --text-secondary: #4a4a4a;
--accent-subtle: rgba(217, 119, 6, 0.15); --text-tertiary: #545454;
--admin-accent: #0891B2;
--admin-subtle: rgba(8, 145, 178, 0.15); --accent: #704909;
--accent-hover: #855609;
--accent-dim: #5C3C06;
--accent-subtle: rgba(112, 73, 9, 0.1);
--accent-glow: rgba(112, 73, 9, 0.2);
--admin-accent: #0A5C73;
--admin-subtle: rgba(10, 92, 115, 0.1);
--success: #166534;
--warning: #74430C;
--error: #991919;
--info: #1A40B0;
/* Status badge colors (darker for light bg) */
--status-open: #704909;
--status-review: #0A5C73;
--status-planned: #1A40B0;
--status-progress: #74430C;
--status-done: #166534;
--status-declined: #991919;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1), 0 4px 8px rgba(0, 0, 0, 0.06);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.06);
--shadow-glow: 0 0 20px rgba(112, 73, 9, 0.1);
}
/* Admin mode - override accent with cyan */
.admin-mode {
--accent: #08C4E4;
--accent-hover: #22D3EE;
--accent-dim: #06A3BE;
--accent-subtle: rgba(8, 196, 228, 0.12);
--accent-glow: rgba(8, 196, 228, 0.25);
--border-accent: rgba(8, 196, 228, 0.3);
--shadow-glow: 0 0 20px rgba(8, 196, 228, 0.15);
}
html.light .admin-mode {
--accent: #0A5C73;
--accent-hover: #0C6D87;
--accent-dim: #084B5E;
--accent-subtle: rgba(10, 92, 115, 0.1);
--accent-glow: rgba(10, 92, 115, 0.2);
--border-accent: rgba(10, 92, 115, 0.3);
--shadow-glow: 0 0 20px rgba(10, 92, 115, 0.1);
} }
* { * {
@@ -45,42 +158,75 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
scroll-padding-top: 48px;
}
body { body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--text-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
transition: background 200ms ease-out, color 200ms ease-out; transition: background var(--duration-normal) ease-out, color var(--duration-normal) ease-out;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading); font-family: var(--font-heading);
line-height: 1.3;
}
/* Focus ring - visible on all interactive elements */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
} }
} }
@layer components { @layer components {
/* ---------- Buttons ---------- */
.btn { .btn {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem 1rem; padding: 0.75rem 1.25rem;
border-radius: 0.5rem; min-height: 44px;
border-radius: var(--radius-md);
font-family: var(--font-body); font-family: var(--font-body);
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: var(--text-sm);
transition: all 200ms ease-out; transition: all var(--duration-normal) var(--ease-out);
cursor: pointer; cursor: pointer;
border: none; border: none;
outline: none; outline: none;
overflow: hidden;
}
.btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
transform: translateX(-100%);
transition: transform var(--duration-slow) ease-out;
}
.btn:hover::before {
transform: translateX(100%);
}
.btn:active {
transform: scale(0.97);
} }
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);
color: #141420; color: #161616;
box-shadow: var(--shadow-sm);
} }
.btn-primary:hover { .btn-primary:hover {
background: var(--accent-hover); box-shadow: var(--shadow-glow), var(--shadow-md);
} }
.btn-secondary { .btn-secondary {
@@ -92,6 +238,9 @@
background: var(--surface-hover); background: var(--surface-hover);
border-color: var(--border-hover); border-color: var(--border-hover);
} }
.btn-secondary::before {
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent);
}
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
@@ -101,49 +250,296 @@
background: var(--surface-hover); background: var(--surface-hover);
color: var(--text); color: var(--text);
} }
.btn-ghost::before { display: none; }
html.light .btn-primary { color: #fff; }
html.light .btn-admin { color: #fff; }
.icon-picker-btn {
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out;
}
.icon-picker-btn:hover {
background: var(--surface-hover) !important;
color: var(--text) !important;
}
.number-input-btn {
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out;
}
.number-input-btn:not(:disabled):hover {
background: var(--surface-hover) !important;
color: var(--text) !important;
}
.number-input-btn:not(:disabled):active {
background: var(--border) !important;
}
/* Small inline action buttons (edit, delete, lock, etc.) */
.action-btn {
cursor: pointer;
border: none;
background: transparent;
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out, opacity var(--duration-fast) ease-out;
}
.action-btn:hover {
background: var(--surface-hover) !important;
opacity: 0.85;
filter: brightness(1.15);
}
.action-btn:active {
background: var(--border) !important;
}
/* Sidebar/nav links with hover feedback */
.nav-link {
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out;
}
.nav-link:hover {
background: var(--surface-hover) !important;
color: var(--text) !important;
}
.roadmap-card:hover {
border-color: var(--border-hover) !important;
box-shadow: var(--shadow-md) !important;
}
.btn-admin { .btn-admin {
background: var(--admin-accent); background: var(--admin-accent);
color: #141420; color: #161616;
box-shadow: var(--shadow-sm);
} }
.btn-admin:hover { .btn-admin:hover {
opacity: 0.9; box-shadow: var(--shadow-glow), var(--shadow-md);
} }
/* ---------- Cards ---------- */
.card { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 0.75rem; border-radius: var(--radius-lg);
transition: border-color 200ms ease-out, box-shadow 200ms ease-out; box-shadow: var(--shadow-sm);
} transition: border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out, border-left-color var(--duration-normal) ease-out;
.card:hover {
border-color: var(--border-hover);
} }
/* Interactive cards get accent line on hover */
.card-interactive {
border-left: 3px solid transparent;
}
.card-interactive:hover {
border-color: var(--border-hover);
border-left-color: var(--accent);
box-shadow: var(--shadow-md);
}
/* Static cards (forms, settings) have no hover effect */
.card-static {
cursor: default;
}
.card-static:hover {
border-color: var(--border);
box-shadow: var(--shadow-sm);
transform: none;
}
/* ---------- Inputs ---------- */
.input { .input {
width: 100%; width: 100%;
padding: 0.625rem 0.875rem; padding: 0.75rem 1rem;
background: var(--surface); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 0.5rem; border-radius: var(--radius-md);
color: var(--text); color: var(--text);
font-family: var(--font-body); font-family: var(--font-body);
font-size: 0.875rem; font-size: var(--text-sm);
transition: border-color 200ms ease-out; transition: border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out;
outline: none; outline: none;
} }
.input:focus { .input:focus {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
} }
.input::placeholder { .input::placeholder {
color: var(--text-tertiary); color: var(--text-tertiary);
} }
/* ---------- Markdown body ---------- */
.markdown-body {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.7;
word-wrap: break-word;
}
.markdown-body p { margin-bottom: 1em; }
.markdown-body p:last-child { margin-bottom: 0; }
.markdown-body strong { color: var(--text); font-weight: 600; }
.markdown-body em { font-style: italic; }
.markdown-body del { text-decoration: line-through; opacity: 0.7; }
.markdown-body a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
transition: opacity var(--duration-fast) ease-out;
}
.markdown-body a:hover { opacity: 0.8; }
.markdown-body code {
background: var(--bg);
padding: 0.15em 0.4em;
border-radius: var(--radius-sm);
font-size: 0.88em;
font-family: var(--font-mono);
}
.markdown-body pre {
background: var(--bg);
padding: 12px 16px;
border-radius: var(--radius-md);
overflow-x: auto;
margin: 0.5em 0;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: 0.85em;
}
.markdown-body ul, .markdown-body ol {
padding-left: 1.5em;
margin: 0.4em 0;
}
.markdown-body ul { list-style: disc; }
.markdown-body ol { list-style: decimal; }
.markdown-body li { margin: 0.25em 0; }
.markdown-body blockquote {
border-left: 3px solid var(--border);
padding-left: 12px;
color: var(--text-tertiary);
margin: 0.5em 0;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
overflow-wrap: break-word;
word-break: break-word;
}
.markdown-body h1 {
font-size: 1.5em;
font-weight: 700;
color: var(--text);
margin: 0.8em 0 0.4em;
line-height: 1.3;
}
.markdown-body h2 {
font-size: 1.25em;
font-weight: 600;
color: var(--text);
margin: 0.7em 0 0.3em;
line-height: 1.35;
}
.markdown-body h3 {
font-size: 1.1em;
font-weight: 600;
color: var(--text);
margin: 0.6em 0 0.25em;
line-height: 1.4;
}
.markdown-body h1:first-child,
.markdown-body h2:first-child,
.markdown-body h3:first-child { margin-top: 0; }
.markdown-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 1em 0;
}
.markdown-body img {
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: var(--radius-md);
margin: 0.5em 0;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 0.5em 0;
font-size: 0.9em;
}
.markdown-body th, .markdown-body td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
}
.markdown-body th {
background: var(--bg);
font-weight: 600;
color: var(--text);
}
.markdown-body input[type="checkbox"] {
margin-right: 6px;
accent-color: var(--accent);
pointer-events: none;
}
.markdown-body li:has(> input[type="checkbox"]) {
list-style: none;
margin-left: -1.5em;
}
/* ---------- Skeleton loading ---------- */
.skeleton {
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-hover) 50%, var(--surface) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
.skeleton-text {
height: 14px;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-title {
height: 22px;
margin-bottom: 12px;
width: 60%;
border-radius: 4px;
}
.skeleton-card {
height: 100px;
border-radius: var(--radius-lg);
}
/* ---------- Progress bar ---------- */
.progress-bar {
position: relative;
height: 2px;
background: var(--border);
overflow: hidden;
border-radius: 1px;
}
.progress-bar::after {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 30%;
background: var(--accent);
border-radius: 1px;
animation: progressSlide 1.2s ease-in-out infinite;
}
/* ---------- Animations ---------- */
.slide-up { .slide-up {
animation: slideUp 300ms cubic-bezier(0.16, 1, 0.3, 1); animation: slideUp 300ms var(--ease-out);
} }
.fade-in { .fade-in {
animation: fadeIn 200ms ease-out; animation: fadeIn var(--duration-normal) ease-out;
}
.slide-down {
animation: slideDown var(--duration-normal) ease-out;
}
/* Staggered entrance - use with style="--stagger: N" */
.stagger-in {
animation: staggerFadeIn var(--duration-slow) var(--ease-out) both;
animation-delay: calc(var(--stagger, 0) * 50ms);
} }
@keyframes slideUp { @keyframes slideUp {
@@ -158,4 +554,91 @@
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@keyframes checkDraw {
from { stroke-dashoffset: 30; }
to { stroke-dashoffset: 0; }
}
@keyframes paletteIn {
from { transform: scale(0.97); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes staggerFadeIn {
from { transform: translateY(12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes progressSlide {
0% { left: -30%; }
100% { left: 100%; }
}
@keyframes voteBounce {
0% { transform: translateY(0); }
30% { transform: translateY(-4px); }
50% { transform: translateY(0); }
70% { transform: translateY(-2px); }
100% { transform: translateY(0); }
}
@keyframes countTick {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
@keyframes successCheck {
from { stroke-dashoffset: 30; }
to { stroke-dashoffset: 0; }
}
.vote-bounce {
animation: voteBounce 400ms var(--ease-spring);
}
.count-tick {
animation: countTick 300ms var(--ease-spring);
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.sr-only:focus {
position: fixed;
top: 8px;
left: 8px;
width: auto;
height: auto;
padding: 12px 24px;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
z-index: 9999;
background: var(--surface);
color: var(--text);
border: 2px solid var(--accent);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
box-shadow: var(--shadow-lg);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
} }

View File

@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { api } from '../lib/api'
import { useAdmin } from '../hooks/useAdmin'
import { IconHome, IconFileText, IconLayoutGrid, IconTag, IconTags, IconTrash, IconPlug, IconArrowLeft, IconNews, IconWebhook, IconCode, IconPalette, IconDownload, IconTemplate, IconSettings, IconUsers } from '@tabler/icons-react'
import type { Icon } from '@tabler/icons-react'
interface PluginInfo {
name: string
version: string
adminRoutes?: { path: string; label: string }[]
}
const roleLevel: Record<string, number> = { SUPER_ADMIN: 3, ADMIN: 2, MODERATOR: 1 }
const links: { to: string; label: string; icon: Icon; minLevel: number }[] = [
{ to: '/admin', label: 'Dashboard', icon: IconHome, minLevel: 1 },
{ to: '/admin/posts', label: 'All Posts', icon: IconFileText, minLevel: 1 },
{ to: '/admin/boards', label: 'Boards', icon: IconLayoutGrid, minLevel: 2 },
{ to: '/admin/categories', label: 'Categories', icon: IconTag, minLevel: 1 },
{ to: '/admin/tags', label: 'Tags', icon: IconTags, minLevel: 1 },
{ to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 },
{ to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 },
{ to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 },
{ to: '/admin/statuses', label: 'Custom Statuses', icon: IconPalette, minLevel: 2 },
{ to: '/admin/templates', label: 'Templates', icon: IconTemplate, minLevel: 2 },
{ to: '/admin/embed', label: 'Embed Widget', icon: IconCode, minLevel: 2 },
{ to: '/admin/export', label: 'Export Data', icon: IconDownload, minLevel: 2 },
{ to: '/admin/data-retention', label: 'Data Retention', icon: IconTrash, minLevel: 1 },
{ to: '/admin/settings', label: 'Branding', icon: IconSettings, minLevel: 3 },
]
export default function AdminSidebar() {
const location = useLocation()
const admin = useAdmin()
const [plugins, setPlugins] = useState<PluginInfo[]>([])
useEffect(() => {
api.get<PluginInfo[]>('/plugins/active').then(setPlugins).catch(() => {})
}, [])
const pluginLinks = plugins.flatMap((p) =>
(p.adminRoutes ?? []).map((r) => ({
to: `/admin/plugins${r.path}`,
label: r.label,
}))
)
const currentLevel = roleLevel[admin.role as string] ?? 0
const visibleLinks = links.filter(l => currentLevel >= l.minLevel)
const isActive = (path: string) =>
path === '/admin' ? location.pathname === '/admin' : location.pathname.startsWith(path)
return (
<aside
aria-label="Admin navigation"
className="flex flex-col border-r h-screen sticky top-0"
style={{
width: 240,
background: 'var(--surface)',
borderColor: 'var(--border)',
}}
>
<div
className="border-b"
style={{ borderColor: 'var(--border)', padding: '14px 20px' }}
>
<Link
to="/admin"
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-lg)' }}
>
Admin
</Link>
</div>
<nav className="flex-1 overflow-y-auto" style={{ padding: '8px 10px' }}>
{visibleLinks.map((link) => {
const Icon = link.icon
return (
<Link
key={link.to}
to={link.to}
className="flex items-center gap-3 nav-link"
aria-current={isActive(link.to) ? 'page' : undefined}
style={{
padding: '8px 12px',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-sm)',
marginBottom: 2,
background: isActive(link.to) ? 'var(--admin-subtle)' : 'transparent',
color: isActive(link.to) ? 'var(--admin-accent)' : 'var(--text-secondary)',
fontWeight: isActive(link.to) ? 600 : 400,
transition: 'all var(--duration-fast) ease-out',
}}
>
<Icon size={18} stroke={2} aria-hidden="true" />
{link.label}
</Link>
)
})}
{pluginLinks.length > 0 && (
<>
<div style={{ padding: '16px 12px 6px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em' }}>
Plugins
</div>
{pluginLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="flex items-center gap-3 nav-link"
aria-current={isActive(link.to) ? 'page' : undefined}
style={{
padding: '8px 12px',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-sm)',
marginBottom: 2,
background: isActive(link.to) ? 'var(--admin-subtle)' : 'transparent',
color: isActive(link.to) ? 'var(--admin-accent)' : 'var(--text-secondary)',
transition: 'all var(--duration-fast) ease-out',
}}
>
<IconPlug size={18} stroke={2} aria-hidden="true" />
{link.label}
</Link>
))}
</>
)}
</nav>
<div className="border-t" style={{ borderColor: 'var(--border)', padding: '12px 16px' }}>
<Link
to="/"
className="flex items-center gap-2 nav-link"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', padding: '6px 4px', borderRadius: 'var(--radius-sm)' }}
>
<IconArrowLeft size={14} stroke={2} />
Back to public site
</Link>
</div>
</aside>
)
}

View File

@@ -0,0 +1,72 @@
import { useMemo } from 'react'
function hashCode(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
}
return Math.abs(hash)
}
interface AvatarProps {
userId: string
name?: string | null
avatarUrl?: string | null
size?: number
}
export default function Avatar({ userId, name, avatarUrl, size = 28 }: AvatarProps) {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt={name ? `${name}'s avatar` : 'User avatar'}
style={{
width: size,
height: size,
borderRadius: 'var(--radius-full, 50%)',
objectFit: 'cover',
flexShrink: 0,
}}
loading="lazy"
/>
)
}
return <GeneratedAvatar id={userId} size={size} />
}
function GeneratedAvatar({ id, size }: { id: string; size: number }) {
const { hue, shapes } = useMemo(() => {
let hash = 0
for (let i = 0; i < id.length; i++) hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0
const h = Math.abs(hash % 360)
const s: { x: number; y: number; r: number; op: number }[] = []
for (let i = 0; i < 4; i++) {
const seed = Math.abs((hash >> (i * 4)) % 256)
s.push({
x: 4 + (seed % 24),
y: 4 + ((seed * 7) % 24),
r: 3 + (seed % 6),
op: 0.3 + (seed % 5) * 0.15,
})
}
return { hue: h, shapes: s }
}, [id])
return (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
aria-hidden="true"
style={{ borderRadius: 'var(--radius-full, 50%)', flexShrink: 0 }}
>
<rect width="32" height="32" fill={`hsl(${hue}, 55%, 45%)`} />
{shapes.map((s, i) => (
<circle key={i} cx={s.x} cy={s.y} r={s.r} fill={`hsla(${(hue + 60) % 360}, 60%, 70%, ${s.op})`} />
))}
<rect x="8" y="8" width="16" height="16" rx="3" fill={`hsla(${hue}, 40%, 90%, 0.15)`} />
</svg>
)
}

View File

@@ -0,0 +1,50 @@
import { renderIcon } from './IconPicker'
export default function BoardIcon({
name,
iconName,
iconColor,
size = 32,
}: {
name: string
iconName?: string | null
iconColor?: string | null
size?: number
}) {
const color = iconColor || 'var(--accent)'
const fontSize = size * 0.45
if (iconName) {
return (
<div
className="flex items-center justify-center shrink-0"
style={{
width: size,
height: size,
borderRadius: 'var(--radius-sm)',
background: iconColor ? `${iconColor}18` : 'var(--accent-subtle)',
color,
}}
>
{renderIcon(iconName, size * 0.55, color)}
</div>
)
}
return (
<div
className="flex items-center justify-center shrink-0 font-bold"
style={{
width: size,
height: size,
borderRadius: 'var(--radius-sm)',
background: 'var(--accent-subtle)',
color: 'var(--accent)',
fontFamily: 'var(--font-heading)',
fontSize,
}}
>
{name.charAt(0).toUpperCase()}
</div>
)
}

View File

@@ -1,29 +1,60 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useFocusTrap } from '../hooks/useFocusTrap'
import { IconSearch, IconBug, IconBulb } from '@tabler/icons-react'
import BoardIcon from './BoardIcon'
import StatusBadge from './StatusBadge'
import Avatar from './Avatar'
interface SearchResult { interface BoardResult {
type: 'post' | 'board' type: 'board'
id: string id: string
title: string title: string
slug?: string slug: string
boardSlug?: string iconName: string | null
iconColor: string | null
description: string | null
postCount: number
} }
interface PostResult {
type: 'post'
id: string
title: string
postType: 'FEATURE_REQUEST' | 'BUG_REPORT'
status: string
voteCount: number
commentCount: number
boardSlug: string
boardName: string
boardIconName: string | null
boardIconColor: string | null
author: { id: string; displayName: string | null; avatarUrl: string | null } | null
createdAt: string
}
type FlatResult = (BoardResult | PostResult)
export default function CommandPalette() { export default function CommandPalette() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([]) const [boards, setBoards] = useState<BoardResult[]>([])
const [posts, setPosts] = useState<PostResult[]>([])
const [selected, setSelected] = useState(0) const [selected, setSelected] = useState(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const trapRef = useFocusTrap(open)
const nav = useNavigate() const nav = useNavigate()
const allResults: FlatResult[] = [...boards, ...posts]
const toggle = useCallback(() => { const toggle = useCallback(() => {
setOpen((v) => { setOpen((v) => {
if (!v) { if (!v) {
setQuery('') setQuery('')
setResults([]) setBoards([])
setPosts([])
setSelected(0) setSelected(0)
} }
return !v return !v
@@ -52,17 +83,20 @@ export default function CommandPalette() {
useEffect(() => { useEffect(() => {
if (!query.trim()) { if (!query.trim()) {
setResults([]) setBoards([])
setPosts([])
return return
} }
const t = setTimeout(async () => { const t = setTimeout(async () => {
setLoading(true) setLoading(true)
try { try {
const res = await api.get<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`) const res = await api.get<{ boards: BoardResult[]; posts: PostResult[] }>(`/search?q=${encodeURIComponent(query)}`)
setResults(res) setBoards(res.boards)
setPosts(res.posts)
setSelected(0) setSelected(0)
} catch { } catch {
setResults([]) setBoards([])
setPosts([])
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -70,7 +104,7 @@ export default function CommandPalette() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [query]) }, [query])
const navigate = (r: SearchResult) => { const go = (r: FlatResult) => {
if (r.type === 'board') { if (r.type === 'board') {
nav(`/b/${r.slug}`) nav(`/b/${r.slug}`)
} else { } else {
@@ -82,19 +116,17 @@ export default function CommandPalette() {
const onKeyDown = (e: React.KeyboardEvent) => { const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
setSelected((s) => Math.min(s + 1, results.length - 1)) setSelected((s) => Math.min(s + 1, allResults.length - 1))
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
setSelected((s) => Math.max(s - 1, 0)) setSelected((s) => Math.max(s - 1, 0))
} else if (e.key === 'Enter' && results[selected]) { } else if (e.key === 'Enter' && allResults[selected]) {
navigate(results[selected]) go(allResults[selected])
} }
} }
if (!open) return null if (!open) return null
const boards = results.filter((r) => r.type === 'board')
const posts = results.filter((r) => r.type === 'post')
let idx = -1 let idx = -1
return ( return (
@@ -102,34 +134,49 @@ export default function CommandPalette() {
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]" className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
{/* Backdrop */}
<div <div
className="absolute inset-0 fade-in" className="absolute inset-0 fade-in"
style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }} style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }}
/> />
{/* Modal */}
<div <div
className="relative w-full max-w-lg mx-4 rounded-xl overflow-hidden shadow-2xl slide-up" ref={trapRef}
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }} role="dialog"
aria-modal="true"
aria-labelledby="command-palette-title"
className="relative w-full max-w-lg mx-4 overflow-hidden fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-xl)',
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center gap-3 px-4 py-3 border-b" style={{ borderColor: 'var(--border)' }}> <h2 id="command-palette-title" className="sr-only">Search</h2>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}> <div
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> className="flex items-center gap-3 border-b"
</svg> style={{ borderColor: 'var(--border)', padding: '12px 16px' }}
>
<IconSearch size={18} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<input <input
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Search posts and boards..." placeholder="Search posts, feedback, and boards..."
className="flex-1 bg-transparent outline-none text-sm" className="flex-1 bg-transparent outline-none"
style={{ color: 'var(--text)', fontFamily: 'var(--font-body)' }} style={{ color: 'var(--text)', fontFamily: 'var(--font-body)', fontSize: 'var(--text-sm)' }}
aria-label="Search"
/> />
<kbd <kbd
className="text-[10px] px-1.5 py-0.5 rounded" className="px-1.5 py-0.5"
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }} style={{
background: 'var(--surface-hover)',
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
> >
ESC ESC
</kbd> </kbd>
@@ -137,46 +184,52 @@ export default function CommandPalette() {
<div className="max-h-80 overflow-y-auto"> <div className="max-h-80 overflow-y-auto">
{loading && ( {loading && (
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}> <div className="text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', padding: '32px 16px' }}>
Searching... Searching...
</div> </div>
)} )}
{!loading && query && results.length === 0 && ( {!loading && query && allResults.length === 0 && (
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}> <div className="text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', padding: '32px 16px' }}>
No results for "{query}" No results for "{query}"
</div> </div>
)} )}
{!loading && !query && ( {!loading && !query && (
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}> <div className="text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', padding: '32px 16px' }}>
Start typing to search... Start typing to search...
</div> </div>
)} )}
{boards.length > 0 && ( {boards.length > 0 && (
<div> <div>
<div className="px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}> <div style={{ padding: '8px 16px 4px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em', textTransform: 'uppercase' }}>
Boards Boards
</div> </div>
{boards.map((r) => { {boards.map((b) => {
idx++ idx++
const i = idx const i = idx
return ( return (
<button <button
key={r.id} key={b.id}
onClick={() => navigate(r)} onClick={() => go(b)}
className="w-full text-left px-4 py-2.5 flex items-center gap-3 text-sm" className="w-full text-left flex items-center gap-3 action-btn"
style={{ style={{
padding: '10px 16px',
background: selected === i ? 'var(--surface-hover)' : 'transparent', background: selected === i ? 'var(--surface-hover)' : 'transparent',
color: 'var(--text)', color: 'var(--text)',
transition: 'background 100ms ease-out', fontSize: 'var(--text-sm)',
transition: 'background var(--duration-fast) ease-out',
}} }}
aria-label={"Go to board: " + b.title}
> >
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--accent)', flexShrink: 0 }}> <BoardIcon name={b.title} iconName={b.iconName} iconColor={b.iconColor} size={24} />
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> <span className="flex-1 truncate">{b.title}</span>
</svg> {b.description && (
{r.title} <span className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', maxWidth: 180 }}>
{b.description}
</span>
)}
</button> </button>
) )
})} })}
@@ -185,27 +238,50 @@ export default function CommandPalette() {
{posts.length > 0 && ( {posts.length > 0 && (
<div> <div>
<div className="px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}> <div style={{ padding: '8px 16px 4px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em', textTransform: 'uppercase' }}>
Posts Posts
</div> </div>
{posts.map((r) => { {posts.map((p) => {
idx++ idx++
const i = idx const i = idx
return ( return (
<button <button
key={r.id} key={p.id}
onClick={() => navigate(r)} onClick={() => go(p)}
className="w-full text-left px-4 py-2.5 flex items-center gap-3 text-sm" className="w-full text-left flex items-center gap-3 action-btn"
style={{ style={{
padding: '10px 16px',
background: selected === i ? 'var(--surface-hover)' : 'transparent', background: selected === i ? 'var(--surface-hover)' : 'transparent',
color: 'var(--text)', color: 'var(--text)',
transition: 'background 100ms ease-out', fontSize: 'var(--text-sm)',
transition: 'background var(--duration-fast) ease-out',
}} }}
aria-label={"Go to post: " + p.title}
> >
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}> <span
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> className="flex items-center justify-center shrink-0"
</svg> style={{
{r.title} width: 24, height: 24,
borderRadius: 'var(--radius-sm)',
background: p.postType === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
color: p.postType === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
}}
>
{p.postType === 'BUG_REPORT'
? <IconBug size={13} stroke={2} />
: <IconBulb size={13} stroke={2} />
}
</span>
<div className="flex-1 min-w-0">
<span className="truncate block">{p.title}</span>
<span className="flex items-center gap-2" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', marginTop: 1 }}>
<Avatar userId={p.author?.id ?? '0000'} avatarUrl={p.author?.avatarUrl} size={12} />
{p.author?.displayName ?? `Anonymous #${(p.author?.id ?? '0000').slice(-4)}`}
<span style={{ opacity: 0.5 }}>in</span>
{p.boardName}
</span>
</div>
<StatusBadge status={p.status} />
</button> </button>
) )
})} })}
@@ -214,12 +290,12 @@ export default function CommandPalette() {
</div> </div>
<div <div
className="px-4 py-2 flex items-center gap-4 text-[10px] border-t" className="flex items-center gap-4 border-t"
style={{ borderColor: 'var(--border)', color: 'var(--text-tertiary)' }} style={{ borderColor: 'var(--border)', padding: '8px 16px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
> >
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}></kbd> navigate</span> <span><kbd className="px-1 py-0.5" style={{ background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}>&#x2191;&#x2193;</kbd> navigate</span>
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}>Enter</kbd> open</span> <span><kbd className="px-1 py-0.5" style={{ background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}>Enter</kbd> open</span>
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}>Esc</kbd> close</span> <span><kbd className="px-1 py-0.5" style={{ background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}>Esc</kbd> close</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,253 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { IconChevronDown, IconCheck, IconSearch } from '@tabler/icons-react'
interface Option {
value: string
label: string
}
function fuzzyMatch(text: string, query: string): boolean {
const lower = text.toLowerCase()
const q = query.toLowerCase()
let qi = 0
for (let i = 0; i < lower.length && qi < q.length; i++) {
if (lower[i] === q[qi]) qi++
}
return qi === q.length
}
export default function Dropdown({
value,
options,
onChange,
placeholder = 'Select...',
searchable = false,
'aria-label': ariaLabel,
}: {
value: string
options: Option[]
onChange: (value: string) => void
placeholder?: string
searchable?: boolean
'aria-label'?: string
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(-1)
const ref = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const selected = options.find((o) => o.value === value)
const filtered = searchable && query
? options.filter((o) => fuzzyMatch(o.label, query))
: options
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (open && searchable) {
setQuery('')
setTimeout(() => searchRef.current?.focus(), 0)
}
if (open) {
const idx = filtered.findIndex((o) => o.value === value)
setActiveIndex(idx >= 0 ? idx : 0)
}
}, [open])
useEffect(() => {
if (!open || activeIndex < 0) return
const list = listRef.current
if (!list) return
const item = list.children[activeIndex] as HTMLElement | undefined
item?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, open])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setOpen(true)
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex((i) => Math.max(i - 1, 0))
break
case 'Enter':
case ' ':
if (!searchable || e.key === 'Enter') {
e.preventDefault()
if (activeIndex >= 0 && filtered[activeIndex]) {
onChange(filtered[activeIndex].value)
setOpen(false)
triggerRef.current?.focus()
}
}
break
case 'Escape':
e.preventDefault()
setOpen(false)
triggerRef.current?.focus()
break
case 'Home':
e.preventDefault()
setActiveIndex(0)
break
case 'End':
e.preventDefault()
setActiveIndex(filtered.length - 1)
break
}
}, [open, filtered, activeIndex, onChange, searchable])
const listboxId = 'dropdown-listbox'
const activeId = activeIndex >= 0 ? `dropdown-opt-${activeIndex}` : undefined
return (
<div ref={ref} className="relative" onKeyDown={handleKeyDown}>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between"
role="combobox"
aria-label={ariaLabel}
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={open ? listboxId : undefined}
aria-activedescendant={open ? activeId : undefined}
style={{
padding: '0.75rem 1rem',
background: 'var(--bg)',
border: open ? '1px solid var(--accent)' : '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: selected ? 'var(--text)' : 'var(--text-tertiary)',
fontSize: 'var(--text-sm)',
fontFamily: 'var(--font-body)',
transition: 'border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out',
boxShadow: open ? '0 0 0 3px var(--accent-subtle)' : 'none',
cursor: 'pointer',
textAlign: 'left',
}}
>
<span className="truncate">{selected?.label || placeholder}</span>
<IconChevronDown
size={14}
stroke={2}
aria-hidden="true"
style={{
color: 'var(--text-tertiary)',
transition: 'transform var(--duration-fast) ease-out',
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
flexShrink: 0,
}}
/>
</button>
{open && (
<div
className="absolute left-0 right-0 z-50 mt-1 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-lg)',
maxHeight: 300,
display: 'flex',
flexDirection: 'column',
}}
>
{searchable && (
<div
style={{
padding: '8px 8px 4px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
}}
>
<div className="flex items-center gap-2" style={{
padding: '6px 10px',
background: 'var(--bg)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
}}>
<IconSearch size={13} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} aria-hidden="true" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setActiveIndex(0) }}
placeholder="Search..."
aria-label="Search options"
style={{
background: 'transparent',
border: 'none',
outline: 'none',
color: 'var(--text)',
fontSize: 'var(--text-sm)',
width: '100%',
fontFamily: 'var(--font-body)',
}}
/>
</div>
</div>
)}
<div ref={listRef} role="listbox" id={listboxId} style={{ overflowY: 'auto', padding: '4px 0' }}>
{filtered.length === 0 && (
<div
className="px-3 py-2 text-center"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}
>
No matches
</div>
)}
{filtered.map((opt, i) => (
<button
key={opt.value}
id={`dropdown-opt-${i}`}
type="button"
role="option"
aria-selected={opt.value === value}
onClick={() => { onChange(opt.value); setOpen(false) }}
className="w-full flex items-center justify-between px-3 text-left"
style={{
fontSize: 'var(--text-sm)',
minHeight: 44,
color: opt.value === value ? 'var(--accent)' : 'var(--text)',
background: i === activeIndex
? 'var(--surface-hover)'
: opt.value === value
? 'var(--accent-subtle)'
: 'transparent',
transition: 'background var(--duration-fast) ease-out',
}}
onMouseEnter={() => setActiveIndex(i)}
onFocus={() => setActiveIndex(i)}
>
<span className="truncate">{opt.label}</span>
{opt.value === value && <IconCheck size={14} stroke={2.5} style={{ flexShrink: 0 }} aria-hidden="true" />}
</button>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,157 @@
import { IconX, IconHistory } from '@tabler/icons-react'
import { useFocusTrap } from '../hooks/useFocusTrap'
import Markdown from './Markdown'
interface EditEntry {
id: string
previousTitle?: string | null
previousDescription?: Record<string, string> | null
previousBody?: string | null
editedBy?: { id: string; displayName: string | null } | null
createdAt: string
}
interface Props {
open: boolean
onClose: () => void
entries: EditEntry[]
type: 'post' | 'comment'
isAdmin?: boolean
onRollback?: (editHistoryId: string) => void
}
export default function EditHistoryModal({ open, onClose, entries, type, isAdmin, onRollback }: Props) {
const trapRef = useFocusTrap(open)
if (!open) return null
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={onClose}
>
<div
className="absolute inset-0 fade-in"
style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }}
/>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="edit-history-modal-title"
className="relative w-full mx-4 fade-in"
style={{
maxWidth: 560,
maxHeight: '80vh',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
display: 'flex',
flexDirection: 'column',
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
>
{/* Header */}
<div
className="flex items-center justify-between px-6 py-4"
style={{ borderBottom: '1px solid var(--border)' }}
>
<h2 id="edit-history-modal-title" className="font-semibold" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
Edit history
</h2>
<button
onClick={onClose}
className="flex items-center justify-center"
style={{ width: 44, height: 44, color: 'var(--text-tertiary)', background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}
aria-label="Close"
>
<IconX size={14} stroke={2} />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 overflow-y-auto" style={{ flex: 1 }}>
{entries.length === 0 ? (
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--text-tertiary)' }}>No edit history found.</p>
) : (
<div className="flex flex-col gap-4">
{entries.map((edit) => (
<div
key={edit.id}
className="p-4"
style={{
background: 'var(--bg)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
}}
>
<div className="flex items-center justify-between mb-3">
<time dateTime={edit.createdAt} style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
{new Date(edit.createdAt).toLocaleString()}
{edit.editedBy?.displayName && (
<> - {edit.editedBy.displayName}</>
)}
</time>
{isAdmin && onRollback && (
<button
onClick={() => onRollback(edit.id)}
className="inline-flex items-center gap-1 px-2.5 py-1"
style={{
fontSize: 'var(--text-xs)',
color: 'var(--accent)',
background: 'var(--accent-subtle)',
borderRadius: 'var(--radius-sm)',
transition: 'all var(--duration-fast) ease-out',
}}
>
<IconHistory size={11} stroke={2} aria-hidden="true" /> Restore
</button>
)}
</div>
{type === 'post' && (
<>
{edit.previousTitle && (
<div
className="font-semibold mb-2"
style={{ fontSize: 'var(--text-sm)', color: 'var(--text)' }}
>
{edit.previousTitle}
</div>
)}
{edit.previousDescription && (
<div className="flex flex-col gap-2">
{Object.entries(edit.previousDescription).map(([k, v]) => (
<div key={k}>
<span
className="font-medium"
style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', letterSpacing: '0.04em' }}
>
{k}
</span>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)', marginTop: 2 }}>
<Markdown>{typeof v === 'string' ? v : ''}</Markdown>
</div>
</div>
))}
</div>
)}
</>
)}
{type === 'comment' && edit.previousBody && (
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
<Markdown>{edit.previousBody}</Markdown>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,68 +1,54 @@
import { IconSpeakerphone, IconSearch, IconActivity, IconFileText } from '@tabler/icons-react'
import type { Icon } from '@tabler/icons-react'
interface Props { interface Props {
title?: string title?: string
message?: string message?: string
actionLabel?: string actionLabel?: string
onAction?: () => void onAction?: () => void
icon?: Icon
} }
export default function EmptyState({ export default function EmptyState({
title = 'Nothing here yet', title = 'Nothing here yet',
message = 'Be the first to share feedback', message = 'Be the first to share your thoughts and ideas',
actionLabel = 'Create a post', actionLabel = 'Share feedback',
onAction, onAction,
icon: CustomIcon,
}: Props) { }: Props) {
return ( const Icon = CustomIcon || IconSpeakerphone
<div className="flex flex-col items-center justify-center py-16 px-4 fade-in">
{/* Megaphone SVG */}
<svg
width="120"
height="120"
viewBox="0 0 120 120"
fill="none"
className="mb-6"
>
<circle cx="60" cy="60" r="55" stroke="var(--text-tertiary)" strokeWidth="1" strokeDasharray="4 4" />
<path
d="M75 35L45 50H35a5 5 0 00-5 5v10a5 5 0 005 5h10l30 15V35z"
stroke="var(--accent)"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M85 48a10 10 0 010 24"
stroke="var(--accent)"
strokeWidth="2.5"
strokeLinecap="round"
fill="none"
opacity="0.6"
/>
<path
d="M92 40a20 20 0 010 40"
stroke="var(--accent)"
strokeWidth="2"
strokeLinecap="round"
fill="none"
opacity="0.3"
/>
<path
d="M42 70v10a5 5 0 005 5h5a5 5 0 005-5v-7"
stroke="var(--text-tertiary)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
<h3 return (
className="text-lg font-semibold mb-2" <div className="flex flex-col items-center justify-center py-20 px-4 fade-in">
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }} <div
className="w-24 h-24 rounded-full flex items-center justify-center mb-8"
style={{
background: 'var(--accent-subtle)',
boxShadow: 'var(--shadow-glow)',
}}
>
<Icon size={44} stroke={1.5} style={{ color: 'var(--accent)' }} />
</div>
<h2
className="font-bold mb-3"
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--text)',
fontSize: 'var(--text-xl)',
}}
> >
{title} {title}
</h3> </h2>
<p className="text-sm mb-6" style={{ color: 'var(--text-tertiary)' }}> <p
className="mb-8 text-center"
style={{
color: 'var(--text-secondary)',
fontSize: 'var(--text-base)',
maxWidth: 360,
lineHeight: 1.6,
}}
>
{message} {message}
</p> </p>
{onAction && ( {onAction && (

View File

@@ -0,0 +1,196 @@
import { useState, useRef, useCallback } from 'react'
import { IconUpload, IconX, IconLoader2 } from '@tabler/icons-react'
interface Attachment {
id: string
filename: string
mimeType: string
size: number
}
interface Props {
attachmentIds: string[]
onChange: (ids: string[]) => void
}
const MAX_SIZE = 5 * 1024 * 1024
const ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'
export default function FileUpload({ attachmentIds, onChange }: Props) {
const [previews, setPreviews] = useState<Map<string, string>>(new Map())
const [filenames, setFilenames] = useState<Map<string, string>>(new Map())
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [dragOver, setDragOver] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const upload = useCallback(async (file: File) => {
if (file.size > MAX_SIZE) {
setError('File too large (max 5MB)')
return null
}
if (!file.type.match(/^image\/(jpeg|png|gif|webp)$/)) {
setError('Only jpg, png, gif, webp images are allowed')
return null
}
const form = new FormData()
form.append('file', file)
const res = await fetch('/api/v1/attachments', {
method: 'POST',
credentials: 'include',
body: form,
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as any).error || 'Upload failed')
}
return (await res.json()) as Attachment
}, [])
const handleFiles = useCallback(async (files: FileList | File[]) => {
setError('')
const toUpload = Array.from(files).slice(0, 10 - attachmentIds.length)
if (!toUpload.length) return
setUploading(true)
const newIds: string[] = []
const newPreviews = new Map(previews)
const newFilenames = new Map(filenames)
for (const file of toUpload) {
try {
const att = await upload(file)
if (att) {
newIds.push(att.id)
newPreviews.set(att.id, URL.createObjectURL(file))
newFilenames.set(att.id, att.filename)
}
} catch (err: any) {
setError(err.message || 'Upload failed')
}
}
setPreviews(newPreviews)
setFilenames(newFilenames)
onChange([...attachmentIds, ...newIds])
setUploading(false)
}, [attachmentIds, previews, onChange, upload])
const remove = useCallback(async (id: string) => {
await fetch(`/api/v1/attachments/${id}`, {
method: 'DELETE',
credentials: 'include',
}).catch(() => {})
const url = previews.get(id)
if (url) URL.revokeObjectURL(url)
const next = new Map(previews)
next.delete(id)
setPreviews(next)
const nextNames = new Map(filenames)
nextNames.delete(id)
setFilenames(nextNames)
onChange(attachmentIds.filter((a) => a !== id))
}, [attachmentIds, previews, filenames, onChange])
const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files)
}, [handleFiles])
return (
<div>
<div
role="button"
tabIndex={0}
aria-label="Upload files by clicking or dragging"
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); inputRef.current?.click() } }}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
style={{
border: `2px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
padding: '16px',
textAlign: 'center',
cursor: 'pointer',
background: dragOver ? 'var(--accent-subtle)' : 'transparent',
transition: 'all var(--duration-fast) ease-out',
}}
>
<input
ref={inputRef}
type="file"
accept={ACCEPT}
multiple
style={{ display: 'none' }}
onChange={(e) => { if (e.target.files) handleFiles(e.target.files); e.target.value = '' }}
/>
<div className="flex items-center justify-center gap-2" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{uploading ? (
<IconLoader2 size={16} stroke={2} className="animate-spin" />
) : (
<IconUpload size={16} stroke={2} />
)}
<span>{uploading ? 'Uploading...' : 'Drop images or click to upload'}</span>
</div>
</div>
{error && (
<div role="alert" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)', marginTop: 4 }}>{error}</div>
)}
{attachmentIds.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{attachmentIds.map((id) => (
<div
key={id}
className="relative group"
style={{
width: 72,
height: 72,
borderRadius: 'var(--radius-sm)',
overflow: 'hidden',
border: '1px solid var(--border)',
}}
>
<img
src={previews.get(id) || `/api/v1/attachments/${id}`}
alt={filenames.get(id) || 'Uploaded image'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<button
onClick={(e) => { e.stopPropagation(); remove(id) }}
className="absolute top-0.5 right-0.5 opacity-0 group-hover:opacity-100 focus:opacity-100"
style={{
background: 'rgba(0,0,0,0.6)',
color: '#fff',
borderRadius: '50%',
width: 44,
height: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'opacity var(--duration-fast) ease-out',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
aria-label="Remove attachment"
>
<IconX size={12} stroke={2.5} />
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,283 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import * as tablerIcons from '@tabler/icons-react'
import { IconSearch, IconX } from '@tabler/icons-react'
const ALL_ICON_NAMES: string[] = Object.keys(tablerIcons).filter(
(k) => k.startsWith('Icon') && !k.includes('Filled') && k !== 'Icon'
)
const PRESET_COLORS = [
'#F59E0B', '#EF4444', '#22C55E', '#3B82F6', '#8B5CF6',
'#EC4899', '#06B6D4', '#F97316', '#14B8A6', '#6366F1',
'#A855F7', '#64748B', '#E11D48', '#0EA5E9', '#84CC16',
'#D946EF', '#FB923C', '#2DD4BF', '#818CF8', '#F43F5E',
'#34D399',
]
const COLOR_NAMES: Record<string, string> = {
'#F59E0B': 'Amber', '#EF4444': 'Red', '#22C55E': 'Green', '#3B82F6': 'Blue', '#8B5CF6': 'Violet',
'#EC4899': 'Pink', '#06B6D4': 'Cyan', '#F97316': 'Orange', '#14B8A6': 'Teal', '#6366F1': 'Indigo',
'#A855F7': 'Purple', '#64748B': 'Slate', '#E11D48': 'Rose', '#0EA5E9': 'Sky', '#84CC16': 'Lime',
'#D946EF': 'Fuchsia', '#FB923C': 'Light orange', '#2DD4BF': 'Mint', '#818CF8': 'Periwinkle', '#F43F5E': 'Coral',
'#34D399': 'Emerald',
}
// common/popular icons shown first before search
const POPULAR = [
'IconHome', 'IconStar', 'IconHeart', 'IconMessage', 'IconBell',
'IconSettings', 'IconUser', 'IconFolder', 'IconTag', 'IconFlag',
'IconBulb', 'IconRocket', 'IconCode', 'IconBug', 'IconShield',
'IconLock', 'IconEye', 'IconMusic', 'IconPhoto', 'IconBook',
'IconCalendar', 'IconClock', 'IconMap', 'IconGlobe', 'IconCloud',
'IconBolt', 'IconFlame', 'IconDiamond', 'IconCrown', 'IconTrophy',
'IconPuzzle', 'IconBrush', 'IconPalette', 'IconWand', 'IconSparkles',
'IconTarget', 'IconCompass', 'IconAnchor', 'IconFeather', 'IconLeaf',
'IconDroplet', 'IconSun', 'IconMoon', 'IconZap', 'IconActivity',
'IconChartBar', 'IconDatabase', 'IconServer', 'IconCpu', 'IconWifi',
'IconBriefcase', 'IconGift', 'IconTruck', 'IconShoppingCart', 'IconCreditCard',
'IconMicrophone', 'IconHeadphones', 'IconCamera', 'IconVideo', 'IconGamepad',
]
function renderIcon(name: string, size: number, color?: string) {
const Comp = (tablerIcons as Record<string, any>)[name]
if (!Comp) return null
return <Comp size={size} stroke={1.5} style={color ? { color } : undefined} />
}
export { renderIcon }
function IconGrid({ names, value, currentColor, onPick }: { names: string[], value: string | null, currentColor: string, onPick: (name: string) => void }) {
return (
<div className="grid grid-cols-8 gap-0.5">
{names.map((name) => (
<button
key={name}
type="button"
onClick={() => onPick(name)}
title={name.replace('Icon', '')}
aria-label={name.replace('Icon', '')}
className="flex items-center justify-center icon-picker-btn"
style={{
width: 44,
height: 44,
color: name === value ? currentColor : 'var(--text-secondary)',
background: name === value ? 'var(--accent-subtle)' : 'transparent',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
{renderIcon(name, 18)}
</button>
))}
</div>
)
}
export default function IconPicker({
value,
color,
onChangeIcon,
onChangeColor,
}: {
value: string | null
color: string | null
onChangeIcon: (name: string | null) => void
onChangeColor: (color: string | null) => void
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [visibleCount, setVisibleCount] = useState(120)
const ref = useRef<HTMLDivElement>(null)
const gridRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open])
// reset scroll on search change
useEffect(() => {
setVisibleCount(120)
if (gridRef.current) gridRef.current.scrollTop = 0
}, [search])
const popular = useMemo(() => POPULAR.filter((n) => ALL_ICON_NAMES.includes(n)), [])
const allExceptPopular = useMemo(() => ALL_ICON_NAMES.filter((n) => !POPULAR.includes(n)), [])
const filtered = useMemo(() => {
if (!search.trim()) return null
const q = search.toLowerCase().replace(/\s+/g, '')
return ALL_ICON_NAMES.filter((name) =>
name.toLowerCase().replace('icon', '').includes(q)
)
}, [search])
const totalCount = filtered ? filtered.length : popular.length + allExceptPopular.length
const handleScroll = useCallback(() => {
const el = gridRef.current
if (!el) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
setVisibleCount((c) => Math.min(c + 120, totalCount))
}
}, [totalCount])
const currentColor = color || 'var(--accent)'
return (
<div ref={ref} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center gap-2"
style={{
padding: '8px 12px',
background: 'var(--bg)',
border: open ? '1px solid var(--accent)' : '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: 'var(--text)',
fontSize: 'var(--text-sm)',
transition: 'border-color var(--duration-normal) ease-out',
cursor: 'pointer',
flex: 1,
}}
>
{value ? (
<span style={{ color: currentColor }}>
{renderIcon(value, 18, currentColor)}
</span>
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Choose icon</span>
)}
<span className="truncate" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>
{value ? value.replace('Icon', '') : 'None'}
</span>
{value && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onChangeIcon(null); onChangeColor(null) }}
className="ml-auto"
style={{ color: 'var(--text-tertiary)', padding: 2 }}
aria-label="Clear icon"
>
<IconX size={12} stroke={2} />
</button>
)}
</button>
</div>
{open && (
<div
className="absolute left-0 z-50 mt-1 fade-in"
style={{
width: 320,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-xl)',
overflow: 'hidden',
}}
>
{/* Search */}
<div className="p-2" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="flex items-center gap-2 px-2" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
<IconSearch size={14} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search icons..."
aria-label="Search icons"
autoFocus
style={{
width: '100%',
padding: '8px 0',
background: 'transparent',
border: 'none',
outline: 'none',
color: 'var(--text)',
fontSize: 'var(--text-xs)',
fontFamily: 'var(--font-body)',
}}
/>
{search && (
<button type="button" onClick={() => setSearch('')} aria-label="Clear search" style={{ color: 'var(--text-tertiary)' }}>
<IconX size={12} stroke={2} />
</button>
)}
</div>
</div>
{/* Color picker */}
<div className="px-3 py-2 flex items-center gap-1.5 flex-wrap" style={{ borderBottom: '1px solid var(--border)' }}>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginRight: 4 }}>Color</span>
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => onChangeColor(c)}
className="rounded-full"
aria-label={COLOR_NAMES[c] || c}
style={{
width: 44,
height: 44,
background: c,
border: c === color ? '2px solid var(--text)' : '2px solid transparent',
borderRadius: '50%',
cursor: 'pointer',
transition: 'border-color var(--duration-fast) ease-out',
}}
/>
))}
</div>
{/* Icon grid */}
<div
ref={gridRef}
className="p-2"
style={{ maxHeight: 320, overflowY: 'auto' }}
onScroll={handleScroll}
>
{filtered && filtered.length === 0 ? (
<div className="py-6 text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
No icons found
</div>
) : filtered ? (
<>
<div className="mb-1 px-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{filtered.length} results
</div>
<IconGrid names={filtered.slice(0, visibleCount)} value={value} currentColor={currentColor} onPick={(n) => { onChangeIcon(n); setOpen(false) }} />
</>
) : (
<>
<div className="mb-1 px-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Popular
</div>
<IconGrid names={popular} value={value} currentColor={currentColor} onPick={(n) => { onChangeIcon(n); setOpen(false) }} />
<div className="my-2 mx-1" style={{ borderTop: '1px solid var(--border)' }} />
<div className="mb-1 px-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
All icons
</div>
<IconGrid names={allExceptPopular.slice(0, Math.max(0, visibleCount - popular.length))} value={value} currentColor={currentColor} onPick={(n) => { onChangeIcon(n); setOpen(false) }} />
</>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -1,73 +1,237 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { useFocusTrap } from '../hooks/useFocusTrap'
import { IconLock, IconFingerprint, IconShieldCheck } from '@tabler/icons-react'
const DISMISSED_KEY = 'echoboard-identity-ack' const DISMISSED_KEY = 'echoboard-identity-ack'
const UPGRADE_DISMISSED_KEY = 'echoboard-upgrade-dismissed'
export default function IdentityBanner({ onRegister }: { onRegister: () => void }) { export default function IdentityBanner({ onRegister }: { onRegister: () => void }) {
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [showMore, setShowMore] = useState(false)
const [upgradeNudge, setUpgradeNudge] = useState(false)
const auth = useAuth()
const trapRef = useFocusTrap(visible)
useEffect(() => { useEffect(() => {
if (!localStorage.getItem(DISMISSED_KEY)) { if (!localStorage.getItem(DISMISSED_KEY)) {
const t = setTimeout(() => setVisible(true), 800) const t = setTimeout(() => setVisible(true), 400)
return () => clearTimeout(t) return () => clearTimeout(t)
} }
}, [])
if (!visible) return null if (!auth.isPasskeyUser && !auth.user?.hasRecoveryCode && auth.isAuthenticated) {
const dismissed = localStorage.getItem(UPGRADE_DISMISSED_KEY)
if (dismissed) {
const ts = parseInt(dismissed, 10)
if (Date.now() - ts < 7 * 24 * 60 * 60 * 1000) return
}
import('../lib/api').then(({ api }) => {
api.get<{ length: number } & any[]>('/me/posts').then((posts) => {
if (Array.isArray(posts) && posts.length >= 3) {
setUpgradeNudge(true)
}
}).catch(() => {})
})
}
}, [auth.isPasskeyUser, auth.isAuthenticated, auth.user?.hasRecoveryCode])
const dismiss = () => { const dismiss = () => {
localStorage.setItem(DISMISSED_KEY, '1') localStorage.setItem(DISMISSED_KEY, '1')
setVisible(false) setVisible(false)
} }
return ( const dismissUpgrade = () => {
<div localStorage.setItem(UPGRADE_DISMISSED_KEY, String(Date.now()))
className="fixed bottom-0 left-0 right-0 z-50 md:left-[280px] slide-up" setUpgradeNudge(false)
style={{ pointerEvents: 'none' }} }
>
// Upgrade nudge - subtle toast
if (upgradeNudge && !visible) {
return (
<div <div
className="mx-4 mb-4 p-5 rounded-xl shadow-2xl md:max-w-lg md:mx-auto" className="fixed bottom-20 md:bottom-4 left-4 right-4 md:left-auto md:right-20 md:max-w-sm z-40 p-4 fade-in"
style={{ style={{
background: 'var(--surface)', background: 'var(--surface)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
pointerEvents: 'auto', borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-lg)',
}} }}
> >
<div className="flex items-start gap-4"> <p className="mb-3" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
<div You have been active! If you clear your cookies, you'll lose access to your posts. Save your identity with a passkey, or grab a recovery code.
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5" </p>
style={{ background: 'var(--accent-subtle)' }} <div className="flex gap-2 flex-wrap">
<button
onClick={() => { dismissUpgrade(); onRegister() }}
className="btn btn-primary flex items-center gap-1.5"
style={{ fontSize: 'var(--text-xs)' }}
> >
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--accent)' }}> <IconFingerprint size={14} stroke={2} />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> Save identity
</svg> </button>
</div> <Link
<div className="flex-1"> to="/settings"
<h3 onClick={dismissUpgrade}
className="text-base font-semibold mb-1" className="btn btn-secondary flex items-center gap-1.5"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }} style={{ fontSize: 'var(--text-xs)' }}
>
<IconShieldCheck size={14} stroke={2} />
Recovery code
</Link>
<button
onClick={dismissUpgrade}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-xs)' }}
>
Dismiss
</button>
</div>
</div>
)
}
if (!visible) return null
// First-visit modal (centered)
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div
className="absolute inset-0 fade-in"
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}
/>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="identity-banner-title"
className="relative w-full max-w-xl mx-4 overflow-y-auto fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
maxHeight: '85vh',
padding: '32px',
}}
onKeyDown={(e) => e.key === 'Escape' && dismiss()}
>
<div
className="w-12 h-12 flex items-center justify-center mb-5"
style={{
background: 'var(--accent-subtle)',
borderRadius: 'var(--radius-md)',
}}
>
<IconLock size={24} stroke={2} style={{ color: 'var(--accent)' }} />
</div>
<h2
id="identity-banner-title"
className="font-bold mb-5"
style={{
fontFamily: 'var(--font-heading)',
fontSize: 'var(--text-xl)',
color: 'var(--text)',
}}
>
Before you dive in
</h2>
<div className="mb-4">
<h3 className="font-semibold mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
How your identity works
</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Your identity on this board starts as a cookie in your browser. It contains a random token - nothing personal. This token ties your posts, votes, and comments to you so you can manage them.
</p>
</div>
<div
className="mb-4 p-4"
style={{
background: 'rgba(234, 179, 8, 0.08)',
border: '1px solid rgba(234, 179, 8, 0.25)',
borderRadius: 'var(--radius-md)',
}}
>
<h3 className="font-semibold mb-2" style={{ color: 'var(--warning)', fontSize: 'var(--text-sm)' }}>
Your cookie expires when you close the browser
</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Your identity is a session cookie. Close your browser, clear cookies, switch device - and you lose access to everything you created. Posts stay on the board but become uneditable by you.
</p>
</div>
<div className="mb-5">
<h3 className="font-semibold mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
Want persistent access?
</h3>
<p className="mb-2" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Pick a username and save a passkey - no email, no account, no personal data. Your passkey syncs across your devices through your platform's credential manager, so you can always get back to your posts.
</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', lineHeight: 1.5 }}>
Browser doesn't support passkeys? You can generate a recovery code instead - a phrase you save that lets you get back to your posts. Visit Settings at any time to set one up.
</p>
</div>
<div
style={{
display: 'grid',
gridTemplateRows: showMore ? '1fr' : '0fr',
transition: 'grid-template-rows var(--duration-slow) var(--ease-out)',
}}
>
<div style={{ overflow: 'hidden' }}>
<div
className="mb-5 p-4"
style={{
background: 'var(--bg)',
color: 'var(--text-secondary)',
fontSize: 'var(--text-sm)',
lineHeight: 1.6,
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
opacity: showMore ? 1 : 0,
transition: 'opacity var(--duration-normal) ease-out',
}}
> >
Your identity is cookie-based <p className="mb-2">
</h3> <strong style={{ color: 'var(--text)' }}>What is stored:</strong> A random token (SHA-256 hashed on the server). The cookie is session-only and does not persist after you close your browser. If you register, a username and passkey public key (both encrypted at rest).
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}> </p>
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. <p className="mb-2">
</p> <strong style={{ color: 'var(--text)' }}>What is never stored:</strong> Your email, IP address, browser fingerprint, or any personal information. This site sets exactly one cookie containing your random identifier. No tracking, no analytics.
<div className="flex flex-wrap gap-2"> </p>
<button onClick={dismiss} className="btn btn-primary text-sm"> <p>
Continue anonymously <strong style={{ color: 'var(--text)' }}>Passkeys explained:</strong> Passkeys use the WebAuthn standard. Your device generates a key pair - the private key stays on your device (or syncs via iCloud Keychain, Google Password Manager, Windows Hello). The server only stores the public key.
</button> </p>
<button
onClick={() => { dismiss(); onRegister() }}
className="btn btn-secondary text-sm"
>
Register with passkey
</button>
<Link to="/privacy" onClick={dismiss} className="btn btn-ghost text-sm">
Learn more
</Link>
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={() => { dismiss(); onRegister() }}
className="btn btn-primary flex items-center justify-center gap-2"
>
<IconFingerprint size={16} stroke={2} />
Save my identity
</button>
<button onClick={dismiss} className="btn btn-secondary">
Continue anonymously
</button>
{!showMore && (
<button
onClick={() => setShowMore(true)}
className="btn btn-ghost"
>
Learn more
</button>
)}
</div>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,36 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
export default function Markdown({ children }: { children: string }) {
return (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
a: ({ href, children }) => {
const safe = href && /^https?:\/\//i.test(href)
return safe
? <a href={href} target="_blank" rel="noopener noreferrer">{children}<span className="sr-only"> (opens in new tab)</span></a>
: <span>{children}</span>
},
img: ({ src, alt }) => {
const safe = src && /^https?:\/\//i.test(src)
return safe
? <img src={src} alt={alt || ''} style={{ maxWidth: '100%', maxHeight: '400px', objectFit: 'contain' }} loading="lazy" referrerPolicy="no-referrer" />
: <span>{alt || ''}</span>
},
script: () => null,
iframe: () => null,
form: () => null,
input: () => null,
object: () => null,
embed: () => null,
style: () => null,
}}
>
{children}
</ReactMarkdown>
</div>
)
}

View File

@@ -0,0 +1,499 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import {
IconBold, IconItalic, IconStrikethrough, IconLink, IconCode,
IconList, IconListNumbers, IconQuote, IconPhoto, IconTable,
IconH1, IconH2, IconH3, IconMinus, IconCheckbox, IconSourceCode,
IconEye, IconPencil,
} from '@tabler/icons-react'
import Markdown from './Markdown'
import Avatar from './Avatar'
import { api } from '../lib/api'
interface MentionUser {
id: string
username: string
avatarUrl: string | null
}
interface Props {
value: string
onChange: (value: string) => void
placeholder?: string
rows?: number
autoFocus?: boolean
preview?: boolean
ariaRequired?: boolean
ariaLabel?: string
mentions?: boolean
}
type Action =
| { prefix: string; suffix: string }
| { linePrefix: string }
| { block: string }
interface ToolbarItem {
icon: typeof IconBold
title: string
action: Action
}
type ToolbarEntry = ToolbarItem | 'table'
type ToolbarGroup = ToolbarEntry[]
const toolbar: ToolbarGroup[] = [
[
{ icon: IconH1, title: 'Heading 1', action: { linePrefix: '# ' } },
{ icon: IconH2, title: 'Heading 2', action: { linePrefix: '## ' } },
{ icon: IconH3, title: 'Heading 3', action: { linePrefix: '### ' } },
],
[
{ icon: IconBold, title: 'Bold', action: { prefix: '**', suffix: '**' } },
{ icon: IconItalic, title: 'Italic', action: { prefix: '*', suffix: '*' } },
{ icon: IconStrikethrough, title: 'Strikethrough', action: { prefix: '~~', suffix: '~~' } },
],
[
{ icon: IconCode, title: 'Inline code', action: { prefix: '`', suffix: '`' } },
{ icon: IconSourceCode, title: 'Code block', action: { block: '```\n\n```' } },
{ icon: IconQuote, title: 'Blockquote', action: { linePrefix: '> ' } },
],
[
{ icon: IconList, title: 'Bullet list', action: { linePrefix: '- ' } },
{ icon: IconListNumbers, title: 'Numbered list', action: { linePrefix: '1. ' } },
{ icon: IconCheckbox, title: 'Task list', action: { linePrefix: '- [ ] ' } },
],
[
{ icon: IconLink, title: 'Link', action: { prefix: '[', suffix: '](url)' } },
{ icon: IconPhoto, title: 'Image', action: { prefix: '![', suffix: '](url)' } },
{ icon: IconMinus, title: 'Horizontal rule', action: { block: '---' } },
'table',
],
]
const GRID_COLS = 8
const GRID_ROWS = 6
const CELL = 44
const GAP = 1
function buildTable(cols: number, rows: number): string {
const header = '| ' + Array.from({ length: cols }, (_, i) => `Column ${i + 1}`).join(' | ') + ' |'
const sep = '| ' + Array.from({ length: cols }, () => '---').join(' | ') + ' |'
const dataRows = Array.from({ length: rows }, () =>
'| ' + Array.from({ length: cols }, () => ' ').join(' | ') + ' |'
)
return [header, sep, ...dataRows].join('\n')
}
function TablePicker({ onSelect, onClose }: { onSelect: (cols: number, rows: number) => void; onClose: () => void }) {
const [hover, setHover] = useState<[number, number]>([0, 0])
const popRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (popRef.current && !popRef.current.contains(e.target as Node)) onClose()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [onClose])
return (
<div
ref={popRef}
className="absolute z-50"
style={{
top: '100%',
left: 0,
marginTop: 4,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-lg)',
padding: 8,
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${GRID_COLS}, ${CELL}px)`,
gap: GAP,
}}
>
{Array.from({ length: GRID_ROWS * GRID_COLS }, (_, i) => {
const col = i % GRID_COLS
const row = Math.floor(i / GRID_COLS)
const active = col < hover[0] && row < hover[1]
return (
<button
key={i}
type="button"
aria-label={`${col + 1} by ${row + 1} table`}
onMouseEnter={() => setHover([col + 1, row + 1])}
onFocus={() => setHover([col + 1, row + 1])}
onClick={() => { onSelect(col + 1, row + 1); onClose() }}
style={{
width: CELL,
height: CELL,
borderRadius: 3,
border: active ? '1px solid var(--accent)' : '1px solid var(--border)',
background: active ? 'var(--accent-subtle)' : 'transparent',
cursor: 'pointer',
padding: 0,
transition: 'background 60ms ease-out, border-color 60ms ease-out',
}}
/>
)
})}
</div>
<div
className="text-center mt-1.5"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
>
{hover[0] > 0 && hover[1] > 0 ? `${hover[0]} x ${hover[1]}` : 'Select size'}
</div>
</div>
)
}
export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions }: Props) {
const ref = useRef<HTMLTextAreaElement>(null)
const [previewing, setPreviewing] = useState(false)
const [tablePicker, setTablePicker] = useState(false)
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([])
const [mentionActive, setMentionActive] = useState(0)
const mentionDebounce = useRef<ReturnType<typeof setTimeout>>()
const mentionDropdownRef = useRef<HTMLDivElement>(null)
const [mentionQuery, setMentionQuery] = useState('')
const getMentionQuery = useCallback((): string | null => {
const ta = ref.current
if (!ta || !enableMentions) return null
const pos = ta.selectionStart
const before = value.slice(0, pos)
const match = before.match(/@([a-zA-Z0-9_]{2,30})$/)
return match ? match[1] : null
}, [value, enableMentions])
useEffect(() => {
if (!enableMentions) return
const q = getMentionQuery()
if (!q || q.length < 2) {
setMentionUsers([])
setMentionQuery('')
return
}
if (q === mentionQuery) return
setMentionQuery(q)
if (mentionDebounce.current) clearTimeout(mentionDebounce.current)
mentionDebounce.current = setTimeout(() => {
api.get<{ users: MentionUser[] }>(`/users/search?q=${encodeURIComponent(q)}`)
.then((r) => { setMentionUsers(r.users); setMentionActive(0) })
.catch(() => setMentionUsers([]))
}, 200)
return () => { if (mentionDebounce.current) clearTimeout(mentionDebounce.current) }
}, [value, getMentionQuery, enableMentions])
const insertMention = (username: string) => {
const ta = ref.current
if (!ta) return
const pos = ta.selectionStart
const before = value.slice(0, pos)
const after = value.slice(pos)
const atIdx = before.lastIndexOf('@')
if (atIdx === -1) return
const newVal = before.slice(0, atIdx) + '@' + username + ' ' + after
onChange(newVal)
setMentionUsers([])
setMentionQuery('')
const newPos = atIdx + username.length + 2
requestAnimationFrame(() => { ta.focus(); ta.setSelectionRange(newPos, newPos) })
}
useEffect(() => {
if (!enableMentions) return
const handler = (e: MouseEvent) => {
if (mentionDropdownRef.current && !mentionDropdownRef.current.contains(e.target as Node)) {
setMentionUsers([])
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [enableMentions])
const insertBlock = (block: string) => {
const ta = ref.current
if (!ta) return
const start = ta.selectionStart
const end = ta.selectionEnd
const before = value.slice(0, start)
const after = value.slice(end)
const needsBefore = before.length > 0 && !before.endsWith('\n\n')
const needsAfter = after.length > 0 && !after.startsWith('\n')
const prefix = needsBefore ? (before.endsWith('\n') ? '\n' : '\n\n') : ''
const suffix = needsAfter ? '\n' : ''
const newValue = before + prefix + block + suffix + after
const firstNewline = block.indexOf('\n')
const blockStart = before.length + prefix.length
const cursorPos = firstNewline > -1 ? blockStart + firstNewline + 1 : blockStart + block.length
onChange(newValue)
requestAnimationFrame(() => {
ta.focus()
ta.setSelectionRange(cursorPos, cursorPos)
})
}
const apply = (action: Action) => {
const ta = ref.current
if (!ta) return
const start = ta.selectionStart
const end = ta.selectionEnd
const selected = value.slice(start, end)
let newValue: string
let cursorPos: number
if ('block' in action) {
insertBlock(action.block)
return
} else if ('linePrefix' in action) {
const lines = selected ? selected.split('\n') : ['']
const prefixed = lines.map((l) => action.linePrefix + l).join('\n')
newValue = value.slice(0, start) + prefixed + value.slice(end)
cursorPos = start + prefixed.length
} else {
const wrapped = action.prefix + (selected || 'text') + action.suffix
newValue = value.slice(0, start) + wrapped + value.slice(end)
if (selected) {
cursorPos = start + wrapped.length
} else {
cursorPos = start + action.prefix.length + 4
}
}
onChange(newValue)
requestAnimationFrame(() => {
ta.focus()
if (!selected && 'prefix' in action && 'suffix' in action) {
ta.setSelectionRange(start + action.prefix.length, start + action.prefix.length + 4)
} else {
ta.setSelectionRange(cursorPos, cursorPos)
}
})
}
const btnStyle = {
color: 'var(--text-tertiary)',
borderRadius: 'var(--radius-sm)',
transition: 'color var(--duration-fast) ease-out, background var(--duration-fast) ease-out',
}
const hover = (e: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement>, enter: boolean) => {
e.currentTarget.style.color = enter ? 'var(--text)' : 'var(--text-tertiary)'
e.currentTarget.style.background = enter ? 'var(--surface-hover)' : 'transparent'
}
return (
<div>
<div
className="flex items-center gap-0.5 mb-1.5 px-1 flex-wrap"
style={{ minHeight: 28 }}
>
{toolbar.map((group, gi) => (
<div key={gi} className="flex items-center gap-0.5">
{gi > 0 && (
<div
style={{
width: 1,
height: 16,
background: 'var(--border)',
margin: '0 4px',
flexShrink: 0,
}}
/>
)}
{group.map((entry) => {
if (entry === 'table') {
return (
<div key="table" className="relative">
<button
type="button"
title="Table"
aria-label="Table"
onClick={() => { setPreviewing(false); setTablePicker(!tablePicker) }}
className="w-11 h-11 flex items-center justify-center"
style={{
...btnStyle,
color: tablePicker ? 'var(--accent)' : 'var(--text-tertiary)',
}}
onMouseEnter={(e) => hover(e, true)}
onMouseLeave={(e) => {
e.currentTarget.style.color = tablePicker ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
onFocus={(e) => hover(e, true)}
onBlur={(e) => {
e.currentTarget.style.color = tablePicker ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
>
<IconTable size={14} stroke={2} />
</button>
{tablePicker && (
<TablePicker
onSelect={(cols, rows) => {
setPreviewing(false)
insertBlock(buildTable(cols, rows))
}}
onClose={() => setTablePicker(false)}
/>
)}
</div>
)
}
const { icon: Icon, title, action } = entry
return (
<button
key={title}
type="button"
title={title}
aria-label={title}
onClick={() => { setPreviewing(false); apply(action) }}
className="w-11 h-11 flex items-center justify-center"
style={btnStyle}
onMouseEnter={(e) => hover(e, true)}
onMouseLeave={(e) => hover(e, false)}
onFocus={(e) => hover(e, true)}
onBlur={(e) => hover(e, false)}
>
<Icon size={14} stroke={2} />
</button>
)
})}
</div>
))}
{enablePreview && (
<>
<div
style={{
width: 1,
height: 16,
background: 'var(--border)',
margin: '0 4px',
flexShrink: 0,
}}
/>
<button
type="button"
title={previewing ? 'Edit' : 'Preview'}
aria-label={previewing ? 'Edit' : 'Preview'}
onClick={() => setPreviewing(!previewing)}
className="w-11 h-11 flex items-center justify-center"
style={{
...btnStyle,
color: previewing ? 'var(--accent)' : 'var(--text-tertiary)',
}}
onMouseEnter={(e) => hover(e, true)}
onMouseLeave={(e) => {
e.currentTarget.style.color = previewing ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
onFocus={(e) => hover(e, true)}
onBlur={(e) => {
e.currentTarget.style.color = previewing ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
>
{previewing ? <IconPencil size={14} stroke={2} /> : <IconEye size={14} stroke={2} />}
</button>
</>
)}
</div>
{previewing ? (
<div
className="input w-full"
style={{
minHeight: rows * 24 + 24,
padding: '12px 14px',
overflow: 'auto',
resize: 'vertical',
}}
>
{value.trim() ? (
<Markdown>{value}</Markdown>
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
Nothing to preview
</span>
)}
</div>
) : (
<div style={{ position: 'relative' }}>
<textarea
ref={ref}
className="input w-full"
placeholder={placeholder}
rows={rows}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={enableMentions && mentionUsers.length > 0 ? (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionActive((a) => (a + 1) % mentionUsers.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setMentionActive((a) => (a - 1 + mentionUsers.length) % mentionUsers.length) }
else if ((e.key === 'Enter' || e.key === 'Tab') && mentionUsers[mentionActive]) { e.preventDefault(); insertMention(mentionUsers[mentionActive].username) }
else if (e.key === 'Escape') { setMentionUsers([]); setMentionQuery('') }
} : undefined}
style={{ resize: 'vertical' }}
autoFocus={autoFocus}
aria-label={ariaLabel || 'Markdown content'}
aria-required={ariaRequired || undefined}
/>
{enableMentions && mentionUsers.length > 0 && (
<div
ref={mentionDropdownRef}
role="listbox"
aria-label="Mention suggestions"
style={{
position: 'absolute',
left: 0,
bottom: -4,
transform: 'translateY(100%)',
zIndex: 50,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
minWidth: 200,
maxHeight: 240,
overflow: 'auto',
}}
>
{mentionUsers.map((u, i) => (
<button
key={u.id}
role="option"
aria-selected={i === mentionActive}
onClick={() => insertMention(u.username)}
className="flex items-center gap-2 w-full px-3"
style={{
minHeight: 44,
background: i === mentionActive ? 'var(--surface-hover)' : 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-sm)',
color: 'var(--text)',
textAlign: 'left',
}}
onMouseEnter={() => setMentionActive(i)}
>
<Avatar userId={u.id} name={u.username} avatarUrl={u.avatarUrl} size={22} />
<span>@{u.username}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,164 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { api } from '../lib/api'
import Avatar from './Avatar'
interface MentionUser {
id: string
username: string
avatarUrl: string | null
}
interface Props {
value: string
onChange: (value: string) => void
placeholder?: string
rows?: number
ariaLabel?: string
}
export default function MentionInput({ value, onChange, placeholder, rows = 3, ariaLabel }: Props) {
const ref = useRef<HTMLTextAreaElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [users, setUsers] = useState<MentionUser[]>([])
const [active, setActive] = useState(0)
const [query, setQuery] = useState('')
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
const getMentionQuery = useCallback((): string | null => {
const ta = ref.current
if (!ta) return null
const pos = ta.selectionStart
const before = value.slice(0, pos)
const match = before.match(/@([a-zA-Z0-9_]{2,30})$/)
return match ? match[1] : null
}, [value])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value)
}
useEffect(() => {
const q = getMentionQuery()
if (!q || q.length < 2) {
setUsers([])
setQuery('')
return
}
if (q === query) return
setQuery(q)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
api.get<{ users: MentionUser[] }>(`/users/search?q=${encodeURIComponent(q)}`)
.then((r) => {
setUsers(r.users)
setActive(0)
})
.catch(() => setUsers([]))
}, 200)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [value, getMentionQuery])
const insertMention = (username: string) => {
const ta = ref.current
if (!ta) return
const pos = ta.selectionStart
const before = value.slice(0, pos)
const after = value.slice(pos)
const atIdx = before.lastIndexOf('@')
if (atIdx === -1) return
const newValue = before.slice(0, atIdx) + '@' + username + ' ' + after
onChange(newValue)
setUsers([])
setQuery('')
const newPos = atIdx + username.length + 2
requestAnimationFrame(() => {
ta.focus()
ta.setSelectionRange(newPos, newPos)
})
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (users.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((a) => (a + 1) % users.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((a) => (a - 1 + users.length) % users.length)
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (users[active]) {
e.preventDefault()
insertMention(users[active].username)
}
} else if (e.key === 'Escape') {
setUsers([])
setQuery('')
}
}
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setUsers([])
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
return (
<div style={{ position: 'relative' }}>
<textarea
ref={ref}
className="input w-full"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
style={{ resize: 'vertical' }}
aria-label={ariaLabel || 'Comment'}
/>
{users.length > 0 && (
<div
ref={dropdownRef}
style={{
position: 'absolute',
left: 0,
bottom: -4,
transform: 'translateY(100%)',
zIndex: 50,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
minWidth: 200,
maxHeight: 240,
overflow: 'auto',
}}
>
{users.map((u, i) => (
<button
key={u.id}
onClick={() => insertMention(u.username)}
className="flex items-center gap-2 w-full px-3"
style={{
minHeight: 44,
background: i === active ? 'var(--surface-hover)' : 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-sm)',
color: 'var(--text)',
textAlign: 'left',
}}
onMouseEnter={() => setActive(i)}
>
<Avatar userId={u.id} name={u.username} avatarUrl={u.avatarUrl} size={22} />
<span>@{u.username}</span>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -1,92 +1,63 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation, useParams } from 'react-router-dom'
import { IconHome, IconSearch, IconPlus, IconBell, IconUser, IconShieldCheck } from '@tabler/icons-react'
const tabs = [ import { useAdmin } from '../hooks/useAdmin'
{
path: '/',
label: 'Home',
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
</svg>
),
},
{
path: '/search',
label: 'Search',
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
),
},
{
path: '/new',
label: 'New',
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
),
accent: true,
},
{
path: '/activity',
label: 'Activity',
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
},
{
path: '/settings',
label: 'Profile',
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
),
},
]
export default function MobileNav() { export default function MobileNav() {
const location = useLocation() const location = useLocation()
const { boardSlug } = useParams()
const admin = useAdmin()
const isActive = (path: string) => location.pathname === path
const newPath = boardSlug ? `/b/${boardSlug}?new=1` : '/'
const tabs = [
{ path: '/', label: 'Home', icon: IconHome },
{ path: '/search', label: 'Search', icon: IconSearch },
{ path: newPath, label: 'New', icon: IconPlus, accent: true },
{ path: '/activity', label: 'Activity', icon: IconBell },
{ path: '/settings', label: admin.isAdmin ? 'Admin' : 'Profile', icon: admin.isAdmin ? IconShieldCheck : IconUser },
]
return ( return (
<nav <nav
className="fixed bottom-0 left-0 right-0 md:hidden flex items-center justify-around py-2 border-t z-50" aria-label="Main navigation"
className="fixed bottom-0 left-0 right-0 md:hidden flex items-center justify-around border-t z-50"
style={{ style={{
background: 'var(--surface)', background: 'var(--surface)',
borderColor: 'var(--border)', borderColor: admin.isAdmin ? 'rgba(6, 182, 212, 0.15)' : 'var(--border)',
paddingBottom: 'max(0.5rem, env(safe-area-inset-bottom))', padding: '6px 0',
paddingBottom: 'max(6px, env(safe-area-inset-bottom))',
boxShadow: '0 -1px 8px rgba(0,0,0,0.08)',
}} }}
> >
{tabs.map((tab) => { {tabs.map((tab) => {
const active = location.pathname === tab.path || const active = isActive(tab.path)
(tab.path === '/' && location.pathname === '/') const Icon = tab.icon
return ( return (
<Link <Link
key={tab.path} key={tab.label}
to={tab.path} to={tab.path}
className="flex flex-col items-center gap-0.5 px-3 py-1" className="flex flex-col items-center gap-0.5 px-3 py-1 nav-link"
style={{ transition: 'color 200ms ease-out' }} style={{ transition: 'color var(--duration-fast) ease-out', borderRadius: 'var(--radius-sm)' }}
aria-current={active ? 'page' : undefined}
> >
{tab.accent ? ( {tab.accent ? (
<div <div
className="w-10 h-10 rounded-full flex items-center justify-center -mt-4" className="w-11 h-11 rounded-full flex items-center justify-center -mt-4"
style={{ background: 'var(--accent)', color: '#141420' }} style={{
background: 'var(--accent)',
color: 'var(--bg)',
boxShadow: 'var(--shadow-glow)',
}}
> >
{tab.icon} <Icon size={22} stroke={2.5} />
</div> </div>
) : ( ) : (
<div style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}> <div style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}>
{tab.icon} <Icon size={22} stroke={2} />
</div> </div>
)} )}
<span <span style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
className="text-[10px]"
style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}
>
{tab.label} {tab.label}
</span> </span>
</Link> </Link>

View File

@@ -0,0 +1,138 @@
import { useRef, useCallback, useEffect } from 'react'
import { IconMinus, IconPlus } from '@tabler/icons-react'
function SpinButton({
direction,
disabled,
onTick,
}: {
direction: 'down' | 'up'
disabled: boolean
onTick: () => void
}) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const tickRef = useRef(onTick)
tickRef.current = onTick
const stopRepeat = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current)
if (intervalRef.current) clearInterval(intervalRef.current)
timerRef.current = null
intervalRef.current = null
}, [])
useEffect(() => stopRepeat, [stopRepeat])
const startRepeat = useCallback(() => {
if (disabled) return
tickRef.current()
timerRef.current = setTimeout(() => {
intervalRef.current = setInterval(() => tickRef.current(), 80)
}, 400)
}, [disabled])
const Icon = direction === 'down' ? IconMinus : IconPlus
return (
<button
type="button"
disabled={disabled}
onMouseDown={startRepeat}
onMouseUp={stopRepeat}
onMouseLeave={stopRepeat}
onTouchStart={startRepeat}
onTouchEnd={stopRepeat}
aria-label={direction === 'down' ? 'Decrease' : 'Increase'}
className="number-input-btn"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
alignSelf: 'stretch',
background: 'transparent',
border: 'none',
borderRight: direction === 'down' ? '1px solid var(--border)' : 'none',
borderLeft: direction === 'up' ? '1px solid var(--border)' : 'none',
color: disabled ? 'var(--text-tertiary)' : 'var(--text-secondary)',
cursor: disabled ? 'default' : 'pointer',
padding: 0,
flexShrink: 0,
}}
>
<Icon size={13} stroke={2} />
</button>
)
}
export default function NumberInput({
value,
onChange,
min = 1,
max = 999,
step = 1,
style,
'aria-label': ariaLabel,
}: {
value: number
onChange: (value: number) => void
min?: number
max?: number
step?: number
style?: React.CSSProperties
'aria-label'?: string
}) {
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback((n: number) => Math.max(min, Math.min(max, n)), [min, max])
const tickDown = useCallback(() => {
onChange(clamp(valueRef.current - step))
}, [onChange, clamp, step])
const tickUp = useCallback(() => {
onChange(clamp(valueRef.current + step))
}, [onChange, clamp, step])
return (
<div
className="number-input-wrapper"
style={{
display: 'flex',
alignItems: 'stretch',
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
...style,
}}
>
<SpinButton direction="down" disabled={value <= min} onTick={tickDown} />
<input
type="text"
inputMode="numeric"
value={value}
aria-label={ariaLabel}
onChange={(e) => {
const n = parseInt(e.target.value)
if (!isNaN(n)) onChange(clamp(n))
}}
style={{
flex: 1,
minWidth: 0,
padding: '8px 4px',
background: 'transparent',
border: 'none',
outline: 'none',
color: 'var(--text)',
fontSize: 'var(--text-sm)',
fontFamily: 'var(--font-body)',
textAlign: 'center',
}}
/>
<SpinButton direction="up" disabled={value >= max} onTick={tickUp} />
</div>
)
}

View File

@@ -1,7 +1,10 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useAuth } from '../hooks/useAuth' import { useAuth } from '../hooks/useAuth'
import { useFocusTrap } from '../hooks/useFocusTrap'
import { IconX, IconCheck, IconAlertTriangle, IconFingerprint } from '@tabler/icons-react'
interface Props { interface Props {
mode: 'register' | 'login' mode: 'register' | 'login'
@@ -11,25 +14,48 @@ interface Props {
export default function PasskeyModal({ mode, open, onClose }: Props) { export default function PasskeyModal({ mode, open, onClose }: Props) {
const auth = useAuth() const auth = useAuth()
const trapRef = useFocusTrap(open)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const [available, setAvailable] = useState<boolean | null>(null) const [available, setAvailable] = useState<boolean | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [browserSupport, setBrowserSupport] = useState<'checking' | 'full' | 'basic' | 'none'>('checking')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const checkTimer = useRef<ReturnType<typeof setTimeout>>() const checkTimer = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
async function check() {
if (!window.PublicKeyCredential) {
setBrowserSupport('none')
return
}
try {
const platform = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
setBrowserSupport(platform ? 'full' : 'basic')
} catch {
setBrowserSupport('basic')
}
}
check()
}, [])
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setUsername('') const prefill = mode === 'register' && auth.displayName && auth.displayName !== 'Anonymous'
? auth.displayName
: ''
setUsername(prefill)
setAvailable(null) setAvailable(null)
setError('') setError('')
setSuccess(false)
setTimeout(() => inputRef.current?.focus(), 100) setTimeout(() => inputRef.current?.focus(), 100)
} }
}, [open]) }, [open])
useEffect(() => { useEffect(() => {
if (mode !== 'register' || !username.trim() || username.length < 3) { if (!username.trim() || username.length < 3) {
setAvailable(null) setAvailable(null)
return return
} }
@@ -37,16 +63,16 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
checkTimer.current = setTimeout(async () => { checkTimer.current = setTimeout(async () => {
setChecking(true) setChecking(true)
try { try {
const res = await api.get<{ available: boolean }>(`/identity/check-username?name=${encodeURIComponent(username)}`) const res = await api.get<{ available: boolean }>(`/auth/passkey/check-username/${encodeURIComponent(username)}`)
setAvailable(res.available) setAvailable(res.available)
} catch { } catch {
setAvailable(null) setAvailable(null)
} finally { } finally {
setChecking(false) setChecking(false)
} }
}, 400) }, 300)
return () => clearTimeout(checkTimer.current) return () => clearTimeout(checkTimer.current)
}, [username, mode]) }, [username])
const handleRegister = async () => { const handleRegister = async () => {
if (!username.trim()) { if (!username.trim()) {
@@ -59,9 +85,10 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
try { try {
const opts = await api.post<any>('/auth/passkey/register/options', { username }) const opts = await api.post<any>('/auth/passkey/register/options', { username })
const attestation = await startRegistration({ optionsJSON: opts }) const attestation = await startRegistration({ optionsJSON: opts })
await api.post('/auth/passkey/register/verify', { username, attestation }) await api.post('/auth/passkey/register/verify', { username, response: attestation })
setSuccess(true)
await auth.refresh() await auth.refresh()
onClose() setTimeout(onClose, 2000)
} catch (e: any) { } catch (e: any) {
setError(e?.message || 'Registration failed. Please try again.') setError(e?.message || 'Registration failed. Please try again.')
} finally { } finally {
@@ -74,11 +101,12 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
setError('') setError('')
try { try {
const opts = await api.post<any>('/auth/passkey/login/options') const opts = await api.post<any>('/auth/passkey/login/options', {})
const assertion = await startAuthentication({ optionsJSON: opts }) const assertion = await startAuthentication({ optionsJSON: opts })
await api.post('/auth/passkey/login/verify', { assertion }) await api.post('/auth/passkey/login/verify', { response: assertion })
setSuccess(true)
await auth.refresh() await auth.refresh()
onClose() setTimeout(onClose, 2000)
} catch (e: any) { } catch (e: any) {
setError(e?.message || 'Authentication failed. Please try again.') setError(e?.message || 'Authentication failed. Please try again.')
} finally { } finally {
@@ -98,87 +126,194 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }} style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }}
/> />
<div <div
className="relative w-full max-w-sm mx-4 rounded-xl p-6 shadow-2xl slide-up" ref={trapRef}
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }} role="dialog"
aria-modal="true"
aria-labelledby="passkey-modal-title"
className="relative w-full max-w-sm mx-4 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
padding: '24px',
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
> >
<div className="flex items-center justify-between mb-6"> {success ? (
<h2 <div className="flex flex-col items-center py-6 fade-in">
className="text-lg font-bold" <div
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }} className="w-16 h-16 flex items-center justify-center mb-4"
> style={{ background: 'rgba(34, 197, 94, 0.12)', borderRadius: 'var(--radius-lg)' }}
{mode === 'register' ? 'Register Passkey' : 'Login with Passkey'} >
</h2> <IconCheck size={32} stroke={2.5} color="var(--success)" />
<button onClick={onClose} className="btn btn-ghost p-1"> </div>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <p className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> {mode === 'register' ? 'Identity saved' : 'Signed in'}
</svg>
</button>
</div>
{mode === 'register' ? (
<>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
Choose a display name and register a passkey to keep your identity across devices.
</p> </p>
<div className="relative mb-4"> </div>
<input ) : (
ref={inputRef} <>
className="input pr-8" <div className="flex items-center justify-between mb-5">
placeholder="Display name" <h2
value={username} id="passkey-modal-title"
onChange={(e) => setUsername(e.target.value)} className="font-bold"
/> style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-lg)' }}
{checking && ( >
<div className="absolute right-3 top-1/2 -translate-y-1/2"> {mode === 'register' ? 'Save my identity' : 'Sign in with passkey'}
<div className="w-4 h-4 border-2 rounded-full" style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }} /> </h2>
<button
onClick={onClose}
className="w-11 h-11 flex items-center justify-center"
style={{
color: 'var(--text-tertiary)',
borderRadius: 'var(--radius-sm)',
transition: 'color var(--duration-fast) ease-out',
}}
aria-label="Close"
>
<IconX size={18} stroke={2} />
</button>
</div>
{mode === 'register' ? (
<>
{/* Passkey explainer */}
<div
className="mb-4 p-3 flex gap-3"
style={{
background: 'var(--bg)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
color: 'var(--text-secondary)',
lineHeight: 1.5,
}}
>
<IconFingerprint size={20} stroke={1.5} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 1 }} />
<div>
<p style={{ fontWeight: 600, color: 'var(--text)', marginBottom: 4 }}>What's a passkey?</p>
<p>
A passkey is a modern replacement for passwords. It uses your device's built-in security
(fingerprint, face, PIN, or security key) to prove it's you - no password to remember or type.
Your passkey stays on your device and is never sent to our server.
</p>
</div>
</div> </div>
)}
{!checking && available !== null && ( {browserSupport === 'none' && (
<div className="absolute right-3 top-1/2 -translate-y-1/2"> <div
{available ? ( className="mb-4 p-3 flex items-start gap-3"
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--success)" strokeWidth={2.5}> style={{
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> background: 'rgba(239, 68, 68, 0.06)',
</svg> borderRadius: 'var(--radius-md)',
) : ( border: '1px solid rgba(239, 68, 68, 0.15)',
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--error)" strokeWidth={2.5}> fontSize: 'var(--text-xs)',
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> color: 'var(--text-secondary)',
</svg> lineHeight: 1.5,
}}
>
<IconAlertTriangle size={16} stroke={2} style={{ color: 'var(--error)', flexShrink: 0, marginTop: 1 }} />
<div>
<p>Your browser doesn't support passkeys. Try a recent version of Chrome, Safari, Firefox, or Edge.</p>
<p className="mt-1">
You can still protect your identity with a{' '}
<Link
to="/settings"
onClick={onClose}
style={{ color: 'var(--accent)', textDecoration: 'underline', textUnderlineOffset: '2px' }}
>
recovery code
</Link>
.
</p>
</div>
</div>
)}
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Pick a username to save your identity. No email, no account - just a name so you can get back to your posts.
</p>
<div className="relative mb-4">
<input
ref={inputRef}
className="input pr-8"
placeholder="Username"
aria-label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
maxLength={30}
autoComplete="username"
aria-describedby={error ? 'passkey-error' : undefined}
/>
{checking && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div
className="w-4 h-4 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
)}
{!checking && available !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{available ? (
<IconCheck size={16} stroke={2.5} color="var(--success)" />
) : (
<IconX size={16} stroke={2.5} color="var(--error)" />
)}
</div>
)} )}
</div> </div>
{!checking && available === false && (
<p role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>This username is taken. Try adding numbers or pick a different name.</p>
)}
</>
) : (
<>
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Use your registered passkey to sign in and restore your identity.
</p>
<div className="mb-4">
<input
ref={inputRef}
className="input"
placeholder="Username (optional - helps find your passkey)"
aria-label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
aria-describedby={error ? 'passkey-error' : undefined}
/>
</div>
</>
)}
{error && <p id="passkey-error" role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{error}</p>}
<button
onClick={mode === 'register' ? handleRegister : handleLogin}
disabled={loading || browserSupport === 'none' || (mode === 'register' && (!username.trim() || available === false))}
className="btn btn-primary w-full"
style={{ opacity: loading ? 0.6 : 1 }}
>
{loading ? (
<div
className="w-4 h-4 border-2 rounded-full"
style={{ borderColor: 'rgba(20,20,32,0.3)', borderTopColor: '#141420', animation: 'spin 0.6s linear infinite' }}
/>
) : mode === 'register' ? (
'Save my identity'
) : (
'Sign in with passkey'
)} )}
</div> </button>
{!checking && available === false && (
<p className="text-xs mb-3" style={{ color: 'var(--error)' }}>This name is taken</p> {mode === 'register' && (
<p className="mt-4 text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Your passkey is stored on your device. No passwords involved.
</p>
)} )}
</> </>
) : (
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
Use your registered passkey to sign in and restore your identity.
</p>
)}
{error && <p className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</p>}
<button
onClick={mode === 'register' ? handleRegister : handleLogin}
disabled={loading || (mode === 'register' && (!username.trim() || available === false))}
className="btn btn-primary w-full"
style={{ opacity: loading ? 0.6 : 1 }}
>
{loading ? (
<div className="w-4 h-4 border-2 rounded-full" style={{ borderColor: 'rgba(20,20,32,0.3)', borderTopColor: '#141420', animation: 'spin 0.6s linear infinite' }} />
) : mode === 'register' ? (
'Register Passkey'
) : (
'Sign in with Passkey'
)}
</button>
{mode === 'register' && (
<p className="text-xs mt-4 text-center" style={{ color: 'var(--text-tertiary)' }}>
Your passkey is stored on your device. No passwords involved.
</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,51 @@
import { useState, useEffect } from 'react'
import DOMPurify from 'dompurify'
import { api } from '../lib/api'
interface PluginInfo {
name: string
version: string
components?: Record<string, string>
}
let pluginCache: PluginInfo[] | null = null
let pluginPromise: Promise<PluginInfo[]> | null = null
function fetchPlugins(): Promise<PluginInfo[]> {
if (pluginCache) return Promise.resolve(pluginCache)
if (!pluginPromise) {
pluginPromise = api.get<PluginInfo[]>('/plugins/active')
.then((data) => { pluginCache = data; return data })
.catch(() => { pluginCache = []; return [] })
}
return pluginPromise
}
export default function PluginSlot({ name }: { name: string }) {
const [html, setHtml] = useState<string[]>([])
useEffect(() => {
fetchPlugins().then((plugins) => {
const parts: string[] = []
for (const p of plugins) {
if (p.components?.[name]) parts.push(p.components[name])
}
if (parts.length > 0) setHtml(parts)
})
}, [name])
if (html.length === 0) return null
return (
<>
{html.map((h, i) => (
<div key={i} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(h, {
ALLOWED_TAGS: ['div', 'span', 'p', 'a', 'b', 'i', 'em', 'strong', 'ul', 'ol', 'li', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'img', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt', 'class'],
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
}) }} />
))}
</>
)
}

View File

@@ -1,101 +1,367 @@
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import StatusBadge from './StatusBadge' import StatusBadge from './StatusBadge'
import type { StatusConfig } from './StatusBadge'
import { IconChevronUp, IconMessageCircle, IconBug, IconBulb, IconPin, IconClock, IconEye } from '@tabler/icons-react'
import Avatar from './Avatar'
interface Post { interface Post {
id: string id: string
title: string title: string
excerpt?: string description?: Record<string, string>
type: 'feature' | 'bug' | 'general' type: 'FEATURE_REQUEST' | 'BUG_REPORT'
status: string status: string
statusReason?: string | null
category?: string | null
voteCount: number voteCount: number
commentCount: number commentCount: number
authorName: string viewCount?: number
isPinned?: boolean
isStale?: boolean
onBehalfOf?: string | null
author?: { id: string; displayName: string; avatarUrl?: string | null } | null
createdAt: string createdAt: string
boardSlug: string boardSlug: string
hasVoted?: boolean voted?: boolean
voteWeight?: number
} }
function descriptionExcerpt(desc?: Record<string, string>, max = 120): string {
if (!desc) return ''
const text = Object.values(desc).join(' ').replace(/\s+/g, ' ').trim()
return text.length > max ? text.slice(0, max) + '...' : text
}
const IMPORTANCE_OPTIONS = [
{ value: 'critical', label: 'Critical', color: '#ef4444' },
{ value: 'important', label: 'Important', color: '#f59e0b' },
{ value: 'nice_to_have', label: 'Nice to have', color: '#3b82f6' },
{ value: 'minor', label: 'Minor', color: '#9ca3af' },
] as const
export default function PostCard({ export default function PostCard({
post, post,
onVote, onVote,
onUnvote,
onImportance,
showImportancePopup,
budgetDepleted,
customStatuses,
index = 0,
}: { }: {
post: Post post: Post
onVote?: (id: string) => void onVote?: (id: string) => void
onUnvote?: (id: string) => void
onImportance?: (id: string, importance: string) => void
showImportancePopup?: boolean
budgetDepleted?: boolean
customStatuses?: StatusConfig[]
index?: number
}) { }) {
const [voteAnimating, setVoteAnimating] = useState(false)
const [popupVisible, setPopupVisible] = useState(false)
const popupTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const cardRef = useRef<HTMLDivElement>(null)
const timeAgo = formatTimeAgo(post.createdAt) const timeAgo = formatTimeAgo(post.createdAt)
const typeLabel = post.type === 'BUG_REPORT' ? 'Bug' : 'Feature'
useEffect(() => {
if (showImportancePopup) {
setPopupVisible(true)
popupTimer.current = setTimeout(() => setPopupVisible(false), 5000)
return () => {
if (popupTimer.current) clearTimeout(popupTimer.current)
}
} else {
setPopupVisible(false)
}
}, [showImportancePopup])
const pickImportance = (val: string) => {
if (popupTimer.current) clearTimeout(popupTimer.current)
setPopupVisible(false)
onImportance?.(post.id, val)
}
const handleVoteClick = (e: React.MouseEvent) => {
e.preventDefault()
if (post.voted) {
onUnvote?.(post.id)
} else {
setVoteAnimating(true)
setTimeout(() => setVoteAnimating(false), 400)
onVote?.(post.id)
}
}
return ( return (
<div <div
className="card flex gap-0 overflow-hidden" ref={cardRef}
style={{ transition: 'border-color 200ms ease-out' }} className="card card-interactive flex gap-0 overflow-hidden stagger-in"
style={{ '--stagger': index, position: 'relative' } as React.CSSProperties}
> >
{/* Vote column */} {/* Vote column - desktop */}
<button <button
onClick={(e) => { e.preventDefault(); onVote?.(post.id) }} className="hidden md:flex flex-col items-center justify-center shrink-0 gap-1 action-btn"
className="flex flex-col items-center justify-center px-3 py-4 shrink-0 gap-1" onClick={handleVoteClick}
style={{ style={{
width: 48, width: 56,
background: post.hasVoted ? 'var(--accent-subtle)' : 'transparent', padding: '20px 12px',
color: post.hasVoted ? 'var(--accent)' : 'var(--text-tertiary)', background: post.voted ? 'var(--accent-subtle)' : 'transparent',
transition: 'all 200ms ease-out',
borderRight: '1px solid var(--border)', borderRight: '1px solid var(--border)',
border: 'none',
borderRadius: 0,
cursor: 'pointer',
transition: 'background var(--duration-normal) ease-out',
}}
title={post.voted ? 'Click to remove your vote' : 'Click to vote'}
aria-label="Vote"
>
<IconChevronUp
size={18}
stroke={2.5}
className={voteAnimating ? 'vote-bounce' : ''}
style={{
color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'color var(--duration-fast) ease-out',
}}
/>
<span
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
style={{
color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)',
fontSize: 'var(--text-sm)',
}}
aria-live="polite"
aria-atomic="true"
>
{post.voteCount}
</span>
{budgetDepleted && !post.voted && (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', textAlign: 'center', lineHeight: 1.2, marginTop: 2 }}>
0 left
</span>
)}
</button>
{/* Mobile vote strip */}
<div
className="flex md:hidden items-center gap-2 px-4 py-2"
style={{
background: post.voted ? 'var(--accent-subtle)' : 'transparent',
borderBottom: '1px solid var(--border)',
}} }}
> >
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <button
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" /> onClick={handleVoteClick}
</svg> className="flex items-center gap-2"
<span className="text-xs font-semibold">{post.voteCount}</span> style={{ color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)', cursor: 'pointer', background: 'none', border: 'none' }}
</button> aria-label="Vote"
>
<IconChevronUp size={14} stroke={2.5} className={voteAnimating ? 'vote-bounce' : ''} />
<span className="font-semibold" style={{ fontSize: 'var(--text-xs)' }} aria-live="polite" aria-atomic="true">{post.voteCount}</span>
</button>
<StatusBadge status={post.status} customStatuses={customStatuses} />
<div className="flex items-center gap-1 ml-auto" style={{ color: 'var(--text-tertiary)' }}>
<IconMessageCircle size={12} stroke={2} />
<span style={{ fontSize: 'var(--text-xs)' }}>{post.commentCount}</span>
</div>
</div>
{/* Content zone */} {/* Content zone */}
<Link <Link
to={`/b/${post.boardSlug}/post/${post.id}`} to={`/b/${post.boardSlug}/post/${post.id}`}
className="flex-1 py-3 px-4 min-w-0" className="flex-1 min-w-0"
style={{ padding: '16px 20px' }}
> >
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<span <span
className="text-xs px-1.5 py-0.5 rounded" className="inline-flex items-center gap-1 px-2 py-0.5"
style={{ style={{
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)', fontSize: 'var(--text-xs)',
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)', borderRadius: 'var(--radius-sm)',
background: post.type === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
color: post.type === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
}} }}
> >
{post.type} {post.type === 'BUG_REPORT' ? <IconBug size={11} stroke={2} aria-hidden="true" /> : <IconBulb size={11} stroke={2} aria-hidden="true" />}
{typeLabel}
</span> </span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}> {post.category && (
{post.authorName} - {timeAgo} <span
className="px-2 py-0.5"
style={{
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface-hover)',
color: 'var(--text-secondary)',
}}
>
{post.category}
</span>
)}
{post.isPinned && (
<IconPin size={12} stroke={2} style={{ color: 'var(--accent)' }} />
)}
{post.isStale && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5"
style={{
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
background: 'rgba(245, 158, 11, 0.1)',
color: 'rgb(245, 158, 11)',
}}
title="No recent activity"
>
<IconClock size={10} stroke={2} aria-hidden="true" />
Stale
<span className="sr-only"> - no recent activity</span>
</span>
)}
<span className="inline-flex items-center gap-1.5" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
<Avatar
userId={post.author?.id ?? '0000'}
name={post.onBehalfOf ?? post.author?.displayName ?? null}
avatarUrl={post.author?.avatarUrl}
size={18}
/>
{post.onBehalfOf
? <>on behalf of {post.onBehalfOf}</>
: (post.author?.displayName ?? `Anonymous #${(post.author?.id ?? '0000').slice(-4)}`)
} - <time dateTime={post.createdAt}>{timeAgo}</time>
</span> </span>
</div> </div>
<h3 <h2
className="text-sm font-medium mb-1 truncate" className="font-medium mb-1 truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }} style={{
fontFamily: 'var(--font-heading)',
color: 'var(--text)',
fontSize: 'var(--text-lg)',
}}
> >
{post.title} {post.title}
</h3> </h2>
{post.excerpt && ( {post.statusReason && (
<p <p
className="text-xs line-clamp-2" className="truncate mb-1"
style={{ color: 'var(--text-secondary)' }} style={{
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
}}
> >
{post.excerpt} Reason: {post.statusReason}
</p>
)}
{post.description && (
<p
className="line-clamp-2"
style={{
color: 'var(--text-secondary)',
lineHeight: 1.6,
fontSize: 'var(--text-sm)',
}}
>
{descriptionExcerpt(post.description)}
</p> </p>
)} )}
</Link> </Link>
{/* Status + comments */} {/* Status + comments - desktop */}
<div className="flex flex-col items-end justify-center px-4 py-3 shrink-0 gap-2"> <div className="hidden md:flex flex-col items-end justify-center px-5 py-4 shrink-0 gap-2">
<StatusBadge status={post.status} /> <StatusBadge status={post.status} customStatuses={customStatuses} />
<div className="flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}> <div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)' }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <IconMessageCircle size={14} stroke={2} aria-hidden="true" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /> <span style={{ fontSize: 'var(--text-xs)' }}>{post.commentCount}</span>
</svg> <span className="sr-only">comments</span>
<span className="text-xs">{post.commentCount}</span> </div>
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)' }}>
<IconEye size={14} stroke={2} aria-hidden="true" />
<span style={{ fontSize: 'var(--text-xs)' }}>{post.viewCount ?? 0}</span>
<span className="sr-only">views</span>
</div> </div>
</div> </div>
{/* Importance popup */}
{popupVisible && (
<div
role="menu"
aria-label="Rate importance"
onKeyDown={(e) => { if (e.key === 'Escape') setPopupVisible(false) }}
style={{
position: 'absolute',
left: 4,
bottom: -4,
transform: 'translateY(100%)',
zIndex: 50,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
padding: '8px 10px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
display: 'flex',
flexDirection: 'column',
gap: 2,
minWidth: 150,
}}
onClick={(e) => e.preventDefault()}
>
<span
style={{
fontSize: 'var(--text-xs)',
color: 'var(--text-tertiary)',
marginBottom: 4,
fontWeight: 500,
}}
>
How important is this?
</span>
{IMPORTANCE_OPTIONS.map((opt) => (
<button
key={opt.value}
role="menuitem"
onClick={(e) => { e.preventDefault(); pickImportance(opt.value) }}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
minHeight: 44,
borderRadius: 'var(--radius-sm)',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: 'var(--text-xs)',
color: 'var(--text-secondary)',
textAlign: 'left',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--surface-hover)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
onFocus={(e) => { e.currentTarget.style.background = 'var(--surface-hover)' }}
onBlur={(e) => { e.currentTarget.style.background = 'transparent' }}
>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: opt.color,
flexShrink: 0,
}}
/>
{opt.label}
</button>
))}
</div>
)}
</div> </div>
) )
} }
export { IMPORTANCE_OPTIONS }
function formatTimeAgo(date: string): string { function formatTimeAgo(date: string): string {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000) const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
if (seconds < 60) return 'just now' if (seconds < 60) return 'just now'

View File

@@ -1,174 +1,495 @@
import { useState, useRef } from 'react' import { useState, useEffect } from 'react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useAuth } from '../hooks/useAuth'
import { solveAltcha } from '../lib/altcha'
import { IconFingerprint } from '@tabler/icons-react'
import Dropdown from './Dropdown'
import MarkdownEditor from './MarkdownEditor'
import FileUpload from './FileUpload'
interface SimilarPost {
id: string
title: string
status: string
voteCount: number
similarity: number
}
interface TemplateField {
key: string
label: string
type: 'text' | 'textarea' | 'select'
required: boolean
placeholder?: string
options?: string[]
}
interface Template {
id: string
name: string
fields: TemplateField[]
isDefault: boolean
}
interface Props { interface Props {
boardSlug: string boardSlug: string
boardId?: string
onSubmit?: () => void onSubmit?: () => void
onCancel?: () => void
} }
type PostType = 'feature' | 'bug' | 'general' type PostType = 'FEATURE_REQUEST' | 'BUG_REPORT'
export default function PostForm({ boardSlug, onSubmit }: Props) { interface FieldErrors {
const [expanded, setExpanded] = useState(false) [key: string]: string
const [type, setType] = useState<PostType>('feature') }
export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Props) {
const auth = useAuth()
const [type, setType] = useState<PostType>('FEATURE_REQUEST')
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [body, setBody] = useState('') const [category, setCategory] = useState('')
const [expected, setExpected] = useState('')
const [actual, setActual] = useState('')
const [steps, setSteps] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const formRef = useRef<HTMLDivElement>(null) const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
const [similar, setSimilar] = useState<SimilarPost[]>([])
// templates
const [templates, setTemplates] = useState<Template[]>([])
const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [templateValues, setTemplateValues] = useState<Record<string, string>>({})
useEffect(() => {
api.get<{ id: string; name: string; slug: string }[]>('/categories')
.then(setCategories)
.catch(() => {})
}, [])
// fetch templates for this board
useEffect(() => {
if (!boardSlug) return
api.get<{ templates: Template[] }>(`/boards/${boardSlug}/templates`)
.then((r) => {
setTemplates(r.templates)
const def = r.templates.find((t) => t.isDefault)
if (def) setSelectedTemplateId(def.id)
})
.catch(() => {})
}, [boardSlug])
// debounced duplicate detection
useEffect(() => {
if (!boardId || title.trim().length < 5) { setSimilar([]); return }
const t = setTimeout(() => {
api.get<{ posts: SimilarPost[] }>(`/similar?title=${encodeURIComponent(title)}&boardId=${encodeURIComponent(boardId)}`)
.then((r) => setSimilar(r.posts))
.catch(() => setSimilar([]))
}, 400)
return () => clearTimeout(t)
}, [title, boardId])
// bug report fields
const [steps, setSteps] = useState('')
const [expected, setExpected] = useState('')
const [actual, setActual] = useState('')
const [environment, setEnvironment] = useState('')
const [bugContext, setBugContext] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
// feature request fields
const [useCase, setUseCase] = useState('')
const [proposedSolution, setProposedSolution] = useState('')
const [alternatives, setAlternatives] = useState('')
const [featureContext, setFeatureContext] = useState('')
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) || null
const reset = () => { const reset = () => {
setTitle('') setTitle('')
setBody('') setCategory('')
setSteps('')
setExpected('') setExpected('')
setActual('') setActual('')
setSteps('') setEnvironment('')
setBugContext('')
setUseCase('')
setProposedSolution('')
setAlternatives('')
setFeatureContext('')
setAttachmentIds([])
setTemplateValues({})
setError('') setError('')
setExpanded(false) setFieldErrors({})
}
const validate = (): boolean => {
const errors: FieldErrors = {}
if (title.trim().length < 5) {
errors.title = 'Title must be at least 5 characters'
}
if (selectedTemplate) {
for (const f of selectedTemplate.fields) {
if (f.required && !templateValues[f.key]?.trim()) {
errors[f.key] = `${f.label} is required`
}
}
} else if (type === 'BUG_REPORT') {
if (!steps.trim()) errors.steps = 'Steps to reproduce are required'
if (!expected.trim()) errors.expected = 'Expected behavior is required'
if (!actual.trim()) errors.actual = 'Actual behavior is required'
} else {
if (!useCase.trim()) errors.useCase = 'Use case is required'
}
setFieldErrors(errors)
return Object.keys(errors).length === 0
} }
const submit = async () => { const submit = async () => {
if (!title.trim()) { if (!validate()) return
setError('Title is required')
return
}
setSubmitting(true) setSubmitting(true)
setError('') setError('')
const payload: Record<string, string> = { title, type, body } let altcha: string
if (type === 'bug') { try {
payload.stepsToReproduce = steps altcha = await solveAltcha()
payload.expected = expected } catch {
payload.actual = actual setError('Verification failed. Please try again.')
setSubmitting(false)
return
}
let description: Record<string, string>
if (selectedTemplate) {
description = {}
for (const f of selectedTemplate.fields) {
description[f.key] = templateValues[f.key] || ''
}
} else if (type === 'BUG_REPORT') {
description = {
stepsToReproduce: steps,
expectedBehavior: expected,
actualBehavior: actual,
environment: environment || '',
additionalContext: bugContext || '',
}
} else {
description = {
useCase,
proposedSolution: proposedSolution || '',
alternativesConsidered: alternatives || '',
additionalContext: featureContext || '',
}
} }
try { try {
await api.post(`/boards/${boardSlug}/posts`, payload) await api.post(`/boards/${boardSlug}/posts`, {
title,
type,
description,
category: category || undefined,
templateId: selectedTemplate ? selectedTemplate.id : undefined,
attachmentIds: attachmentIds.length ? attachmentIds : undefined,
altcha,
})
reset() reset()
onSubmit?.() onSubmit?.()
} catch (e) { } catch {
setError('Failed to submit. Please try again.') setError('Failed to submit. Please try again.')
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
} }
if (!expanded) { const fieldError = (key: string) =>
return ( fieldErrors[key] ? (
<button <span id={`err-${key}`} role="alert" className="mt-0.5 block" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{fieldErrors[key]}</span>
onClick={() => setExpanded(true)} ) : null
className="card w-full px-4 py-3 text-left flex items-center gap-3"
style={{ cursor: 'pointer' }} const label = (text: string, required?: boolean, htmlFor?: string) => (
> <label htmlFor={htmlFor} className="block font-medium mb-1 uppercase tracking-wider" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
<div {text}{required && <span style={{ color: 'var(--error)' }}> *</span>}
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" </label>
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }} )
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> const handleTemplateChange = (id: string) => {
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /> setSelectedTemplateId(id)
</svg> setTemplateValues({})
</div> setFieldErrors({})
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}> }
Share feedback...
</span> const renderTemplateFields = () => {
</button> if (!selectedTemplate) return null
)
return selectedTemplate.fields.map((f) => (
<div key={f.key} className="mb-3">
{label(f.label, f.required)}
{f.type === 'text' && (
<input
className="input w-full"
placeholder={f.placeholder || ''}
value={templateValues[f.key] || ''}
onChange={(e) => setTemplateValues((prev) => ({ ...prev, [f.key]: e.target.value }))}
aria-required={f.required || undefined}
aria-invalid={!!fieldErrors[f.key]}
aria-describedby={fieldErrors[f.key] ? `err-${f.key}` : undefined}
/>
)}
{f.type === 'textarea' && (
<MarkdownEditor
value={templateValues[f.key] || ''}
onChange={(val) => setTemplateValues((prev) => ({ ...prev, [f.key]: val }))}
placeholder={f.placeholder || ''}
rows={3}
ariaRequired={f.required}
ariaLabel={f.label}
/>
)}
{f.type === 'select' && f.options && (
<Dropdown
value={templateValues[f.key] || ''}
onChange={(val) => setTemplateValues((prev) => ({ ...prev, [f.key]: val }))}
placeholder={f.placeholder || 'Select...'}
options={[
{ value: '', label: f.placeholder || 'Select...' },
...f.options.map((o) => ({ value: o, label: o })),
]}
/>
)}
{fieldError(f.key)}
</div>
))
} }
return ( return (
<div ref={formRef} className="card p-4 slide-up"> <div
className="card card-static p-5"
style={{ animation: 'slideUp var(--duration-normal) var(--ease-out)' }}
>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}> <h2
New Post className="font-bold"
</h3> style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-base)' }}
<button onClick={reset} className="btn btn-ghost text-xs">Cancel</button> >
</div> Share feedback
</h2>
{/* Type selector */} {onCancel && (
<div className="flex gap-2 mb-4">
{(['feature', 'bug', 'general'] as PostType[]).map((t) => (
<button <button
key={t} onClick={() => { reset(); onCancel() }}
onClick={() => setType(t)} className="btn btn-ghost"
className="px-3 py-1.5 rounded-md text-xs font-medium capitalize" style={{ fontSize: 'var(--text-xs)' }}
style={{
background: type === t ? 'var(--accent-subtle)' : 'transparent',
color: type === t ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${type === t ? 'var(--accent)' : 'var(--border)'}`,
transition: 'all 200ms ease-out',
}}
> >
{t === 'feature' ? 'Feature Request' : t === 'bug' ? 'Bug Report' : 'General'} Cancel
</button> </button>
))} )}
</div> </div>
<input {/* Template selector */}
className="input mb-3" {templates.length > 0 && (
placeholder="Title" <div className="mb-4">
value={title} {label('Template')}
onChange={(e) => setTitle(e.target.value)} <div className="flex gap-2 flex-wrap">
/> <button
onClick={() => handleTemplateChange('')}
className="px-3 py-1.5 font-medium"
style={{
background: !selectedTemplateId ? 'var(--accent-subtle)' : 'transparent',
color: !selectedTemplateId ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${!selectedTemplateId ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
>
Default
</button>
{templates.map((t) => (
<button
key={t.id}
onClick={() => handleTemplateChange(t.id)}
className="px-3 py-1.5 font-medium"
style={{
background: selectedTemplateId === t.id ? 'var(--accent-subtle)' : 'transparent',
color: selectedTemplateId === t.id ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${selectedTemplateId === t.id ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
>
{t.name}
</button>
))}
</div>
</div>
)}
<textarea {/* Type selector - only when not using a template */}
className="input mb-3" {!selectedTemplate && (
placeholder={type === 'bug' ? 'Describe the bug...' : type === 'feature' ? 'Describe the feature...' : 'What is on your mind?'} <div className="flex gap-2 mb-4">
rows={3} {([
value={body} ['FEATURE_REQUEST', 'Feature Request'],
onChange={(e) => setBody(e.target.value)} ['BUG_REPORT', 'Bug Report'],
style={{ resize: 'vertical' }} ] as const).map(([value, lbl]) => (
/> <button
key={value}
onClick={() => setType(value)}
className="px-3 py-1.5 font-medium"
style={{
background: type === value ? 'var(--accent-subtle)' : 'transparent',
color: type === value ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${type === value ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
>
{lbl}
</button>
))}
</div>
)}
{type === 'bug' && ( {/* Title */}
<> <div className="mb-3">
<textarea {label('Title', true, 'post-title')}
className="input mb-3" <input
placeholder="Steps to reproduce" id="post-title"
rows={2} className="input w-full"
value={steps} placeholder="Brief summary"
onChange={(e) => setSteps(e.target.value)} value={title}
style={{ resize: 'vertical' }} onChange={(e) => setTitle(e.target.value)}
maxLength={200}
aria-required="true"
aria-invalid={!!fieldErrors.title}
aria-describedby={fieldErrors.title ? 'err-title' : undefined}
/>
{fieldError('title')}
{similar.length > 0 && (
<div
className="mt-2 rounded-lg overflow-hidden"
style={{ border: '1px solid var(--border-accent)', background: 'var(--surface)' }}
>
<div className="px-3 py-1.5" style={{ background: 'var(--accent-subtle)', color: 'var(--accent)', fontSize: 'var(--text-xs)', fontWeight: 500 }}>
Similar posts already exist - vote instead?
</div>
{similar.map((p) => (
<a
key={p.id}
href={`/b/${boardSlug}/post/${p.id}`}
className="flex items-center justify-between px-3 py-2"
style={{ borderTop: '1px solid var(--border)', fontSize: 'var(--text-xs)', color: 'var(--text)' }}
>
<span className="truncate mr-2">{p.title}</span>
<span className="shrink-0 flex items-center gap-2" style={{ color: 'var(--text-tertiary)' }}>
<span style={{ color: 'var(--accent)' }}>{p.voteCount} votes</span>
<span>{p.similarity}% match</span>
</span>
</a>
))}
</div>
)}
</div>
{/* Category */}
{categories.length > 0 && (
<div className="mb-3">
{label('Category')}
<Dropdown
value={category}
onChange={setCategory}
placeholder="No category"
options={[
{ value: '', label: 'No category' },
...categories.map((c) => ({ value: c.slug, label: c.name })),
]}
/> />
<div className="grid grid-cols-2 gap-3 mb-3"> </div>
<textarea )}
className="input"
placeholder="Expected behavior" {/* Template fields or default fields */}
rows={2} {selectedTemplate ? (
value={expected} renderTemplateFields()
onChange={(e) => setExpected(e.target.value)} ) : type === 'BUG_REPORT' ? (
style={{ resize: 'vertical' }} <>
<div className="mb-3">
{label('Steps to reproduce', true)}
<MarkdownEditor
value={steps}
onChange={setSteps}
placeholder="1. Go to... 2. Click on... 3. See error"
rows={3}
ariaRequired
ariaLabel="Steps to reproduce"
/> />
<textarea {fieldError('steps')}
className="input" </div>
placeholder="Actual behavior" <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
rows={2} <div>
value={actual} {label('Expected behavior', true)}
onChange={(e) => setActual(e.target.value)} <MarkdownEditor value={expected} onChange={setExpected} placeholder="What should happen?" rows={2} ariaRequired ariaLabel="Expected behavior" />
style={{ resize: 'vertical' }} {fieldError('expected')}
</div>
<div>
{label('Actual behavior', true)}
<MarkdownEditor value={actual} onChange={setActual} placeholder="What actually happens?" rows={2} ariaRequired ariaLabel="Actual behavior" />
{fieldError('actual')}
</div>
</div>
<div className="mb-3">
{label('Environment / version')}
<input
className="input w-full"
placeholder="OS, browser, app version..."
value={environment}
onChange={(e) => setEnvironment(e.target.value)}
/> />
</div> </div>
<div className="mb-3">
{label('Additional context')}
<MarkdownEditor value={bugContext} onChange={setBugContext} placeholder="Screenshots, logs, anything else..." rows={2} />
</div>
</>
) : (
<>
<div className="mb-3">
{label('Use case / problem statement', true)}
<MarkdownEditor value={useCase} onChange={setUseCase} placeholder="What are you trying to accomplish?" rows={3} ariaRequired ariaLabel="Use case" />
{fieldError('useCase')}
</div>
<div className="mb-3">
{label('Proposed solution')}
<MarkdownEditor value={proposedSolution} onChange={setProposedSolution} placeholder="How do you think this should work?" rows={2} />
</div>
<div className="mb-3">
{label('Alternatives considered')}
<MarkdownEditor value={alternatives} onChange={setAlternatives} placeholder="Have you tried any workarounds?" rows={2} />
</div>
<div className="mb-3">
{label('Additional context')}
<MarkdownEditor value={featureContext} onChange={setFeatureContext} placeholder="Anything else to add?" rows={2} />
</div>
</> </>
)} )}
{/* ALTCHA widget placeholder */} <div className="mb-3">
<div {label('Attachments')}
className="mb-4 p-3 rounded-lg text-xs flex items-center gap-2" <FileUpload attachmentIds={attachmentIds} onChange={setAttachmentIds} />
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
ALTCHA verification
</div> </div>
{error && ( {error && (
<div className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div> <div role="alert" className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
)} )}
<div className="flex justify-end"> <div className="flex items-center justify-between">
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Your ability to edit this post depends on your browser cookie.
</span>
<button <button
onClick={submit} onClick={submit}
disabled={submitting} disabled={submitting}
@@ -178,6 +499,18 @@ export default function PostForm({ boardSlug, onSubmit }: Props) {
{submitting ? 'Submitting...' : 'Submit'} {submitting ? 'Submitting...' : 'Submit'}
</button> </button>
</div> </div>
{!auth.isPasskeyUser && (
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--border)' }}>
<a
href="/settings"
className="flex items-center gap-2"
style={{ color: 'var(--accent)', fontSize: 'var(--text-xs)' }}
>
<IconFingerprint size={14} stroke={2} />
Save my identity to keep your posts across devices
</a>
</div>
)}
</div> </div>
) )
} }

View File

@@ -0,0 +1,191 @@
import { useState } from 'react'
import { IconShieldCheck, IconCopy, IconCheck, IconRefresh } from '@tabler/icons-react'
import { api } from '../lib/api'
import { useAuth } from '../hooks/useAuth'
import { useFocusTrap } from '../hooks/useFocusTrap'
interface Props {
open: boolean
onClose: () => void
}
export default function RecoveryCodeModal({ open, onClose }: Props) {
const auth = useAuth()
const trapRef = useFocusTrap(open)
const [phrase, setPhrase] = useState<string | null>(null)
const [expiresAt, setExpiresAt] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [copied, setCopied] = useState<'phrase' | 'link' | null>(null)
const [error, setError] = useState('')
const hasExisting = auth.user?.hasRecoveryCode
const generate = async () => {
setGenerating(true)
setError('')
try {
const res = await api.post<{ phrase: string; expiresAt: string }>('/me/recovery-code', {})
setPhrase(res.phrase)
setExpiresAt(res.expiresAt)
auth.refresh()
} catch {
setError('Failed to generate recovery code')
} finally {
setGenerating(false)
}
}
const copyPhrase = () => {
if (!phrase) return
navigator.clipboard.writeText(phrase)
setCopied('phrase')
setTimeout(() => setCopied(null), 2000)
}
const copyLink = () => {
if (!phrase) return
navigator.clipboard.writeText(`${window.location.origin}/recover#${phrase}`)
setCopied('link')
setTimeout(() => setCopied(null), 2000)
}
const handleClose = () => {
setPhrase(null)
setExpiresAt(null)
setError('')
setCopied(null)
onClose()
}
if (!open) return null
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div
className="absolute inset-0 fade-in"
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}
onClick={handleClose}
/>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="recovery-modal-title"
className="relative w-full max-w-md mx-4 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
padding: '28px',
maxHeight: '85vh',
overflowY: 'auto',
}}
onKeyDown={(e) => e.key === 'Escape' && handleClose()}
>
<div
className="w-10 h-10 flex items-center justify-center mb-4"
style={{
background: 'var(--accent-subtle)',
borderRadius: 'var(--radius-md)',
}}
>
<IconShieldCheck size={20} stroke={2} style={{ color: 'var(--accent)' }} />
</div>
<h2
id="recovery-modal-title"
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-lg)', color: 'var(--text)' }}
>
Recovery Code
</h2>
{phrase ? (
<>
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Save this phrase somewhere safe. It will only be shown once. If you lose access to your cookies, enter this phrase to recover your identity.
</p>
<div
className="p-4 mb-3"
style={{
background: 'var(--bg)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
}}
>
<div
className="font-mono font-semibold text-center mb-3"
style={{
color: 'var(--accent)',
fontSize: 'var(--text-lg)',
wordBreak: 'break-all',
letterSpacing: '0.02em',
lineHeight: 1.6,
}}
>
{phrase}
</div>
<div className="flex gap-2">
<button onClick={copyPhrase} className="btn btn-secondary flex-1 flex items-center justify-center gap-2" style={{ fontSize: 'var(--text-xs)' }}>
{copied === 'phrase' ? <IconCheck size={14} stroke={2} /> : <IconCopy size={14} stroke={2} />}
{copied === 'phrase' ? 'Copied' : 'Copy phrase'}
</button>
<button onClick={copyLink} className="btn btn-secondary flex-1 flex items-center justify-center gap-2" style={{ fontSize: 'var(--text-xs)' }}>
{copied === 'link' ? <IconCheck size={14} stroke={2} /> : <IconCopy size={14} stroke={2} />}
{copied === 'link' ? 'Copied' : 'Copy link'}
</button>
</div>
</div>
<div
className="p-3 mb-4"
style={{
background: 'rgba(234, 179, 8, 0.08)',
border: '1px solid rgba(234, 179, 8, 0.25)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
color: 'var(--text-secondary)',
lineHeight: 1.5,
}}
>
Expires on <strong style={{ color: 'var(--warning)' }}>{new Date(expiresAt!).toLocaleDateString()}</strong>.
The code can only be used once - after recovery you'll need to generate a new one.
</div>
<button onClick={handleClose} className="btn btn-primary w-full">
I've saved it
</button>
</>
) : (
<>
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
{hasExisting
? `You already have a recovery code (expires ${new Date(auth.user!.recoveryCodeExpiresAt!).toLocaleDateString()}). Generating a new one will replace it.`
: 'Generate a 6-word phrase you can use to recover your identity if your cookies get cleared. Save it somewhere safe - a notes app, a password manager, or even a piece of paper.'}
</p>
{error && (
<p role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{error}</p>
)}
<div className="flex gap-2">
<button
onClick={generate}
disabled={generating}
className="btn btn-primary flex-1 flex items-center justify-center gap-2"
>
{hasExisting ? <IconRefresh size={16} stroke={2} /> : <IconShieldCheck size={16} stroke={2} />}
{generating ? 'Generating...' : hasExisting ? 'Generate new code' : 'Generate recovery code'}
</button>
<button onClick={handleClose} className="btn btn-secondary">
Cancel
</button>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -1,246 +1,668 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { Link, useLocation, useParams } from 'react-router-dom' import { Link, useLocation, useParams } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth' import { useAuth } from '../hooks/useAuth'
import { useAdmin } from '../hooks/useAdmin'
import { useTheme } from '../hooks/useTheme'
import { useBranding } from '../hooks/useBranding'
import { useTranslation } from '../i18n'
import { api } from '../lib/api' import { api } from '../lib/api'
import {
IconHome, IconSearch, IconBell, IconActivity, IconFileText, IconSettings, IconUser,
IconFingerprint, IconCookie, IconChevronLeft, IconChevronRight, IconChevronDown,
IconSun, IconMoon, IconShieldLock, IconShieldCheck, IconLayoutKanban, IconNews,
} from '@tabler/icons-react'
import BoardIcon from './BoardIcon'
import Avatar from './Avatar'
interface Board { interface Board {
id: string id: string
slug: string slug: string
name: string name: string
description: string description: string
iconName: string | null
iconColor: string | null
postCount: number postCount: number
} }
interface Notification {
id: string
type: string
title: string
body: string
postId: string | null
post?: { id: string; board: { slug: string } } | null
read: boolean
createdAt: string
}
function timeAgo(date: string): string {
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
if (s < 60) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
if (d < 30) return `${d}d ago`
return `${Math.floor(d / 30)}mo ago`
}
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false
)
useEffect(() => {
const mq = window.matchMedia(query)
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [query])
return matches
}
export default function Sidebar() { export default function Sidebar() {
const { boardSlug } = useParams() const { boardSlug } = useParams()
const location = useLocation() const location = useLocation()
const auth = useAuth() const auth = useAuth()
const admin = useAdmin()
const { resolved, toggle: toggleTheme } = useTheme()
const { appName, logoUrl } = useBranding()
const { t } = useTranslation()
const isMobile = useMediaQuery('(max-width: 767px)')
const isLarge = useMediaQuery('(min-width: 1024px)')
const [collapsed, setCollapsed] = useState(() => {
if (typeof window === 'undefined') return false
const stored = localStorage.getItem('echoboard_sidebar')
return stored === 'collapsed'
})
const userToggled = useRef(!!localStorage.getItem('echoboard_sidebar'))
const [boards, setBoards] = useState<Board[]>([]) const [boards, setBoards] = useState<Board[]>([])
const [collapsed, setCollapsed] = useState(false) const [bellOpen, setBellOpen] = useState(false)
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
useEffect(() => { useEffect(() => {
api.get<Board[]>('/boards').then(setBoards).catch(() => {}) api.get<Board[]>('/boards').then(setBoards).catch(() => {})
}, []) }, [])
const isActive = (path: string) => location.pathname === path // poll unread notification count
const isBoardActive = (slug: string) => boardSlug === slug useEffect(() => {
const fetch = () => {
api.get<{ notifications: Notification[]; unread: number }>('/notifications')
.then((r) => {
setNotifications(r.notifications.slice(0, 8))
setUnreadCount(r.unread)
})
.catch(() => {})
}
fetch()
const iv = setInterval(fetch, 30000)
return () => clearInterval(iv)
}, [])
if (collapsed) { // Default collapsed on tablet, expanded on desktop - only if user hasn't toggled
return ( useEffect(() => {
<aside if (userToggled.current) return
className="hidden md:flex lg:hidden flex-col items-center py-4 gap-2 border-r" setCollapsed(!isLarge)
style={{ }, [isLarge])
width: 64,
background: 'var(--surface)',
borderColor: 'var(--border)',
}}
>
<button
onClick={() => setCollapsed(false)}
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
style={{ color: 'var(--accent)' }}
>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<Link const toggleCollapse = () => {
to="/" userToggled.current = true
className="w-10 h-10 rounded-lg flex items-center justify-center" setCollapsed((c) => {
style={{ const next = !c
background: isActive('/') ? 'var(--accent-subtle)' : 'transparent', localStorage.setItem('echoboard_sidebar', next ? 'collapsed' : 'expanded')
color: isActive('/') ? 'var(--accent)' : 'var(--text-secondary)', return next
}} })
>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
</svg>
</Link>
{boards.map((b) => (
<Link
key={b.id}
to={`/b/${b.slug}`}
className="w-10 h-10 rounded-lg flex items-center justify-center text-xs font-semibold"
style={{
fontFamily: 'var(--font-heading)',
background: isBoardActive(b.slug) ? 'var(--accent-subtle)' : 'transparent',
color: isBoardActive(b.slug) ? 'var(--accent)' : 'var(--text-secondary)',
}}
>
{b.name.charAt(0).toUpperCase()}
</Link>
))}
<div className="mt-auto">
<Link
to="/activity"
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ color: isActive('/activity') ? 'var(--accent)' : 'var(--text-secondary)' }}
>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</Link>
</div>
</aside>
)
} }
const isActive = (path: string) => location.pathname === path
const isBoardActive = (slug: string) => boardSlug === slug || location.pathname.startsWith(`/b/${slug}`)
if (isMobile) return null
const width = collapsed ? 64 : 300
const isDark = resolved === 'dark'
const navItem = (to: string, icon: React.ReactNode, label: string, active: boolean) => (
<Link
to={to}
className="flex items-center gap-3 rounded-lg nav-link"
style={{
padding: collapsed ? '10px' : '8px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
background: active ? 'var(--accent-subtle)' : 'transparent',
color: active ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 'var(--text-sm)',
transition: 'all var(--duration-fast) ease-out',
borderRadius: 'var(--radius-md)',
}}
title={collapsed ? label : undefined}
aria-current={active ? 'page' : undefined}
>
{icon}
{!collapsed && <span style={{ fontWeight: active ? 600 : 400 }}>{label}</span>}
</Link>
)
return ( return (
<aside <aside
className="hidden lg:flex flex-col border-r h-screen sticky top-0" aria-label="Main navigation"
className="relative flex flex-col border-r h-screen sticky top-0"
style={{ style={{
width: 280, width,
minWidth: width,
background: 'var(--surface)', background: 'var(--surface)',
borderColor: 'var(--border)', borderColor: 'var(--border)',
transition: 'width var(--duration-normal) var(--ease-out), min-width var(--duration-normal) var(--ease-out)',
overflow: 'visible',
zIndex: 20,
}} }}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'var(--border)' }}> <div
<Link className="flex items-center border-b"
to="/" style={{
className="text-xl font-bold" borderColor: 'var(--border)',
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }} padding: collapsed ? '12px 12px' : '12px 16px',
> gap: 8,
Echoboard minHeight: 56,
</Link> }}
<button >
className="w-8 h-8 rounded-lg flex items-center justify-center" {!collapsed && (
style={{ color: 'var(--text-secondary)' }}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
</div>
{/* Board list */}
<nav className="flex-1 overflow-y-auto py-3 px-3">
<Link
to="/"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-1"
style={{
background: isActive('/') ? 'var(--accent-subtle)' : 'transparent',
color: isActive('/') ? 'var(--accent)' : 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
</svg>
Home
</Link>
<div className="mt-4 mb-2 px-3">
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
Boards
</span>
</div>
{boards.map((b) => (
<Link <Link
key={b.id} to="/"
to={`/b/${b.slug}`} className="font-bold truncate flex items-center gap-2"
className="flex items-center justify-between px-3 py-2 rounded-lg text-sm mb-0.5"
style={{ style={{
background: isBoardActive(b.slug) ? 'var(--accent-subtle)' : 'transparent', fontFamily: 'var(--font-heading)',
color: isBoardActive(b.slug) ? 'var(--accent)' : 'var(--text-secondary)', color: 'var(--text)',
transition: 'all 200ms ease-out', fontSize: 'var(--text-lg)',
flex: 1,
minWidth: 0,
}} }}
> >
<span>{b.name}</span> {logoUrl ? (
<span <img src={logoUrl} alt={appName} style={{ height: 24, objectFit: 'contain' }} />
className="text-xs px-1.5 py-0.5 rounded" ) : (
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }} appName
> )}
{b.postCount}
</span>
</Link> </Link>
))} )}
{collapsed && (
<div className="mt-6 mb-2 px-3"> <Link
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}> to="/"
You className="font-bold flex items-center justify-center"
</span> style={{
</div> fontFamily: 'var(--font-heading)',
color: 'var(--accent)',
<Link fontSize: 'var(--text-lg)',
to="/activity" flex: 1,
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5" textAlign: 'center',
style={{ }}
background: isActive('/activity') ? 'var(--accent-subtle)' : 'transparent',
color: isActive('/activity') ? 'var(--accent)' : 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Activity
</Link>
<Link
to="/my-posts"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
style={{
background: isActive('/my-posts') ? 'var(--accent-subtle)' : 'transparent',
color: isActive('/my-posts') ? 'var(--accent)' : 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
My Posts
</Link>
<Link
to="/settings"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
style={{
background: isActive('/settings') ? 'var(--accent-subtle)' : 'transparent',
color: isActive('/settings') ? 'var(--accent)' : 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</Link>
</nav>
{/* Identity footer */}
<div className="px-4 py-3 border-t" style={{ borderColor: 'var(--border)' }}>
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
> >
{auth.displayName.charAt(0).toUpperCase()} {logoUrl ? (
</div> <img src={logoUrl} alt={appName} style={{ height: 20, objectFit: 'contain' }} />
<div className="flex-1 min-w-0"> ) : (
<div className="text-sm truncate" style={{ color: 'var(--text)' }}> appName.charAt(0)
{auth.displayName} )}
</div> </Link>
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}> )}
{auth.isPasskeyUser ? 'Passkey user' : 'Cookie identity'}
{!collapsed && (
<div className="flex items-center gap-1">
<button
onClick={toggleTheme}
className="w-11 h-11 rounded-lg flex items-center justify-center action-btn"
style={{
color: 'var(--text-secondary)',
transition: 'color var(--duration-fast) ease-out',
borderRadius: 'var(--radius-sm)',
}}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Light mode' : 'Dark mode'}
>
{isDark ? <IconSun size={16} stroke={2} /> : <IconMoon size={16} stroke={2} />}
</button>
<div className="relative">
<button
onClick={() => setBellOpen(!bellOpen)}
className="w-11 h-11 rounded-lg flex items-center justify-center relative action-btn"
style={{
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
}}
aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'}
aria-expanded={bellOpen}
aria-haspopup="true"
>
<IconBell size={16} stroke={2} />
{unreadCount > 0 && (
<span
className="absolute flex items-center justify-center"
style={{
top: 2, right: 0,
minWidth: 14, height: 14,
borderRadius: 7,
background: 'var(--accent)',
color: 'var(--bg)',
fontSize: 'var(--text-xs)',
fontWeight: 700,
padding: '0 3px',
lineHeight: 1,
}}
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{bellOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setBellOpen(false)} />
<div
role="region"
aria-label="Notifications"
className="absolute left-0 top-12 w-80 z-50 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-lg)',
}}
>
<div
className="flex items-center justify-between"
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
}}
>
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-xs)' }}>
{t('notifications')}
</span>
{unreadCount > 0 && (
<button
className="action-btn"
style={{ color: 'var(--accent)', fontSize: 'var(--text-xs)', padding: '4px 8px', borderRadius: 'var(--radius-sm)' }}
onClick={() => {
api.put('/notifications/read', {}).then(() => {
setUnreadCount(0)
setNotifications((n) => n.map((x) => ({ ...x, read: true })))
}).catch(() => {})
}}
>
{t('markAllRead')}
</button>
)}
</div>
<div className="max-h-72 overflow-y-auto" aria-live="polite">
{notifications.length === 0 ? (
<div className="p-4 text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{t('noNotifications')}
</div>
) : (
notifications.map((n) => (
<Link
key={n.id}
to={n.post ? `/b/${n.post.board.slug}/post/${n.post.id}` : '/activity'}
className="block nav-link"
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
borderLeft: n.read ? '3px solid transparent' : '3px solid var(--accent)',
background: n.read ? 'transparent' : 'var(--accent-subtle)',
}}
onClick={() => setBellOpen(false)}
>
<div className="truncate" style={{ color: 'var(--text)', fontSize: 'var(--text-xs)', fontWeight: n.read ? 400 : 600 }}>
{n.title}
</div>
<div className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
{n.body}
</div>
<time dateTime={n.createdAt} style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2, display: 'block' }}>
{timeAgo(n.createdAt)}
</time>
</Link>
))
)}
</div>
<Link
to="/activity"
className="block text-center border-t nav-link"
style={{
borderColor: 'var(--border)',
color: 'var(--accent)',
fontSize: 'var(--text-xs)',
padding: '8px',
}}
onClick={() => setBellOpen(false)}
>
{t('viewAllActivity')}
</Link>
</div>
</>
)}
</div> </div>
</div> </div>
</div> )}
{!auth.isPasskeyUser && ( </div>
{/* Search */}
<div style={{ padding: collapsed ? '8px 10px' : '8px 12px' }}>
<Link
to="/search"
className="flex items-center gap-2"
style={{
padding: collapsed ? '8px' : '8px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: 'var(--text-tertiary)',
fontSize: 'var(--text-sm)',
transition: 'border-color var(--duration-fast) ease-out',
}}
title={collapsed ? 'Search' : undefined}
>
<IconSearch size={15} stroke={2} />
{!collapsed && <span>{t('searchPlaceholder')}</span>}
</Link>
</div>
{/* Navigation */}
<div className="flex-1 flex flex-col overflow-hidden" style={{ padding: collapsed ? '4px 8px 0' : '4px 12px 0' }}>
{navItem('/', <IconHome size={18} stroke={2} aria-hidden="true" />, t('home'), isActive('/'))}
{navItem('/activity', <IconActivity size={18} stroke={2} aria-hidden="true" />, t('activity'), isActive('/activity'))}
{!collapsed && (
<div style={{ padding: '16px 12px 6px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em' }}>
{t('boards')}
</div>
)}
{collapsed && <div style={{ height: 12 }} />}
<nav className="flex-1 overflow-y-auto" style={{ paddingBottom: 4 }}>
{boards.map((b) => {
const active = isBoardActive(b.slug)
const expanded = active && !collapsed
const isBoardSubpage = active && (
location.pathname === `/b/${b.slug}/roadmap` ||
location.pathname === `/b/${b.slug}/changelog`
)
return (
<div key={b.id} style={{ marginBottom: 2 }}>
<Link
to={`/b/${b.slug}`}
className="flex items-center justify-between rounded-lg relative nav-link"
style={{
padding: collapsed ? '10px' : '8px 12px',
justifyContent: collapsed ? 'center' : 'space-between',
background: active ? 'var(--accent-subtle)' : 'transparent',
color: active ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 'var(--text-sm)',
fontWeight: active ? 600 : 400,
transition: 'all var(--duration-fast) ease-out',
borderRadius: 'var(--radius-md)',
}}
title={collapsed ? `${b.name} (${b.postCount})` : undefined}
>
{active && (
<span
className="absolute left-0 top-2 bottom-2 rounded-r"
style={{ width: 3, background: 'var(--accent)' }}
/>
)}
{collapsed ? (
<BoardIcon name={b.name} iconName={b.iconName} iconColor={active ? undefined : b.iconColor} size={24} />
) : (
<>
<span className="flex items-center gap-2 truncate">
<BoardIcon name={b.name} iconName={b.iconName} iconColor={active ? undefined : b.iconColor} size={20} />
<span className="truncate">{b.name}</span>
</span>
<span className="flex items-center gap-1">
<span
className="shrink-0"
style={{
padding: '1px 8px',
background: 'var(--surface-hover)',
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
aria-label={`${b.postCount} posts`}
>
{b.postCount}
</span>
{active && (
<IconChevronDown
size={12}
stroke={2}
style={{
color: 'var(--text-tertiary)',
flexShrink: 0,
}}
aria-hidden="true"
/>
)}
</span>
</>
)}
</Link>
{expanded && (
<div style={{ paddingLeft: 32, paddingTop: 2 }}>
<Link
to={`/b/${b.slug}`}
className="flex items-center gap-2 rounded-md nav-link"
style={{
padding: '5px 10px',
fontSize: 'var(--text-xs)',
color: !isBoardSubpage ? 'var(--accent)' : 'var(--text-tertiary)',
fontWeight: !isBoardSubpage ? 500 : 400,
transition: 'color var(--duration-fast) ease-out',
}}
>
<IconFileText size={13} stroke={2} aria-hidden="true" />
Posts
</Link>
<Link
to={`/b/${b.slug}/roadmap`}
className="flex items-center gap-2 rounded-md nav-link"
style={{
padding: '5px 10px',
fontSize: 'var(--text-xs)',
color: location.pathname === `/b/${b.slug}/roadmap` ? 'var(--accent)' : 'var(--text-tertiary)',
fontWeight: location.pathname === `/b/${b.slug}/roadmap` ? 500 : 400,
transition: 'color var(--duration-fast) ease-out',
}}
>
<IconLayoutKanban size={13} stroke={2} aria-hidden="true" />
{t('roadmap')}
</Link>
<Link
to={`/b/${b.slug}/changelog`}
className="flex items-center gap-2 rounded-md nav-link"
style={{
padding: '5px 10px',
fontSize: 'var(--text-xs)',
color: location.pathname === `/b/${b.slug}/changelog` ? 'var(--accent)' : 'var(--text-tertiary)',
fontWeight: location.pathname === `/b/${b.slug}/changelog` ? 500 : 400,
transition: 'color var(--duration-fast) ease-out',
}}
>
<IconNews size={13} stroke={2} aria-hidden="true" />
{t('changelog')}
</Link>
</div>
)}
</div>
)
})}
</nav>
</div>
{/* You section - pinned above profile footer */}
<div className="border-t" style={{ borderColor: 'var(--border)', padding: collapsed ? '4px 8px' : '4px 12px' }}>
{!collapsed && (
<div style={{ padding: '8px 12px 6px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em' }}>
{t('you')}
</div>
)}
{collapsed && <div style={{ height: 4 }} />}
{admin.isAdmin
? <>
{navItem('/admin/team', <IconUser size={18} stroke={2} aria-hidden="true" />, t('profile'), isActive('/admin/team'))}
{navItem('/admin/posts', <IconFileText size={18} stroke={2} aria-hidden="true" />, t('myPosts'), isActive('/admin/posts'))}
{admin.isSuperAdmin && navItem('/admin/settings', <IconSettings size={18} stroke={2} aria-hidden="true" />, t('settings'), isActive('/admin/settings'))}
</>
: <>
{navItem('/profile', <IconUser size={18} stroke={2} aria-hidden="true" />, t('profile'), isActive('/profile'))}
{navItem('/my-posts', <IconFileText size={18} stroke={2} aria-hidden="true" />, t('myPosts'), isActive('/my-posts'))}
{navItem('/settings', <IconSettings size={18} stroke={2} aria-hidden="true" />, t('settings'), isActive('/settings'))}
</>
}
{navItem('/privacy', <IconShieldLock size={18} stroke={2} aria-hidden="true" />, t('privacy'), isActive('/privacy'))}
</div>
{/* Profile footer */}
<div
className="border-t"
style={{
borderColor: admin.isAdmin ? 'rgba(6, 182, 212, 0.15)' : 'var(--border)',
padding: collapsed ? '12px 8px' : '14px 16px',
}}
>
{collapsed ? (
<div className="flex flex-col items-center gap-2">
{admin.isAdmin ? (
<div
className="w-8 h-8 rounded flex items-center justify-center"
style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)', borderRadius: 'var(--radius-sm)' }}
>
<IconShieldCheck size={16} stroke={2} />
</div>
) : auth.user?.id ? (
<Avatar userId={auth.user.id} avatarUrl={auth.user.avatarUrl} size={32} />
) : (
<div
className="w-8 h-8 rounded flex items-center justify-center"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)', borderRadius: 'var(--radius-sm)' }}
>
<IconCookie size={16} stroke={2} />
</div>
)}
<button
onClick={toggleTheme}
className="w-11 h-11 rounded-lg flex items-center justify-center action-btn"
style={{ color: 'var(--text-secondary)', borderRadius: 'var(--radius-sm)' }}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? <IconSun size={14} stroke={2} /> : <IconMoon size={14} stroke={2} />}
</button>
</div>
) : admin.isAdmin ? (
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded flex items-center justify-center shrink-0"
style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)', borderRadius: 'var(--radius-sm)' }}
>
<IconShieldCheck size={18} stroke={2} />
</div>
<div className="flex-1 min-w-0">
<div className="truncate font-medium" style={{ color: 'var(--admin-accent)', fontSize: 'var(--text-sm)' }}>
{admin.displayName || t('admin')}
</div>
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
<IconShieldCheck size={11} stroke={2} aria-hidden="true" />
<span>{admin.role === 'SUPER_ADMIN' ? 'Super Admin' : admin.role === 'MODERATOR' ? 'Moderator' : 'Admin'}</span>
</div>
</div>
</div>
) : (
<div className="flex items-center gap-3">
{auth.user?.id ? (
<Avatar userId={auth.user.id} avatarUrl={auth.user.avatarUrl} size={36} />
) : (
<div
className="w-9 h-9 rounded flex items-center justify-center shrink-0"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)', borderRadius: 'var(--radius-sm)' }}
>
<IconCookie size={18} stroke={2} />
</div>
)}
<div className="flex-1 min-w-0">
<div className="truncate font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{auth.displayName !== 'Anonymous'
? auth.displayName
: auth.isPasskeyUser && auth.user?.username
? `@${auth.user.username}`
: auth.user?.id
? `Anonymous #${auth.user.id.slice(-4)}`
: t('anonymous')}
</div>
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
{auth.isPasskeyUser ? (
<>
<IconFingerprint size={11} stroke={2} aria-hidden="true" />
<span>{t('passkeyUser')}</span>
</>
) : (
<>
<IconCookie size={11} stroke={2} aria-hidden="true" />
<span>{t('cookieIdentity')}</span>
</>
)}
</div>
</div>
</div>
)}
{!collapsed && !admin.isAdmin && !auth.isPasskeyUser && (
<Link <Link
to="/settings" to="/settings"
className="block mt-2 text-xs text-center py-1.5 rounded-md" className="block mt-3 text-center py-2 nav-link"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }} style={{
background: 'var(--accent-subtle)',
color: 'var(--accent)',
fontSize: 'var(--text-xs)',
fontWeight: 500,
borderRadius: 'var(--radius-md)',
transition: 'opacity var(--duration-fast) ease-out',
}}
> >
Register passkey for persistence {t('saveIdentity')}
</Link> </Link>
)} )}
</div> </div>
{/* Collapse toggle */}
<button
onClick={toggleCollapse}
className="absolute flex items-center justify-center action-btn"
style={{
top: '50%',
right: -22,
transform: 'translateY(-50%)',
width: 44,
height: 44,
borderRadius: '50%',
background: 'var(--surface)',
border: '1px solid var(--border)',
color: 'var(--text-tertiary)',
cursor: 'pointer',
zIndex: 30,
boxShadow: 'var(--shadow-sm)',
transition: 'color var(--duration-fast) ease-out',
}}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <IconChevronRight size={14} stroke={2} /> : <IconChevronLeft size={14} stroke={2} />}
</button>
</aside> </aside>
) )
} }

View File

@@ -1,21 +1,64 @@
const statusConfig: Record<string, { label: string; bg: string; color: string }> = { import { IconCircleDot, IconEye, IconCalendarEvent, IconLoader, IconCircleCheck, IconCircleX } from '@tabler/icons-react'
OPEN: { label: 'Open', bg: 'var(--accent-subtle)', color: 'var(--accent)' }, import type { Icon } from '@tabler/icons-react'
UNDER_REVIEW: { label: 'Under Review', bg: 'var(--admin-subtle)', color: 'var(--admin-accent)' },
PLANNED: { label: 'Planned', bg: 'rgba(59, 130, 246, 0.15)', color: 'var(--info)' }, export interface StatusConfig {
IN_PROGRESS: { label: 'In Progress', bg: 'rgba(234, 179, 8, 0.15)', color: 'var(--warning)' }, status: string
DONE: { label: 'Done', bg: 'rgba(34, 197, 94, 0.15)', color: 'var(--success)' }, label: string
DECLINED: { label: 'Declined', bg: 'rgba(239, 68, 68, 0.15)', color: 'var(--error)' }, color: string
} }
export default function StatusBadge({ status }: { status: string }) { const defaultConfig: Record<string, { label: string; colorVar: string; fallback: string; icon: Icon }> = {
const cfg = statusConfig[status] || { label: status, bg: 'var(--border)', color: 'var(--text-secondary)' } OPEN: { label: 'Open', colorVar: '--status-open', fallback: '#F59E0B', icon: IconCircleDot },
UNDER_REVIEW: { label: 'Under Review', colorVar: '--status-review', fallback: '#06B6D4', icon: IconEye },
PLANNED: { label: 'Planned', colorVar: '--status-planned', fallback: '#3B82F6', icon: IconCalendarEvent },
IN_PROGRESS: { label: 'In Progress', colorVar: '--status-progress', fallback: '#EAB308', icon: IconLoader },
DONE: { label: 'Done', colorVar: '--status-done', fallback: '#22C55E', icon: IconCircleCheck },
DECLINED: { label: 'Declined', colorVar: '--status-declined', fallback: '#EF4444', icon: IconCircleX },
}
const iconMap: Record<string, Icon> = {
OPEN: IconCircleDot,
UNDER_REVIEW: IconEye,
PLANNED: IconCalendarEvent,
IN_PROGRESS: IconLoader,
DONE: IconCircleCheck,
DECLINED: IconCircleX,
}
export default function StatusBadge({ status, customStatuses }: { status: string; customStatuses?: StatusConfig[] }) {
let label: string
let colorVar: string | null = null
let fallbackColor: string
let StatusIcon: Icon
const custom = customStatuses?.find((s) => s.status === status)
if (custom) {
label = custom.label
fallbackColor = custom.color
StatusIcon = iconMap[status] || IconCircleDot
} else {
const def = defaultConfig[status]
label = def?.label || status
colorVar = def?.colorVar || null
fallbackColor = def?.fallback || '#64748B'
StatusIcon = def?.icon || IconCircleDot
}
const color = colorVar ? `var(${colorVar})` : fallbackColor
const bgColor = colorVar ? `color-mix(in srgb, var(${colorVar}) 12%, transparent)` : `${fallbackColor}20`
return ( return (
<span <span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" className="inline-flex items-center gap-1 px-2 py-0.5 font-medium"
style={{ background: cfg.bg, color: cfg.color }} style={{
background: bgColor,
color,
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
> >
{cfg.label} <StatusIcon size={12} stroke={2} aria-hidden="true" />
{label}
</span> </span>
) )
} }

View File

@@ -1,4 +1,5 @@
import { useTheme } from '../hooks/useTheme' import { useTheme } from '../hooks/useTheme'
import { IconSun, IconMoon } from '@tabler/icons-react'
export default function ThemeToggle() { export default function ThemeToggle() {
const { resolved, toggle } = useTheme() const { resolved, toggle } = useTheme()
@@ -7,30 +8,32 @@ export default function ThemeToggle() {
return ( return (
<button <button
onClick={toggle} onClick={toggle}
className="fixed bottom-6 right-6 w-11 h-11 rounded-full flex items-center justify-center z-40 md:bottom-6 md:right-6 bottom-20 shadow-lg" className="fixed z-40 rounded-full flex items-center justify-center md:hidden"
style={{ style={{
bottom: 80,
right: 16,
width: 44,
height: 44,
background: 'var(--surface)', background: 'var(--surface)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
color: 'var(--accent)', color: 'var(--accent)',
transition: 'all 200ms ease-out', boxShadow: 'var(--shadow-md)',
cursor: 'pointer',
transition: 'transform var(--duration-fast) var(--ease-out), background var(--duration-fast) ease-out',
}} }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)' }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
onFocus={(e) => { e.currentTarget.style.transform = 'scale(1.1)' }}
onBlur={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'} aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
> >
<div <div
style={{ style={{
transition: 'transform 300ms cubic-bezier(0.16, 1, 0.3, 1)', transition: 'transform var(--duration-fast) var(--ease-spring)',
transform: isDark ? 'rotate(0deg)' : 'rotate(180deg)', transform: isDark ? 'rotate(0deg)' : 'rotate(180deg)',
}} }}
> >
{isDark ? ( {isDark ? <IconSun size={18} stroke={2} /> : <IconMoon size={18} stroke={2} />}
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</div> </div>
</button> </button>
) )

View File

@@ -1,37 +1,84 @@
import { useState } from 'react' import { useState } from 'react'
import { IconTrendingUp, IconShieldCheck, IconMessageDots, IconEdit, IconTrash, IconArrowBackUp, IconLock, IconLockOpen } from '@tabler/icons-react'
import Avatar from './Avatar'
import Markdown from './Markdown'
import MarkdownEditor from './MarkdownEditor'
import { useConfirm } from '../hooks/useConfirm'
interface ReplyTo {
id: string
body: string
isAdmin: boolean
authorName: string
}
interface TimelineAttachment {
id: string
filename: string
mimeType: string
size: number
}
interface TimelineEntry { interface TimelineEntry {
id: string id: string
type: 'status_change' | 'admin_response' | 'comment' type: 'status_change' | 'comment'
authorId?: string | null
authorName: string authorName: string
authorAvatarUrl?: string | null
content: string content: string
oldStatus?: string oldStatus?: string
newStatus?: string newStatus?: string
reason?: string | null
createdAt: string createdAt: string
reactions?: { emoji: string; count: number; hasReacted: boolean }[] reactions?: { emoji: string; count: number; hasReacted: boolean }[]
isAdmin?: boolean isAdmin?: boolean
authorTitle?: string | null
replyTo?: ReplyTo | null
attachments?: TimelineAttachment[]
editCount?: number
isEditLocked?: boolean
} }
export type { TimelineEntry, ReplyTo }
export default function Timeline({ export default function Timeline({
entries, entries,
onReact, onReact,
currentUserId,
isCurrentAdmin,
onEditComment,
onDeleteComment,
onReply,
onShowEditHistory,
onLockComment,
}: { }: {
entries: TimelineEntry[] entries: TimelineEntry[]
onReact?: (entryId: string, emoji: string) => void onReact?: (entryId: string, emoji: string) => void
currentUserId?: string
isCurrentAdmin?: boolean
onEditComment?: (entryId: string, body: string) => void
onDeleteComment?: (entryId: string) => void
onReply?: (entry: TimelineEntry) => void
onShowEditHistory?: (entryId: string) => void
onLockComment?: (entryId: string) => void
}) { }) {
return ( return (
<div className="relative"> <div className="flex flex-col gap-3">
{/* Vertical line */} {entries.map((entry, i) => (
<div <TimelineItem
className="absolute left-4 top-0 bottom-0 w-px" key={entry.id}
style={{ background: 'var(--border)' }} entry={entry}
/> onReact={onReact}
isOwn={!!currentUserId && entry.type === 'comment' && entry.authorId === currentUserId}
<div className="flex flex-col gap-0"> isCurrentAdmin={isCurrentAdmin}
{entries.map((entry) => ( onEdit={onEditComment}
<TimelineItem key={entry.id} entry={entry} onReact={onReact} /> onDelete={onDeleteComment}
))} onReply={onReply}
</div> onShowEditHistory={onShowEditHistory}
onLockComment={onLockComment}
index={i}
/>
))}
</div> </div>
) )
} }
@@ -39,127 +86,421 @@ export default function Timeline({
function TimelineItem({ function TimelineItem({
entry, entry,
onReact, onReact,
isOwn,
isCurrentAdmin,
onEdit,
onDelete,
onReply,
onShowEditHistory,
onLockComment,
index,
}: { }: {
entry: TimelineEntry entry: TimelineEntry
onReact?: (entryId: string, emoji: string) => void onReact?: (entryId: string, emoji: string) => void
isOwn?: boolean
isCurrentAdmin?: boolean
onEdit?: (entryId: string, body: string) => void
onDelete?: (entryId: string) => void
onReply?: (entry: TimelineEntry) => void
onShowEditHistory?: (entryId: string) => void
onLockComment?: (entryId: string) => void
index: number
}) { }) {
const confirm = useConfirm()
const [showPicker, setShowPicker] = useState(false) const [showPicker, setShowPicker] = useState(false)
const quickEmojis = ['👍', '❤️', '🎉', '😄', '🤔', '👀'] const [hovered, setHovered] = useState(false)
const [focused, setFocused] = useState(false)
const [editMode, setEditMode] = useState(false)
const [editText, setEditText] = useState(entry.content)
const quickEmojis = ['\uD83D\uDC4D', '\u2764\uFE0F', '\uD83C\uDF89', '\uD83D\uDE04', '\uD83D\uDE80']
const iconBg = entry.type === 'admin_response' const isAdmin = entry.isAdmin === true
? 'var(--admin-subtle)' const isStatusChange = entry.type === 'status_change'
: entry.type === 'status_change' const canEdit = (isOwn || (isCurrentAdmin && isAdmin)) && !entry.isEditLocked
? 'var(--accent-subtle)' const canDelete = isOwn || isCurrentAdmin
: 'var(--border)'
const iconColor = entry.type === 'admin_response' if (isStatusChange) {
? 'var(--admin-accent)' return (
: entry.type === 'status_change'
? 'var(--accent)'
: 'var(--text-tertiary)'
return (
<div className="relative pl-10 pb-6">
{/* Dot */}
<div <div
className="absolute left-2 top-1 w-5 h-5 rounded-full flex items-center justify-center z-10" className="flex flex-col items-center py-2 stagger-in"
style={{ background: iconBg }} style={{ '--stagger': index } as React.CSSProperties}
> >
{entry.type === 'status_change' ? ( <div className="flex items-center gap-2">
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}> <div
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /> className="w-6 h-6 rounded-full flex items-center justify-center"
</svg> style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
) : entry.type === 'admin_response' ? ( >
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}> <IconTrendingUp size={12} stroke={2.5} />
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> </div>
</svg> <span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
) : ( Status changed from <strong style={{ color: 'var(--text-secondary)' }}>{entry.oldStatus}</strong> to <strong style={{ color: 'var(--text-secondary)' }}>{entry.newStatus}</strong>
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01" />
</svg>
)}
</div>
{/* Content */}
<div
className="rounded-lg p-3"
style={{
background: entry.type === 'admin_response' ? 'var(--admin-subtle)' : 'var(--surface)',
border: entry.type === 'admin_response' ? `1px solid rgba(6, 182, 212, 0.2)` : `1px solid var(--border)`,
}}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium" style={{ color: entry.isAdmin ? 'var(--admin-accent)' : 'var(--text)' }}>
{entry.authorName}
{entry.isAdmin && (
<span className="ml-1 px-1 py-0.5 rounded text-[10px]" style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)' }}>
admin
</span>
)}
</span> </span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}> <time dateTime={entry.createdAt} style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{new Date(entry.createdAt).toLocaleDateString()} {new Date(entry.createdAt).toLocaleDateString()}
</span> </time>
</div> </div>
{entry.reason && (
{entry.type === 'status_change' ? ( <div
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> className="mt-1 px-3 py-1.5"
Changed status from <strong>{entry.oldStatus}</strong> to <strong>{entry.newStatus}</strong> style={{
</p> fontSize: 'var(--text-xs)',
) : ( color: 'var(--text-secondary)',
<p className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}> background: 'var(--surface-hover)',
{entry.content} borderRadius: 'var(--radius-sm)',
</p> maxWidth: '80%',
)} }}
>
{/* Reactions */} Reason: {entry.reason}
{(entry.reactions?.length || entry.type === 'comment') && (
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
{entry.reactions?.map((r) => (
<button
key={r.emoji}
onClick={() => onReact?.(entry.id, r.emoji)}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
style={{
background: r.hasReacted ? 'var(--accent-subtle)' : 'var(--border)',
border: r.hasReacted ? '1px solid var(--accent)' : '1px solid transparent',
color: 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
{r.emoji} {r.count}
</button>
))}
<div className="relative">
<button
onClick={() => setShowPicker(!showPicker)}
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
>
+
</button>
{showPicker && (
<div
className="absolute bottom-full left-0 mb-1 flex gap-1 p-1.5 rounded-lg z-20 fade-in"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', boxShadow: '0 4px 12px rgba(0,0,0,0.3)' }}
>
{quickEmojis.map((e) => (
<button
key={e}
onClick={() => { onReact?.(entry.id, e); setShowPicker(false) }}
className="w-7 h-7 rounded flex items-center justify-center hover:scale-110"
style={{ transition: 'transform 200ms ease-out' }}
>
{e}
</button>
))}
</div>
)}
</div>
</div> </div>
)} )}
</div> </div>
)
}
return (
<div
className={`flex stagger-in ${isAdmin ? 'justify-end' : 'justify-start'}`}
style={{ '--stagger': index, maxWidth: '85%', marginLeft: isAdmin ? 'auto' : 0 } as React.CSSProperties}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowPicker(false) }}
onFocusCapture={() => setFocused(true)}
onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) { setFocused(false); setShowPicker(false) } }}
>
<div className="flex-1">
{/* Bubble */}
<div
className="p-4"
style={{
background: isAdmin ? 'var(--admin-subtle)' : 'var(--surface)',
border: isAdmin ? '1px solid rgba(6, 182, 212, 0.15)' : '1px solid var(--border)',
borderRadius: isAdmin
? 'var(--radius-lg) var(--radius-lg) var(--radius-sm) var(--radius-lg)'
: 'var(--radius-lg) var(--radius-lg) var(--radius-lg) var(--radius-sm)',
boxShadow: 'var(--shadow-sm)',
}}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<Avatar
userId={entry.authorId ?? '0000'}
name={isAdmin ? null : entry.authorName}
avatarUrl={entry.authorAvatarUrl}
size={22}
/>
<span
className="font-medium"
style={{
color: isAdmin ? 'var(--admin-accent)' : 'var(--text)',
fontSize: 'var(--text-xs)',
}}
>
{entry.authorName}
{entry.authorTitle && (
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}> - {entry.authorTitle}</span>
)}
</span>
{isAdmin && (
<span
className="px-1.5 py-0.5 rounded font-medium"
style={{
background: 'var(--admin-subtle)',
color: 'var(--admin-accent)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
>
Official
</span>
)}
<time dateTime={entry.createdAt} style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{new Date(entry.createdAt).toLocaleDateString()}
</time>
{entry.isEditLocked && (
<span title="Editing locked" style={{ color: 'var(--error)', display: 'inline-flex' }}>
<IconLock size={11} stroke={2} aria-label="Editing locked" />
</span>
)}
{entry.editCount != null && entry.editCount > 0 && (
<button
onClick={() => onShowEditHistory?.(entry.id)}
style={{
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
fontStyle: 'italic',
background: 'none',
border: 'none',
padding: '2px 4px',
cursor: 'pointer',
textDecoration: 'underline',
textDecorationStyle: 'dotted' as const,
textUnderlineOffset: '2px',
borderRadius: 'var(--radius-sm)',
transition: 'color var(--duration-fast) ease-out, background var(--duration-fast) ease-out',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)'; e.currentTarget.style.background = 'var(--accent-subtle)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)'; e.currentTarget.style.background = 'none' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)'; e.currentTarget.style.background = 'var(--accent-subtle)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)'; e.currentTarget.style.background = 'none' }}
>
(edited)
</button>
)}
{!editMode && (
<div
className="ml-auto flex items-center gap-1"
style={{
opacity: hovered || focused ? 1 : 0,
transition: 'opacity var(--duration-fast) ease-out',
}}
>
{onReply && (
<button
onClick={() => onReply(entry)}
className="inline-flex items-center gap-1 px-2 rounded action-btn"
style={{
minHeight: 44,
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
transition: 'color var(--duration-fast) ease-out',
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
>
<IconArrowBackUp size={11} stroke={2} aria-hidden="true" /> Reply
</button>
)}
{canEdit && (
<button
onClick={() => { setEditText(entry.content); setEditMode(true) }}
className="inline-flex items-center gap-1 px-2 rounded action-btn"
style={{
minHeight: 44,
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
transition: 'color var(--duration-fast) ease-out',
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
>
<IconEdit size={11} stroke={2} aria-hidden="true" /> Edit
</button>
)}
{canDelete && (
<button
onClick={async () => { if (await confirm('Delete this comment?')) onDelete?.(entry.id) }}
className="inline-flex items-center gap-1 px-2 rounded action-btn"
style={{
minHeight: 44,
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
transition: 'color var(--duration-fast) ease-out',
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--error)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--error)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
>
<IconTrash size={11} stroke={2} aria-hidden="true" /> Delete
</button>
)}
{onLockComment && isCurrentAdmin && (
<button
onClick={() => onLockComment(entry.id)}
className="inline-flex items-center gap-1 px-2 rounded action-btn"
style={{
minHeight: 44,
color: entry.isEditLocked ? 'var(--error)' : 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
transition: 'color var(--duration-fast) ease-out',
background: entry.isEditLocked ? 'rgba(239, 68, 68, 0.1)' : 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
}}
>
{entry.isEditLocked
? <><IconLock size={11} stroke={2} aria-hidden="true" /> Unlock</>
: <><IconLockOpen size={11} stroke={2} aria-hidden="true" /> Lock</>
}
</button>
)}
</div>
)}
</div>
{/* Quoted message */}
{entry.replyTo && (
<div
className="mb-3 px-3 py-2"
style={{
borderLeft: entry.replyTo.isAdmin
? '2px solid var(--admin-accent)'
: '2px solid var(--border-hover)',
background: 'var(--bg)',
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
fontSize: 'var(--text-xs)',
}}
>
<span
className="font-medium"
style={{ color: entry.replyTo.isAdmin ? 'var(--admin-accent)' : 'var(--text-secondary)' }}
>
{entry.replyTo.authorName}
</span>
<p style={{ color: 'var(--text-tertiary)', marginTop: 2, lineHeight: 1.4 }}>
{entry.replyTo.body.length > 150 ? entry.replyTo.body.slice(0, 150) + '...' : entry.replyTo.body}
</p>
</div>
)}
{/* Content or edit form */}
{editMode ? (
<div>
<MarkdownEditor
value={editText}
onChange={setEditText}
rows={3}
autoFocus
/>
<div className="flex gap-2 mt-2">
<button
onClick={() => { onEdit?.(entry.id, editText); setEditMode(false) }}
className="btn btn-primary"
style={{ fontSize: 'var(--text-xs)', padding: '6px 12px' }}
>
Save
</button>
<button
onClick={() => setEditMode(false)}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-xs)', padding: '6px 12px' }}
>
Cancel
</button>
</div>
</div>
) : (
<Markdown>{entry.content}</Markdown>
)}
{/* Comment attachments */}
{entry.attachments && entry.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{entry.attachments.map((att) => (
<a
key={att.id}
href={`/api/v1/attachments/${att.id}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
width: 80,
height: 80,
borderRadius: 'var(--radius-sm)',
overflow: 'hidden',
border: '1px solid var(--border)',
transition: 'border-color var(--duration-fast) ease-out',
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--accent)' }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border)' }}
>
<img
src={`/api/v1/attachments/${att.id}`}
alt={att.filename}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/>
</a>
))}
</div>
)}
{/* Reactions */}
{!editMode && (
<div
className="flex items-center gap-1.5 mt-3 flex-wrap"
style={{
opacity: hovered || focused || (entry.reactions && entry.reactions.length > 0) ? 1 : 0,
transition: 'opacity var(--duration-normal) ease-out',
}}
>
{entry.reactions?.map((r) => (
<button
key={r.emoji}
onClick={() => onReact?.(entry.id, r.emoji)}
className="inline-flex items-center gap-1 px-2.5 rounded-full"
style={{
minHeight: 44,
fontSize: 'var(--text-xs)',
background: r.hasReacted ? 'var(--accent-subtle)' : 'var(--surface-hover)',
border: r.hasReacted ? '1px solid var(--border-accent)' : '1px solid transparent',
color: 'var(--text-secondary)',
cursor: 'pointer',
transition: 'all var(--duration-fast) ease-out',
}}
>
{r.emoji} {r.count}
</button>
))}
<div className="relative">
<button
onClick={() => setShowPicker(!showPicker)}
className="w-11 h-11 rounded-full flex items-center justify-center action-btn"
style={{
background: 'var(--surface-hover)',
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
aria-label="Add reaction"
>
+
</button>
{showPicker && (
<div
className="absolute bottom-full left-0 mb-2 flex gap-1 p-2 z-20 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-lg)',
}}
>
{quickEmojis.map((e) => (
<button
key={e}
onClick={() => { onReact?.(entry.id, e); setShowPicker(false) }}
className="w-11 h-11 rounded flex items-center justify-center"
style={{
transition: 'transform var(--duration-fast) var(--ease-spring)',
borderRadius: 'var(--radius-sm)',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.2)' }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
onFocus={(e) => { e.currentTarget.style.transform = 'scale(1.2)' }}
onBlur={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
>
{e}
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
</div> </div>
) )
} }

View File

@@ -14,42 +14,52 @@ export default function VoteBudget({ used, total, resetsAt }: Props) {
<div className="relative inline-flex items-center gap-1"> <div className="relative inline-flex items-center gap-1">
<div <div
className="flex items-center gap-1 cursor-help" className="flex items-center gap-1 cursor-help"
tabIndex={0}
role="group"
aria-label={`Vote budget: ${used} of ${total} used, ${remaining} remaining`}
onMouseEnter={() => setShowTip(true)} onMouseEnter={() => setShowTip(true)}
onMouseLeave={() => setShowTip(false)} onMouseLeave={() => setShowTip(false)}
onFocus={() => setShowTip(true)}
onBlur={() => setShowTip(false)}
> >
{Array.from({ length: total }, (_, i) => ( {Array.from({ length: total }, (_, i) => (
<div <div
key={i} key={i}
className="w-2 h-2 rounded-full" className="rounded-full"
style={{ style={{
width: 6,
height: 6,
background: i < used ? 'var(--accent)' : 'var(--border-hover)', background: i < used ? 'var(--accent)' : 'var(--border-hover)',
transition: 'background 200ms ease-out', transition: 'background var(--duration-fast) ease-out',
}} }}
/> />
))} ))}
</div> </div>
<span className="text-xs ml-1" style={{ color: 'var(--text-tertiary)' }}> <span className="ml-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{remaining} left {remaining} left
</span> </span>
{showTip && ( {showTip && (
<div <div
className="absolute bottom-full left-0 mb-2 px-3 py-2 rounded-lg text-xs whitespace-nowrap z-50 fade-in" role="tooltip"
className="absolute bottom-full left-0 mb-2 px-3 py-2 whitespace-nowrap z-50 fade-in"
style={{ style={{
background: 'var(--surface)', background: 'var(--surface)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)', fontSize: 'var(--text-xs)',
boxShadow: 'var(--shadow-lg)',
}} }}
> >
<div className="font-medium mb-1" style={{ color: 'var(--text)' }}> <div className="font-medium mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
Vote Budget Vote Budget
</div> </div>
<div>{used} of {total} votes used</div> <div>{used} of {total} votes used</div>
{resetsAt && ( {resetsAt && (
<div style={{ color: 'var(--text-tertiary)' }}> <div style={{ color: 'var(--text-tertiary)' }}>
Resets {new Date(resetsAt).toLocaleDateString()} Resets <time dateTime={resetsAt}>{new Date(resetsAt).toLocaleDateString()}</time>
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
import { api } from '../lib/api'
interface AdminState {
isAdmin: boolean
loading: boolean
role: string | null
displayName: string | null
teamTitle: string | null
isSuperAdmin: boolean
canInvite: boolean
canAccessSettings: boolean
refresh: () => Promise<void>
exitAdminMode: () => Promise<void>
}
const AdminContext = createContext<AdminState>({
isAdmin: false,
loading: true,
role: null,
displayName: null,
teamTitle: null,
isSuperAdmin: false,
canInvite: false,
canAccessSettings: false,
refresh: async () => {},
exitAdminMode: async () => {},
})
export const AdminProvider = AdminContext.Provider
export function useAdminState(): AdminState {
const [isAdmin, setIsAdmin] = useState(false)
const [loading, setLoading] = useState(true)
const [role, setRole] = useState<string | null>(null)
const [displayName, setDisplayName] = useState<string | null>(null)
const [teamTitle, setTeamTitle] = useState<string | null>(null)
const refresh = useCallback(async () => {
try {
const res = await api.get<{ isAdmin: boolean; role?: string; displayName?: string | null; teamTitle?: string | null }>('/admin/me')
setIsAdmin(res.isAdmin)
setRole(res.role ?? null)
setDisplayName(res.displayName ?? null)
setTeamTitle(res.teamTitle ?? null)
} catch {
setIsAdmin(false)
setRole(null)
} finally {
setLoading(false)
}
}, [])
const exitAdminMode = useCallback(async () => {
try {
await api.post('/admin/exit')
} catch {}
setIsAdmin(false)
setRole(null)
}, [])
useEffect(() => { refresh() }, [refresh])
const isSuperAdmin = role === 'SUPER_ADMIN'
const canInvite = role === 'SUPER_ADMIN' || role === 'ADMIN'
const canAccessSettings = role === 'SUPER_ADMIN'
return { isAdmin, loading, role, displayName, teamTitle, isSuperAdmin, canInvite, canAccessSettings, refresh, exitAdminMode }
}
export function useAdmin(): AdminState {
return useContext(AdminContext)
}

View File

@@ -1,10 +1,15 @@
import { createContext, useContext, useState, useCallback, useEffect } from 'react' import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
import { api } from '../lib/api' import { api } from '../lib/api'
interface User { interface User {
id: string id: string
displayName: string displayName: string
username?: string
isPasskeyUser: boolean isPasskeyUser: boolean
avatarUrl?: string | null
darkMode?: string
hasRecoveryCode?: boolean
recoveryCodeExpiresAt?: string | null
createdAt: string createdAt: string
} }
@@ -15,8 +20,8 @@ interface AuthState {
isPasskeyUser: boolean isPasskeyUser: boolean
displayName: string displayName: string
initIdentity: () => Promise<void> initIdentity: () => Promise<void>
updateProfile: (data: { displayName: string }) => Promise<void> updateProfile: (data: { displayName: string; altcha?: string }) => Promise<void>
deleteIdentity: () => Promise<void> deleteIdentity: (altcha: string) => Promise<void>
refresh: () => Promise<void> refresh: () => Promise<void>
} }
@@ -28,12 +33,23 @@ export function useAuthState(): AuthState {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const creating = useRef(false)
const fetchMe = useCallback(async () => { const fetchMe = useCallback(async () => {
try { try {
const u = await api.get<User>('/me') const u = await api.get<User>('/me')
setUser(u) setUser(u)
} catch { } catch {
setUser(null) if (creating.current) return
creating.current = true
try {
const res = await api.post<User>('/identity', {})
setUser(res)
} catch {
setUser(null)
} finally {
creating.current = false
}
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -41,20 +57,20 @@ export function useAuthState(): AuthState {
const initIdentity = useCallback(async () => { const initIdentity = useCallback(async () => {
try { try {
const u = await api.post<User>('/identity') const res = await api.post<User>('/identity')
setUser(u) setUser(res)
} catch { } catch {
await fetchMe() await fetchMe()
} }
}, [fetchMe]) }, [fetchMe])
const updateProfile = useCallback(async (data: { displayName: string }) => { const updateProfile = useCallback(async (data: { displayName: string; altcha?: string }) => {
const u = await api.put<User>('/me', data) const u = await api.put<User>('/me', data)
setUser(u) setUser(u)
}, []) }, [])
const deleteIdentity = useCallback(async () => { const deleteIdentity = useCallback(async (altcha: string) => {
await api.delete('/me') await api.delete('/me', { altcha })
setUser(null) setUser(null)
}, []) }, [])

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