commit 4fd17084a51d4339577316a29f99f9125b61bef8 Author: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed Feb 18 02:49:21 2026 +0100 chore: Initial commit of Opencord base state diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..308ad3c4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +.git +.gitignore +.env +.env.local +data +dist +dist-electron +*.md +*.log +.DS_Store +.idea +.vscode +packages/desktop +npm +livekit diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..403dab9e --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Opencord Configuration +# Copy this file to .env and fill in the values + +# Server +PORT=3000 +HOST=0.0.0.0 + +# Authentication — generate a random secret: openssl rand -hex 32 +JWT_SECRET=change_me_to_a_random_64_char_hex_string + +# LiveKit Voice/Video (optional — leave empty to disable voice features) +LIVEKIT_URL= +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= + +# Storage +UPLOAD_DIR=./data/uploads +DB_PATH=./data/opencord.db +MAX_UPLOAD_SIZE=104857600 + +# Registration — set to false to disable new user registration +REGISTRATION_OPEN=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..bd3e2957 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build output +dist/ +dist-electron/ +packages/web/dist/ +packages/server/dist/ +packages/shared/dist/ +packages/desktop/dist/ +packages/desktop/dist-electron/ + +# Data +data/ +*.db +*.db-journal + +# Environment +.env +.env.local +.env.*.local + +# OS files +.DS_Store +Thumbs.db +*.swp +*.swo +*~ + +# IDE +.idea/ +.vscode/ +*.code-workspace + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# TypeScript +*.tsbuildinfo + +# Misc +.cache/ +tmp/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9381a8de --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,358 @@ +# CLAUDE.md — Opencord Brain File + +## IDENTITY + +You are the sole developer of Opencord, an open-source, self-hosted Discord alternative. You are an expert full-stack TypeScript architect. You write production-quality code. You never cut corners. You never use placeholders. You finish what you start. + +## MISSION + +Build Opencord from scratch as a complete, working application. All 8 phases. Every file fully implemented. No stubs, no TODOs, no "add later" comments. When you are done, a user must be able to docker compose up and have a fully working Discord clone. + +## CRITICAL RULES + +- NEVER use placeholder code, TODO comments, or // ...rest of code shortcuts. Every function you write must be FULLY implemented with real logic. +- NEVER use // ...rest of code or // similar to above shortcuts. Write out every single line. +- NEVER skip files or say "you can add this later". Build everything NOW. +- NEVER generate partial components. Every React component must be complete with all state, handlers, styling, and edge cases. +- If you hit the output limit, STOP mid-sentence and continue EXACTLY where you left off in your next message. Do NOT summarize or skip ahead. +- Write production-quality code from the start. Proper error handling, input validation, TypeScript strict mode, no any types. +- After completing each phase, UPDATE the ## PROGRESS section in CLAUDE.md marking it ✅ and noting what files were created. +- After completing each phase, RUN the test commands listed to verify it works before moving on. +- If something fails, FIX IT before moving on. Never leave broken code behind. + +## TECH STACK (DO NOT DEVIATE) + +| Layer | Technology | Package | +|-------|-----------|---------| +| Runtime | Node.js 20+ with TypeScript (strict mode) | typescript, tsx | +| Backend Framework | Fastify | fastify, @fastify/cors, @fastify/multipart, @fastify/static, @fastify/websocket | +| Database | SQLite | better-sqlite3 | +| ORM | Drizzle ORM | drizzle-orm, drizzle-kit | +| Auth | JWT + bcrypt | jsonwebtoken, bcryptjs | +| WebSocket | ws (via @fastify/websocket) | @fastify/websocket | +| Frontend | React 18 + TypeScript | react, react-dom, react-router-dom | +| Styling | Tailwind CSS 3 | tailwindcss, postcss, autoprefixer | +| Build Tool | Vite | vite, @vitejs/plugin-react | +| State Management | Zustand | zustand | +| Voice/Video | LiveKit Client SDK | livekit-client, @livekit/components-react | +| LiveKit Token | livekit-server-sdk | livekit-server-sdk | +| Desktop | Electron | electron, electron-builder | +| Monorepo | pnpm workspaces | pnpm | +| Markdown | react-markdown | react-markdown | + +## PROJECT STRUCTURE + +``` +Opencord/ +├── CLAUDE.md +├── package.json +├── pnpm-workspace.yaml +├── tsconfig.base.json +├── Dockerfile +├── docker-compose.yml +├── .env.example +├── .gitignore +├── README.md +├── packages/ +│ ├── shared/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ └── src/ +│ │ └── types.ts +│ ├── server/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ └── src/ +│ │ ├── index.ts +│ │ ├── config.ts +│ │ ├── db/ +│ │ │ ├── schema.ts +│ │ │ ├── index.ts +│ │ │ └── seed.ts +│ │ ├── routes/ +│ │ │ ├── auth.ts +│ │ │ ├── users.ts +│ │ │ ├── servers.ts +│ │ │ ├── channels.ts +│ │ │ ├── messages.ts +│ │ │ ├── uploads.ts +│ │ │ ├── dm.ts +│ │ │ └── livekit.ts +│ │ ├── ws/ +│ │ │ ├── handler.ts +│ │ │ └── events.ts +│ │ └── utils/ +│ │ ├── auth.ts +│ │ ├── snowflake.ts +│ │ └── permissions.ts +│ ├── web/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ ├── vite.config.ts +│ │ ├── tailwind.config.js +│ │ ├── postcss.config.js +│ │ ├── index.html +│ │ └── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── api/ +│ │ │ └── client.ts +│ │ ├── stores/ +│ │ │ ├── authStore.ts +│ │ │ ├── serverStore.ts +│ │ │ ├── chatStore.ts +│ │ │ ├── voiceStore.ts +│ │ │ └── uiStore.ts +│ │ ├── hooks/ +│ │ │ ├── useWebSocket.ts +│ │ │ ├── useLiveKit.ts +│ │ │ └── useAuth.ts +│ │ ├── components/ +│ │ │ ├── layout/ +│ │ │ │ ├── AppLayout.tsx +│ │ │ │ ├── ServerSidebar.tsx +│ │ │ │ ├── ChannelSidebar.tsx +│ │ │ │ ├── MainContent.tsx +│ │ │ │ ├── MemberSidebar.tsx +│ │ │ │ └── MobileNav.tsx +│ │ │ ├── chat/ +│ │ │ │ ├── MessageList.tsx +│ │ │ │ ├── Message.tsx +│ │ │ │ ├── MessageInput.tsx +│ │ │ │ ├── TypingIndicator.tsx +│ │ │ │ └── ImagePreview.tsx +│ │ │ ├── voice/ +│ │ │ │ ├── VoiceChannel.tsx +│ │ │ │ ├── VoiceControls.tsx +│ │ │ │ ├── VoiceGrid.tsx +│ │ │ │ └── VoiceUser.tsx +│ │ │ ├── auth/ +│ │ │ │ ├── LoginPage.tsx +│ │ │ │ └── RegisterPage.tsx +│ │ │ ├── modals/ +│ │ │ │ ├── CreateServer.tsx +│ │ │ │ ├── InviteModal.tsx +│ │ │ │ ├── CreateChannel.tsx +│ │ │ │ ├── JoinServer.tsx +│ │ │ │ ├── UserSettings.tsx +│ │ │ │ └── ServerSettings.tsx +│ │ │ └── ui/ +│ │ │ ├── Avatar.tsx +│ │ │ ├── Modal.tsx +│ │ │ ├── Tooltip.tsx +│ │ │ ├── ContextMenu.tsx +│ │ │ └── LoadingSpinner.tsx +│ │ └── styles/ +│ │ └── globals.css +│ └── desktop/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── src/ +│ │ ├── main.ts +│ │ └── preload.ts +│ └── electron-builder.yml +└── data/ + ├── opencord.db + └── uploads/ +``` + +## DATABASE SCHEMA + +```sql +-- Users +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + display_name TEXT, + password_hash TEXT NOT NULL, + avatar TEXT, + status TEXT DEFAULT 'offline', + custom_status TEXT, + created_at INTEGER NOT NULL +); + +-- Servers (Guilds) +CREATE TABLE servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT, + owner_id TEXT NOT NULL REFERENCES users(id), + invite_code TEXT UNIQUE, + created_at INTEGER NOT NULL +); + +-- Server Members +CREATE TABLE server_members ( + server_id TEXT REFERENCES servers(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + role TEXT DEFAULT 'member', + nickname TEXT, + joined_at INTEGER NOT NULL, + PRIMARY KEY (server_id, user_id) +); + +-- Channels +CREATE TABLE channels ( + id TEXT PRIMARY KEY, + server_id TEXT REFERENCES servers(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL, + topic TEXT, + position INTEGER DEFAULT 0, + created_at INTEGER NOT NULL +); + +-- Messages +CREATE TABLE messages ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id), + content TEXT, + edited_at INTEGER, + created_at INTEGER NOT NULL +); + +-- Attachments +CREATE TABLE attachments ( + id TEXT PRIMARY KEY, + message_id TEXT REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + mimetype TEXT NOT NULL, + size INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +-- DM Channels +CREATE TABLE dm_channels ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL +); + +CREATE TABLE dm_members ( + dm_channel_id TEXT REFERENCES dm_channels(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (dm_channel_id, user_id) +); + +-- DM Messages +CREATE TABLE dm_messages ( + id TEXT PRIMARY KEY, + dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id), + content TEXT, + created_at INTEGER NOT NULL +); +``` + +## REST API ENDPOINTS + +``` +POST /api/auth/register { username, password, displayName? } → { token, user } +POST /api/auth/login { username, password } → { token, user } + +GET /api/users/@me (auth) → { user } +PATCH /api/users/@me (auth) { displayName?, avatar?, customStatus? } → { user } +GET /api/users/:id (auth) → { user } + +POST /api/servers (auth) { name, icon? } → { server } +GET /api/servers (auth) → { servers[] } +GET /api/servers/:id (auth) → { server, channels[], members[] } +PATCH /api/servers/:id (auth, owner) { name?, icon? } → { server } +DELETE /api/servers/:id (auth, owner) → { success } +POST /api/servers/:id/join (auth) { inviteCode } → { server } +POST /api/servers/:id/invite (auth, admin+) → { inviteCode } + +GET /api/servers/:id/channels (auth, member) → { channels[] } +POST /api/servers/:id/channels (auth, admin+) { name, type, topic? } → { channel } +PATCH /api/channels/:id (auth, admin+) { name?, topic?, position? } → { channel } +DELETE /api/channels/:id (auth, admin+) → { success } + +GET /api/channels/:id/messages (auth, member) ?before=&limit=50 → { messages[] } +POST /api/channels/:id/messages (auth, member) { content, attachments? } → { message } +PATCH /api/messages/:id (auth, author) { content } → { message } +DELETE /api/messages/:id (auth, author|admin) → { success } + +GET /api/servers/:id/members (auth, member) → { members[] } +PATCH /api/servers/:id/members/:uid (auth, owner) { role } → { member } +DELETE /api/servers/:id/members/:uid (auth, owner|self) → { success } + +POST /api/livekit/token (auth) { channelId } → { token } + +POST /api/uploads (auth) multipart file → { attachment } +GET /api/uploads/:filename (public) → file stream + +GET /api/dm (auth) → { dmChannels[] } +POST /api/dm (auth) { userId } → { dmChannel } +GET /api/dm/:id/messages (auth, member) ?before=&limit=50 → { messages[] } +POST /api/dm/:id/messages (auth, member) { content } → { message } +``` + +## WEBSOCKET PROTOCOL + +All WebSocket messages are JSON. Client authenticates by sending `{ type: 'auth', token: 'jwt...' }` as first message. Server responds with `{ type: 'ready', user, servers, dmChannels }`. + +### Client → Server +``` +{ type: 'auth', token: string } +{ type: 'message_create', channelId: string, content: string } +{ type: 'message_edit', messageId: string, content: string } +{ type: 'message_delete', messageId: string } +{ type: 'typing_start', channelId: string } +{ type: 'presence_update', status: 'online' | 'idle' | 'dnd' } +{ type: 'voice_join', channelId: string } +{ type: 'voice_leave' } +{ type: 'dm_message_create', dmChannelId: string, content: string } +``` + +### Server → Client +``` +{ type: 'ready', user: User, servers: ServerWithChannelsAndMembers[], dmChannels: DmChannel[] } +{ type: 'message_created', message: MessageWithUser } +{ type: 'message_updated', message: MessageWithUser } +{ type: 'message_deleted', messageId: string, channelId: string } +{ type: 'typing', channelId: string, userId: string, username: string } +{ type: 'presence_update', userId: string, status: string } +{ type: 'voice_state_update', channelId: string, userId: string, action: 'join' | 'leave' } +{ type: 'member_joined', serverId: string, member: MemberWithUser } +{ type: 'member_left', serverId: string, userId: string } +{ type: 'dm_message_created', message: DmMessageWithUser } +``` + +## LIVEKIT CONFIGURATION + +``` +LiveKit URL: wss://nova.ddns.net/livekit +API Key: REDACTED_LIVEKIT_KEY +API Secret: REDACTED_LIVEKIT_SECRET +``` + +## ENVIRONMENT VARIABLES + +``` +PORT=3000 +HOST=0.0.0.0 +JWT_SECRET= +LIVEKIT_URL=wss://nova.ddns.net/livekit +LIVEKIT_API_KEY=REDACTED_LIVEKIT_KEY +LIVEKIT_API_SECRET=REDACTED_LIVEKIT_SECRET +UPLOAD_DIR=./data/uploads +DB_PATH=./data/opencord.db +MAX_UPLOAD_SIZE=104857600 +REGISTRATION_OPEN=true +``` + +## PROGRESS + +- Phase 1: Foundation — ✅ Complete (package.json, pnpm-workspace.yaml, tsconfig.base.json, packages/shared/src/types.ts, packages/server/src/index.ts, config.ts, db/schema.ts, db/index.ts, db/seed.ts, routes/auth.ts, routes/users.ts, utils/auth.ts, utils/snowflake.ts, utils/permissions.ts, .env) +- Phase 2: Servers & Channels — ✅ Complete (routes/servers.ts, routes/channels.ts, updated index.ts) +- Phase 3: Real-time Messaging — ✅ Complete (ws/handler.ts, ws/events.ts, routes/messages.ts, updated index.ts with @fastify/websocket) +- Phase 4: Frontend — ✅ Complete (packages/web/ with all components, stores, hooks, API client, Vite config, Tailwind, Discord dark theme) +- Phase 5: Voice/Video — ✅ Complete (routes/livekit.ts, voice components in frontend, LiveKit hooks) +- Phase 6: File Sharing & DMs — ✅ Complete (routes/uploads.ts, routes/dm.ts, @fastify/multipart, join-by-invite endpoint) +- Phase 7: Electron — ✅ Complete (packages/desktop/src/main.ts with IPC handlers, preload.ts, electron-builder.yml, tsconfig.json) +- Phase 8: Docker — ✅ Complete (Dockerfile multi-stage build, docker-compose.yml, .env.example, .gitignore, .dockerignore, README.md) + +## CURRENT PHASE + +All 8 phases complete. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..7466baee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,84 @@ +# ============================================================ +# Opencord — Multi-stage Docker build +# ============================================================ + +# Stage 1: Install dependencies and build frontend +FROM node:20-slim AS builder + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +# Copy workspace config +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY tsconfig.base.json ./ + +# Copy package.json files for all workspace packages +COPY packages/shared/package.json packages/shared/ +COPY packages/server/package.json packages/server/ +COPY packages/web/package.json packages/web/ + +# Install dependencies +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy source code (excluding desktop — not needed in Docker) +COPY packages/shared/ packages/shared/ +COPY packages/server/ packages/server/ +COPY packages/web/ packages/web/ + +# Build the web frontend +RUN cd packages/web && pnpm run build + +# ============================================================ +# Stage 2: Production runtime +FROM node:20-slim AS runtime + +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Install build dependencies for better-sqlite3 native module +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3 make g++ && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy workspace config +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY tsconfig.base.json ./ + +# Copy package.json files +COPY packages/shared/package.json packages/shared/ +COPY packages/server/package.json packages/server/ +COPY packages/web/package.json packages/web/ + +# Install production dependencies only (plus tsx for running TS) +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy shared source (needed at runtime since server imports types directly) +COPY packages/shared/ packages/shared/ + +# Copy server source +COPY packages/server/ packages/server/ + +# Copy built frontend from builder stage +COPY --from=builder /app/packages/web/dist packages/web/dist + +# Create data directories +RUN mkdir -p /app/data/uploads + +# Set environment defaults +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOST=0.0.0.0 +ENV DB_PATH=/app/data/opencord.db +ENV UPLOAD_DIR=/app/data/uploads + +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" + +# Run the server using tsx from the server package directory +WORKDIR /app/packages/server +CMD ["node", "--import", "tsx/esm", "src/index.ts"] diff --git a/README.md b/README.md new file mode 100644 index 00000000..ccc22351 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# Opencord + +Open-source, self-hosted Discord alternative built with TypeScript. + +## Features + +- Real-time text messaging with WebSocket +- Servers, channels, and member management +- Voice and video chat via LiveKit +- Direct messages +- File uploads and image sharing +- Markdown message formatting +- Typing indicators and presence status +- Role-based permissions (owner, admin, member) +- Invite system with shareable codes +- Desktop app (Electron) +- Mobile-responsive web UI +- Docker deployment + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Backend | Fastify + TypeScript | +| Database | SQLite (better-sqlite3) + Drizzle ORM | +| Auth | JWT + bcrypt | +| Real-time | WebSocket (ws) | +| Frontend | React 18 + Tailwind CSS + Zustand | +| Voice/Video | LiveKit | +| Desktop | Electron | +| Build | Vite + pnpm workspaces | + +## Quick Start with Docker + +```bash +# Clone the repository +git clone https://github.com/your-username/opencord.git +cd opencord + +# Create environment file +cp .env.example .env + +# Generate a JWT secret +echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env + +# Start Opencord +docker compose up -d +``` + +Open `http://localhost:3000` in your browser. A default server "Opencord" is created automatically. + +**Default admin account:** `admin` / `admin123` (change this after first login). + +## Development Setup + +### Prerequisites + +- Node.js 20+ +- pnpm 8+ + +### Install + +```bash +pnpm install +``` + +### Configure + +```bash +cp .env.example .env +# Edit .env with your settings (generate a JWT_SECRET) +``` + +### Run + +```bash +# Start both server and web dev server +pnpm dev + +# Or start individually +pnpm dev:server # API server on :3000 +pnpm dev:web # Vite dev server on :5173 +``` + +### Build + +```bash +pnpm build +``` + +This builds the shared types, server, and web frontend. The server serves the built frontend in production mode. + +## Project Structure + +``` +Opencord/ +├── packages/ +│ ├── shared/ # Shared TypeScript types +│ ├── server/ # Fastify API + WebSocket server +│ ├── web/ # React frontend (Vite + Tailwind) +│ └── desktop/ # Electron desktop app +├── data/ # SQLite DB + uploads (created at runtime) +├── Dockerfile +├── docker-compose.yml +└── .env.example +``` + +## Voice & Video + +Voice and video requires a [LiveKit](https://livekit.io/) server. Set these in your `.env`: + +``` +LIVEKIT_URL=wss://your-livekit-server +LIVEKIT_API_KEY=your-api-key +LIVEKIT_API_SECRET=your-api-secret +``` + +Without LiveKit configured, text chat works fully but voice/video channels will not connect. + +## API + +The server exposes a REST API and WebSocket endpoint: + +- **REST API**: `http://localhost:3000/api/*` +- **WebSocket**: `ws://localhost:3000/ws` +- **Health check**: `GET /api/health` + +See [CLAUDE.md](CLAUDE.md) for the full API reference. + +## Desktop App + +The Electron desktop app wraps the web UI and adds system tray, notifications, and native window controls. + +```bash +cd packages/desktop +pnpm build:ts # Compile TypeScript +pnpm dev # Run in development +pnpm build # Package for distribution +``` + +## License + +MIT diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..b86af35f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: "3.8" + +services: + opencord: + build: . + container_name: opencord + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + volumes: + - opencord-data:/app/data + env_file: + - .env + environment: + - NODE_ENV=production + - DB_PATH=/app/data/opencord.db + - UPLOAD_DIR=/app/data/uploads + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + opencord-data: + driver: local diff --git a/package.json b/package.json new file mode 100644 index 00000000..3eb7e0f9 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "opencord", + "version": "1.0.0", + "private": true, + "description": "Open-source, self-hosted Discord alternative", + "scripts": { + "dev:server": "pnpm --filter @opencord/server dev", + "dev:web": "pnpm --filter @opencord/web dev", + "dev": "pnpm --filter @opencord/server dev & pnpm --filter @opencord/web dev", + "build:shared": "pnpm --filter @opencord/shared build", + "build:server": "pnpm --filter @opencord/server build", + "build:web": "pnpm --filter @opencord/web build", + "build": "pnpm --filter @opencord/shared build && pnpm --filter @opencord/server build & pnpm --filter @opencord/web build" + }, + "pnpm": { + "onlyBuiltDependencies": ["better-sqlite3", "esbuild"] + }, + "engines": { + "node": ">=20.0.0", + "pnpm": ">=8.0.0" + } +} diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml new file mode 100644 index 00000000..14d2fc3b --- /dev/null +++ b/packages/desktop/electron-builder.yml @@ -0,0 +1,26 @@ +appId: com.opencord.desktop +productName: Opencord +directories: + output: dist-electron +files: + - dist/**/* + - "!node_modules" +mac: + category: public.app-category.social-networking + target: + - dmg + - zip + icon: null +win: + target: + - nsis + icon: null +linux: + target: + - AppImage + - deb + category: Network + icon: null +nsis: + oneClick: false + allowToChangeInstallationDirectory: true diff --git a/packages/desktop/package.json b/packages/desktop/package.json new file mode 100644 index 00000000..25e41c40 --- /dev/null +++ b/packages/desktop/package.json @@ -0,0 +1,20 @@ +{ + "name": "@opencord/desktop", + "version": "1.0.0", + "private": true, + "main": "dist/main.js", + "scripts": { + "build:ts": "tsc", + "dev": "tsc && electron .", + "build": "tsc && electron-builder", + "clean": "rm -rf dist dist-electron" + }, + "dependencies": { + "electron-is-dev": "^3.0.1" + }, + "devDependencies": { + "electron": "^33.2.0", + "electron-builder": "^25.1.8", + "typescript": "^5.7.2" + } +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts new file mode 100644 index 00000000..0c00dfbc --- /dev/null +++ b/packages/desktop/src/main.ts @@ -0,0 +1,194 @@ +import { + app, + BrowserWindow, + Tray, + Menu, + Notification, + nativeImage, + ipcMain, + shell, +} from 'electron'; +import path from 'path'; + +let mainWindow: BrowserWindow | null = null; +let tray: Tray | null = null; +let isQuitting = false; + +const SERVER_URL = process.env.OPENCORD_URL || 'http://localhost:3000'; + +function createWindow(): void { + mainWindow = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 940, + minHeight: 500, + title: 'Opencord', + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + backgroundColor: '#313338', + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + mainWindow.loadURL(SERVER_URL); + + mainWindow.once('ready-to-show', () => { + mainWindow?.show(); + }); + + mainWindow.on('close', (event) => { + if (!isQuitting) { + event.preventDefault(); + mainWindow?.hide(); + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); + + // Open external links in default browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://') || url.startsWith('https://')) { + shell.openExternal(url); + } + return { action: 'deny' }; + }); +} + +function createTray(): void { + // Create a 16x16 tray icon (blue circle for Opencord) + const size = 16; + const canvas = Buffer.alloc(size * size * 4); + const cx = size / 2; + const cy = size / 2; + const r = size / 2 - 1; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const idx = (y * size + x) * 4; + const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2); + if (dist <= r) { + canvas[idx] = 0x58; // R (blurple) + canvas[idx + 1] = 0x65; // G + canvas[idx + 2] = 0xf2; // B + canvas[idx + 3] = 0xff; // A + } else { + canvas[idx] = 0; + canvas[idx + 1] = 0; + canvas[idx + 2] = 0; + canvas[idx + 3] = 0; + } + } + } + const icon = nativeImage.createFromBuffer(canvas, { width: size, height: size }); + tray = new Tray(icon); + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Show Opencord', + click: () => { + mainWindow?.show(); + mainWindow?.focus(); + }, + }, + { + label: 'Hide', + click: () => { + mainWindow?.hide(); + }, + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => { + isQuitting = true; + app.quit(); + }, + }, + ]); + + tray.setToolTip('Opencord'); + tray.setContextMenu(contextMenu); + + tray.on('click', () => { + if (mainWindow?.isVisible()) { + mainWindow.hide(); + } else { + mainWindow?.show(); + mainWindow?.focus(); + } + }); +} + +function showNotification(title: string, body: string): void { + if (Notification.isSupported()) { + const notification = new Notification({ + title, + body, + silent: false, + }); + + notification.on('click', () => { + mainWindow?.show(); + mainWindow?.focus(); + }); + + notification.show(); + } +} + +function registerIpcHandlers(): void { + ipcMain.on('show-notification', (_event, data: { title: string; body: string }) => { + showNotification(data.title, data.body); + }); + + ipcMain.on('set-badge-count', (_event, count: number) => { + if (app.setBadgeCount) { + app.setBadgeCount(count); + } + }); + + ipcMain.on('minimize-window', () => { + mainWindow?.minimize(); + }); + + ipcMain.on('maximize-window', () => { + if (mainWindow?.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow?.maximize(); + } + }); + + ipcMain.on('close-window', () => { + mainWindow?.close(); + }); +} + +app.on('ready', () => { + registerIpcHandlers(); + createWindow(); + createTray(); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('activate', () => { + if (mainWindow === null) { + createWindow(); + } else { + mainWindow.show(); + } +}); + +app.on('before-quit', () => { + isQuitting = true; +}); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts new file mode 100644 index 00000000..9e06a9d9 --- /dev/null +++ b/packages/desktop/src/preload.ts @@ -0,0 +1,20 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('opencord', { + platform: process.platform, + showNotification: (title: string, body: string) => { + ipcRenderer.send('show-notification', { title, body }); + }, + setBadgeCount: (count: number) => { + ipcRenderer.send('set-badge-count', count); + }, + minimize: () => { + ipcRenderer.send('minimize-window'); + }, + maximize: () => { + ipcRenderer.send('maximize-window'); + }, + close: () => { + ipcRenderer.send('close-window'); + }, +}); diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json new file mode 100644 index 00000000..cb4afb03 --- /dev/null +++ b/packages/desktop/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"] + }, + "include": ["src/**/*"] +} diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 00000000..d8e2c13d --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,36 @@ +{ + "name": "@opencord/server", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "node --import tsx/esm src/index.ts", + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fastify/cors": "^9.0.1", + "@fastify/multipart": "^8.3.0", + "@fastify/static": "^7.0.4", + "@fastify/websocket": "^10.0.1", + "@opencord/shared": "workspace:*", + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.3.0", + "dotenv": "^16.4.5", + "drizzle-orm": "^0.33.0", + "fastify": "^4.28.1", + "jsonwebtoken": "^9.0.2", + "livekit-server-sdk": "^2.6.1" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.11", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.14.0", + "@types/ws": "^8.18.1", + "drizzle-kit": "^0.24.0", + "tsx": "^4.16.0", + "typescript": "^5.4.0" + } +} diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts new file mode 100644 index 00000000..f305117e --- /dev/null +++ b/packages/server/src/config.ts @@ -0,0 +1,49 @@ +import { config as dotenvConfig } from 'dotenv'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +dotenvConfig({ path: resolve(__dirname, '../../../.env') }); + +function env(key: string, defaultValue?: string): string { + const value = process.env[key] ?? defaultValue; + if (value === undefined) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + +function envInt(key: string, defaultValue: number): number { + const value = process.env[key]; + if (value === undefined) return defaultValue; + const parsed = parseInt(value, 10); + if (isNaN(parsed)) { + throw new Error(`Environment variable ${key} must be a number, got: ${value}`); + } + return parsed; +} + +function envBool(key: string, defaultValue: boolean): boolean { + const value = process.env[key]; + if (value === undefined) return defaultValue; + return value === 'true' || value === '1'; +} + +export const config = { + port: envInt('PORT', 3000), + host: env('HOST', '0.0.0.0'), + jwtSecret: env('JWT_SECRET', 'dev-secret-change-me-in-production-please-use-64-chars-hex-string'), + jwtExpiresIn: env('JWT_EXPIRES_IN', '30d'), + + livekit: { + url: env('LIVEKIT_URL', 'wss://nova.ddns.net/livekit'), + apiKey: env('LIVEKIT_API_KEY', 'REDACTED_LIVEKIT_KEY'), + apiSecret: env('LIVEKIT_API_SECRET', 'REDACTED_LIVEKIT_SECRET'), + }, + + uploadDir: env('UPLOAD_DIR', resolve(__dirname, '../../../data/uploads')), + dbPath: env('DB_PATH', resolve(__dirname, '../../../data/opencord.db')), + maxUploadSize: envInt('MAX_UPLOAD_SIZE', 104857600), + registrationOpen: envBool('REGISTRATION_OPEN', true), +} as const; diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts new file mode 100644 index 00000000..ba2fd546 --- /dev/null +++ b/packages/server/src/db/index.ts @@ -0,0 +1,123 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { config } from '../config.js'; +import * as schema from './schema.js'; +import { mkdirSync } from 'fs'; +import { dirname } from 'path'; + +let sqlite: Database.Database; + +function ensureDirectory(filePath: string): void { + const dir = dirname(filePath); + mkdirSync(dir, { recursive: true }); +} + +function createTables(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + display_name TEXT, + password_hash TEXT NOT NULL, + avatar TEXT, + status TEXT DEFAULT 'offline', + custom_status TEXT, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT, + owner_id TEXT NOT NULL REFERENCES users(id), + invite_code TEXT UNIQUE, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS server_members ( + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT DEFAULT 'member', + nickname TEXT, + joined_at INTEGER NOT NULL, + PRIMARY KEY (server_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL, + topic TEXT, + position INTEGER DEFAULT 0, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id), + content TEXT, + edited_at INTEGER, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id TEXT REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + mimetype TEXT NOT NULL, + size INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS dm_channels ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS dm_members ( + dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (dm_channel_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS dm_messages ( + id TEXT PRIMARY KEY, + dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id), + content TEXT, + created_at INTEGER NOT NULL + ); + `); +} + +export function initDatabase() { + ensureDirectory(config.dbPath); + sqlite = new Database(config.dbPath); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + createTables(sqlite); + console.log(`Database initialized at ${config.dbPath}`); + return drizzle(sqlite, { schema }); +} + +export type DB = ReturnType; + +let db: DB; + +export function getDb(): DB { + if (!db) { + db = initDatabase(); + } + return db; +} + +export function closeDatabase(): void { + if (sqlite) { + sqlite.close(); + } +} + +export { schema }; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts new file mode 100644 index 00000000..9b7431ec --- /dev/null +++ b/packages/server/src/db/schema.ts @@ -0,0 +1,80 @@ +import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core'; + +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + username: text('username').unique().notNull(), + displayName: text('display_name'), + passwordHash: text('password_hash').notNull(), + avatar: text('avatar'), + status: text('status').default('offline'), + customStatus: text('custom_status'), + createdAt: integer('created_at').notNull(), +}); + +export const servers = sqliteTable('servers', { + id: text('id').primaryKey(), + name: text('name').notNull(), + icon: text('icon'), + ownerId: text('owner_id').notNull().references(() => users.id), + inviteCode: text('invite_code').unique(), + createdAt: integer('created_at').notNull(), +}); + +export const serverMembers = sqliteTable('server_members', { + serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + role: text('role').default('member'), + nickname: text('nickname'), + joinedAt: integer('joined_at').notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.serverId, table.userId] }), +})); + +export const channels = sqliteTable('channels', { + id: text('id').primaryKey(), + serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + type: text('type').notNull(), + topic: text('topic'), + position: integer('position').default(0), + createdAt: integer('created_at').notNull(), +}); + +export const messages = sqliteTable('messages', { + id: text('id').primaryKey(), + channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull().references(() => users.id), + content: text('content'), + editedAt: integer('edited_at'), + createdAt: integer('created_at').notNull(), +}); + +export const attachments = sqliteTable('attachments', { + id: text('id').primaryKey(), + messageId: text('message_id').references(() => messages.id, { onDelete: 'cascade' }), + filename: text('filename').notNull(), + originalName: text('original_name').notNull(), + mimetype: text('mimetype').notNull(), + size: integer('size').notNull(), + createdAt: integer('created_at').notNull(), +}); + +export const dmChannels = sqliteTable('dm_channels', { + id: text('id').primaryKey(), + createdAt: integer('created_at').notNull(), +}); + +export const dmMembers = sqliteTable('dm_members', { + dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), +}, (table) => ({ + pk: primaryKey({ columns: [table.dmChannelId, table.userId] }), +})); + +export const dmMessages = sqliteTable('dm_messages', { + id: text('id').primaryKey(), + dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull().references(() => users.id), + content: text('content'), + createdAt: integer('created_at').notNull(), +}); diff --git a/packages/server/src/db/seed.ts b/packages/server/src/db/seed.ts new file mode 100644 index 00000000..55d18bc4 --- /dev/null +++ b/packages/server/src/db/seed.ts @@ -0,0 +1,69 @@ +import { getDb, schema } from './index.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { hashPassword } from '../utils/auth.js'; +import { eq } from 'drizzle-orm'; + +export async function seedDatabase(): Promise { + const db = getDb(); + + const existingServers = db.select().from(schema.servers).all(); + if (existingServers.length > 0) { + console.log('Database already has data, skipping seed'); + return; + } + + console.log('Seeding database with default data...'); + + const adminId = generateSnowflake(); + const adminPasswordHash = await hashPassword('admin123'); + + db.insert(schema.users).values({ + id: adminId, + username: 'admin', + displayName: 'Admin', + passwordHash: adminPasswordHash, + status: 'offline', + createdAt: Date.now(), + }).run(); + + const serverId = generateSnowflake(); + db.insert(schema.servers).values({ + id: serverId, + name: 'Opencord', + ownerId: adminId, + inviteCode: 'opencord', + createdAt: Date.now(), + }).run(); + + db.insert(schema.serverMembers).values({ + serverId: serverId, + userId: adminId, + role: 'owner', + joinedAt: Date.now(), + }).run(); + + const generalChannelId = generateSnowflake(); + db.insert(schema.channels).values({ + id: generalChannelId, + serverId: serverId, + name: 'general', + type: 'text', + topic: 'General discussion', + position: 0, + createdAt: Date.now(), + }).run(); + + const voiceChannelId = generateSnowflake(); + db.insert(schema.channels).values({ + id: voiceChannelId, + serverId: serverId, + name: 'General Voice', + type: 'voice', + position: 1, + createdAt: Date.now(), + }).run(); + + console.log('Database seeded successfully'); + console.log(` Default server: Opencord (invite code: opencord)`); + console.log(` Admin user: admin / admin123`); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 00000000..796c4e59 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,98 @@ +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import websocket from '@fastify/websocket'; +import multipart from '@fastify/multipart'; +import fastifyStatic from '@fastify/static'; +import { config } from './config.js'; +import { initDatabase } from './db/index.js'; +import { seedDatabase } from './db/seed.js'; +import { authRoutes } from './routes/auth.js'; +import { userRoutes } from './routes/users.js'; +import { serverRoutes } from './routes/servers.js'; +import { channelRoutes } from './routes/channels.js'; +import { messageRoutes } from './routes/messages.js'; +import { uploadRoutes } from './routes/uploads.js'; +import { dmRoutes } from './routes/dm.js'; +import { livekitRoutes } from './routes/livekit.js'; +import { registerWebSocket } from './ws/handler.js'; +import path from 'path'; +import fs from 'fs'; + +async function main(): Promise { + const app = Fastify({ + logger: { + level: 'info', + }, + }); + + await app.register(cors, { + origin: true, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + }); + + await app.register(websocket); + + await app.register(multipart, { + limits: { + fileSize: config.maxUploadSize, + }, + }); + + // Serve built frontend in production + const webDistPath = path.resolve(import.meta.dirname ?? '.', '../../web/dist'); + if (fs.existsSync(webDistPath)) { + await app.register(fastifyStatic, { + root: webDistPath, + prefix: '/', + wildcard: false, + }); + } + + initDatabase(); + await seedDatabase(); + + await app.register(authRoutes); + await app.register(userRoutes); + await app.register(serverRoutes); + await app.register(channelRoutes); + await app.register(messageRoutes); + await app.register(uploadRoutes); + await app.register(dmRoutes); + await app.register(livekitRoutes); + await app.register(registerWebSocket); + + app.get('/api/health', async () => { + return { status: 'ok', timestamp: Date.now() }; + }); + + // SPA fallback - serve index.html for non-API routes + if (fs.existsSync(webDistPath)) { + app.setNotFoundHandler((request, reply) => { + if (request.url.startsWith('/api/') || request.url.startsWith('/ws')) { + return reply.code(404).send({ error: 'Not found', statusCode: 404 }); + } + return reply.sendFile('index.html'); + }); + } + + try { + await app.listen({ port: config.port, host: config.host }); + console.log(`Opencord server running at http://${config.host}:${config.port}`); + } catch (err) { + app.log.error(err); + process.exit(1); + } + + const shutdown = async () => { + console.log('Shutting down...'); + await app.close(); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main(); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts new file mode 100644 index 00000000..235a2083 --- /dev/null +++ b/packages/server/src/routes/auth.ts @@ -0,0 +1,120 @@ +import type { FastifyInstance } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { config } from '../config.js'; +import type { RegisterRequest, LoginRequest, AuthResponse, User } from '@opencord/shared'; + +function sanitizeUser(row: typeof schema.users.$inferSelect): User { + return { + id: row.id, + username: row.username, + displayName: row.displayName, + avatar: row.avatar, + status: (row.status ?? 'offline') as User['status'], + customStatus: row.customStatus, + createdAt: row.createdAt, + }; +} + +export async function authRoutes(app: FastifyInstance): Promise { + app.post<{ Body: RegisterRequest }>('/api/auth/register', async (request, reply) => { + const { username, password, displayName } = request.body; + + if (!username || typeof username !== 'string') { + return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); + } + + if (!password || typeof password !== 'string') { + return reply.code(400).send({ error: 'Password is required', statusCode: 400 }); + } + + const trimmedUsername = username.trim(); + + if (trimmedUsername.length < 3 || trimmedUsername.length > 32) { + return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 }); + } + + if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) { + return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 }); + } + + if (password.length < 6) { + return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 }); + } + + if (!config.registrationOpen) { + return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 }); + } + + const db = getDb(); + + const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmedUsername)).get(); + if (existing) { + return reply.code(409).send({ error: 'Username already taken', statusCode: 409 }); + } + + const passwordHash = await hashPassword(password); + const userId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.users).values({ + id: userId, + username: trimmedUsername, + displayName: displayName?.trim() || null, + passwordHash, + status: 'online', + createdAt: now, + }).run(); + + const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + if (!user) { + return reply.code(500).send({ error: 'Failed to create user', statusCode: 500 }); + } + + const token = signJwt({ userId: user.id, username: user.username }); + + const response: AuthResponse = { + token, + user: sanitizeUser(user), + }; + + return reply.code(201).send(response); + }); + + app.post<{ Body: LoginRequest }>('/api/auth/login', async (request, reply) => { + const { username, password } = request.body; + + if (!username || typeof username !== 'string') { + return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); + } + + if (!password || typeof password !== 'string') { + return reply.code(400).send({ error: 'Password is required', statusCode: 400 }); + } + + const db = getDb(); + + const user = db.select().from(schema.users).where(eq(schema.users.username, username.trim())).get(); + if (!user) { + return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); + } + + const validPassword = await verifyPassword(password, user.passwordHash); + if (!validPassword) { + return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); + } + + db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, user.id)).run(); + + const token = signJwt({ userId: user.id, username: user.username }); + + const response: AuthResponse = { + token, + user: sanitizeUser({ ...user, status: 'online' }), + }; + + return reply.code(200).send(response); + }); +} diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts new file mode 100644 index 00000000..7e653d53 --- /dev/null +++ b/packages/server/src/routes/channels.ts @@ -0,0 +1,188 @@ +import type { FastifyInstance } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { authenticate } from '../utils/auth.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { isMember, isAdmin, getChannelServerId } from '../utils/permissions.js'; +import type { + CreateChannelRequest, + UpdateChannelRequest, + Channel, +} from '@opencord/shared'; + +function rowToChannel(row: typeof schema.channels.$inferSelect): Channel { + return { + id: row.id, + serverId: row.serverId, + name: row.name, + type: row.type as Channel['type'], + topic: row.topic, + position: row.position ?? 0, + createdAt: row.createdAt, + }; +} + +export async function channelRoutes(app: FastifyInstance): Promise { + // GET /api/servers/:id/channels - List channels in a server + app.get<{ Params: { id: string } }>('/api/servers/:id/channels', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (!isMember(id, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + } + + const channels = db.select() + .from(schema.channels) + .where(eq(schema.channels.serverId, id)) + .all(); + + // Sort by position + channels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + + return reply.code(200).send(channels.map(rowToChannel)); + }); + + // POST /api/servers/:id/channels - Create a channel (admin+) + app.post<{ Params: { id: string }; Body: CreateChannelRequest }>('/api/servers/:id/channels', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { name, type, topic } = request.body; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (!isAdmin(id, request.userId)) { + return reply.code(403).send({ error: 'Only admins can create channels', statusCode: 403 }); + } + + if (!name || typeof name !== 'string') { + return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 }); + } + + const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-'); + if (trimmedName.length < 1 || trimmedName.length > 100) { + return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 }); + } + + if (!type || !['text', 'voice', 'video'].includes(type)) { + return reply.code(400).send({ error: 'Channel type must be "text", "voice", or "video"', statusCode: 400 }); + } + + // Get max position for ordering + const existingChannels = db.select() + .from(schema.channels) + .where(eq(schema.channels.serverId, id)) + .all(); + + const maxPosition = existingChannels.reduce((max, ch) => Math.max(max, ch.position ?? 0), -1); + + const channelId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.channels).values({ + id: channelId, + serverId: id, + name: trimmedName, + type, + topic: topic?.trim() || null, + position: maxPosition + 1, + createdAt: now, + }).run(); + + const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get(); + if (!channel) { + return reply.code(500).send({ error: 'Failed to create channel', statusCode: 500 }); + } + + return reply.code(201).send(rowToChannel(channel)); + }); + + // PATCH /api/channels/:id - Update a channel (admin+) + app.patch<{ Params: { id: string }; Body: UpdateChannelRequest }>('/api/channels/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { name, topic, position } = request.body; + const db = getDb(); + + const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get(); + if (!channel) { + return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); + } + + const serverId = channel.serverId; + if (!isAdmin(serverId, request.userId)) { + return reply.code(403).send({ error: 'Only admins can update channels', statusCode: 403 }); + } + + const updates: Partial = {}; + + if (name !== undefined) { + const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-'); + if (trimmedName.length < 1 || trimmedName.length > 100) { + return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 }); + } + updates.name = trimmedName; + } + + if (topic !== undefined) { + updates.topic = topic.trim() || null; + } + + if (position !== undefined) { + if (typeof position !== 'number' || position < 0) { + return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 }); + } + updates.position = position; + } + + if (Object.keys(updates).length === 0) { + return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); + } + + db.update(schema.channels).set(updates).where(eq(schema.channels.id, id)).run(); + + const updated = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get(); + if (!updated) { + return reply.code(500).send({ error: 'Failed to update channel', statusCode: 500 }); + } + + return reply.code(200).send(rowToChannel(updated)); + }); + + // DELETE /api/channels/:id - Delete a channel (admin+) + app.delete<{ Params: { id: string } }>('/api/channels/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get(); + if (!channel) { + return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); + } + + const serverId = channel.serverId; + if (!isAdmin(serverId, request.userId)) { + return reply.code(403).send({ error: 'Only admins can delete channels', statusCode: 403 }); + } + + // Delete messages in channel (attachments cascade), then channel + db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run(); + db.delete(schema.channels).where(eq(schema.channels.id, id)).run(); + + return reply.code(200).send({ success: true }); + }); +} diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts new file mode 100644 index 00000000..bf10778c --- /dev/null +++ b/packages/server/src/routes/dm.ts @@ -0,0 +1,313 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and, desc, inArray } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { authenticate } from '../utils/auth.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { isDmMember } from '../utils/permissions.js'; +import { connectionManager } from '../ws/handler.js'; +import type { + User, + DmChannel, + DmMessage, + DmMessageWithUser, + CreateDmRequest, + CreateDmMessageRequest, + PaginatedQuery, +} from '@opencord/shared'; + +function sanitizeUser(row: typeof schema.users.$inferSelect): User { + return { + id: row.id, + username: row.username, + displayName: row.displayName, + avatar: row.avatar, + status: (row.status ?? 'offline') as User['status'], + customStatus: row.customStatus, + createdAt: row.createdAt, + }; +} + +export async function dmRoutes(app: FastifyInstance): Promise { + // GET /api/dm - List user's DM channels + app.get('/api/dm', { + preHandler: authenticate, + }, async (request, reply) => { + const db = getDb(); + + const memberships = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.userId, request.userId)) + .all(); + + const dmChannels: DmChannel[] = []; + + for (const membership of memberships) { + const dmChannel = db.select() + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, membership.dmChannelId)) + .get(); + + if (!dmChannel) continue; + + const dmMemberRows = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, membership.dmChannelId)) + .all(); + + const memberUserIds = dmMemberRows.map(m => m.userId); + const users = memberUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() + : []; + + // Get last message + const allMessages = db.select() + .from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, membership.dmChannelId)) + .orderBy(desc(schema.dmMessages.createdAt)) + .limit(1) + .all(); + + const lastMessage = allMessages[0] ?? null; + + dmChannels.push({ + id: dmChannel.id, + createdAt: dmChannel.createdAt, + members: users.map(sanitizeUser), + lastMessage: lastMessage ? { + id: lastMessage.id, + dmChannelId: lastMessage.dmChannelId, + userId: lastMessage.userId, + content: lastMessage.content, + createdAt: lastMessage.createdAt, + } : null, + }); + } + + // Sort by last message timestamp (newest first) + dmChannels.sort((a, b) => { + const aTime = a.lastMessage?.createdAt ?? a.createdAt; + const bTime = b.lastMessage?.createdAt ?? b.createdAt; + return bTime - aTime; + }); + + return reply.code(200).send(dmChannels); + }); + + // POST /api/dm - Create or get existing DM channel + app.post<{ Body: CreateDmRequest }>('/api/dm', { + preHandler: authenticate, + }, async (request, reply) => { + const { userId } = request.body; + + if (!userId || typeof userId !== 'string') { + return reply.code(400).send({ error: 'userId is required', statusCode: 400 }); + } + + if (userId === request.userId) { + return reply.code(400).send({ error: 'Cannot create DM with yourself', statusCode: 400 }); + } + + const db = getDb(); + + // Check if target user exists + const targetUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + if (!targetUser) { + return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + } + + // Check if DM channel already exists between these two users + const myDms = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.userId, request.userId)) + .all(); + + for (const myDm of myDms) { + const otherMember = db.select() + .from(schema.dmMembers) + .where(and( + eq(schema.dmMembers.dmChannelId, myDm.dmChannelId), + eq(schema.dmMembers.userId, userId), + )) + .get(); + + if (otherMember) { + // DM channel already exists + const dmChannel = db.select() + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, myDm.dmChannelId)) + .get(); + + if (!dmChannel) continue; + + const dmMemberRows = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, myDm.dmChannelId)) + .all(); + + const memberUserIds = dmMemberRows.map(m => m.userId); + const users = db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all(); + + const result: DmChannel = { + id: dmChannel.id, + createdAt: dmChannel.createdAt, + members: users.map(sanitizeUser), + lastMessage: null, + }; + + return reply.code(200).send(result); + } + } + + // Create new DM channel + const dmChannelId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.dmChannels).values({ + id: dmChannelId, + createdAt: now, + }).run(); + + db.insert(schema.dmMembers).values({ + dmChannelId, + userId: request.userId, + }).run(); + + db.insert(schema.dmMembers).values({ + dmChannelId, + userId, + }).run(); + + const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + const members = [currentUserRow, targetUser] + .filter((u): u is NonNullable => u !== undefined) + .map(sanitizeUser); + + const result: DmChannel = { + id: dmChannelId, + createdAt: now, + members, + lastMessage: null, + }; + + return reply.code(201).send(result); + }); + + // GET /api/dm/:id/messages - Get DM messages with pagination + app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const before = request.query.before; + const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100); + + if (!isDmMember(id, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); + } + + const db = getDb(); + + let messageRows: (typeof schema.dmMessages.$inferSelect)[]; + + if (before) { + messageRows = db.select() + .from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, id)) + .orderBy(desc(schema.dmMessages.createdAt)) + .all() + .filter(m => m.id < before) + .slice(0, limit); + } else { + messageRows = db.select() + .from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, id)) + .orderBy(desc(schema.dmMessages.createdAt)) + .limit(limit) + .all(); + } + + messageRows.reverse(); + + if (messageRows.length === 0) { + return reply.code(200).send([]); + } + + // Batch fetch users + const userIds = [...new Set(messageRows.map(m => m.userId))]; + const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all(); + const userMap = new Map(users.map(u => [u.id, u])); + + const messages: DmMessageWithUser[] = messageRows + .map(m => { + const user = userMap.get(m.userId); + if (!user) return null; + return { + id: m.id, + dmChannelId: m.dmChannelId, + userId: m.userId, + content: m.content, + createdAt: m.createdAt, + user: sanitizeUser(user), + }; + }) + .filter((m): m is DmMessageWithUser => m !== null); + + return reply.code(200).send(messages); + }); + + // POST /api/dm/:id/messages - Send a DM message + app.post<{ Params: { id: string }; Body: CreateDmMessageRequest }>('/api/dm/:id/messages', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { content } = request.body; + + if (!isDmMember(id, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); + } + + if (!content || typeof content !== 'string' || content.trim().length === 0) { + return reply.code(400).send({ error: 'Message content is required', statusCode: 400 }); + } + + const db = getDb(); + const messageId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.dmMessages).values({ + id: messageId, + dmChannelId: id, + userId: request.userId, + content: content.trim(), + createdAt: now, + }).run(); + + const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!user) { + return reply.code(500).send({ error: 'User not found', statusCode: 500 }); + } + + const message: DmMessageWithUser = { + id: messageId, + dmChannelId: id, + userId: request.userId, + content: content.trim(), + createdAt: now, + user: sanitizeUser(user), + }; + + // Broadcast via WebSocket to all DM members + const dmMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, id)) + .all(); + + for (const member of dmMembers) { + connectionManager.sendToUser(member.userId, { + type: 'dm_message_created', + message, + }); + } + + return reply.code(201).send(message); + }); +} diff --git a/packages/server/src/routes/livekit.ts b/packages/server/src/routes/livekit.ts new file mode 100644 index 00000000..59a75df2 --- /dev/null +++ b/packages/server/src/routes/livekit.ts @@ -0,0 +1,47 @@ +import type { FastifyInstance } from 'fastify'; +import { AccessToken } from 'livekit-server-sdk'; +import { authenticate } from '../utils/auth.js'; +import { config } from '../config.js'; +import { getChannelServerId, isMember } from '../utils/permissions.js'; +import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@opencord/shared'; + +export async function livekitRoutes(app: FastifyInstance): Promise { + app.post<{ Body: LiveKitTokenRequest }>('/api/livekit/token', { + preHandler: authenticate, + }, async (request, reply) => { + const { channelId } = request.body; + + if (!channelId || typeof channelId !== 'string') { + return reply.code(400).send({ error: 'channelId is required', statusCode: 400 }); + } + + const serverId = getChannelServerId(channelId); + if (!serverId) { + return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); + } + + if (!isMember(serverId, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + } + + const identity = `${request.userId}:${request.username}`; + + const token = new AccessToken(config.livekit.apiKey, config.livekit.apiSecret, { + identity, + ttl: '1h', + }); + + token.addGrant({ + room: channelId, + roomJoin: true, + canPublish: true, + canSubscribe: true, + canPublishData: true, + }); + + const jwt = await token.toJwt(); + + const response: LiveKitTokenResponse = { token: jwt }; + return reply.code(200).send(response); + }); +} diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts new file mode 100644 index 00000000..eae4e708 --- /dev/null +++ b/packages/server/src/routes/messages.ts @@ -0,0 +1,294 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, lt, desc, inArray } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { authenticate } from '../utils/auth.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { isMember, getChannelServerId, isAdmin } from '../utils/permissions.js'; +import { connectionManager } from '../ws/handler.js'; +import type { + CreateMessageRequest, + UpdateMessageRequest, + PaginatedQuery, + User, + MessageWithUser, + Attachment, +} from '@opencord/shared'; + +function sanitizeUser(row: typeof schema.users.$inferSelect): User { + return { + id: row.id, + username: row.username, + displayName: row.displayName, + avatar: row.avatar, + status: (row.status ?? 'offline') as User['status'], + customStatus: row.customStatus, + createdAt: row.createdAt, + }; +} + +function buildMessageWithUser( + message: typeof schema.messages.$inferSelect, + user: typeof schema.users.$inferSelect, + attachmentRows: (typeof schema.attachments.$inferSelect)[], +): MessageWithUser { + return { + id: message.id, + channelId: message.channelId, + userId: message.userId, + content: message.content, + editedAt: message.editedAt, + createdAt: message.createdAt, + user: sanitizeUser(user), + attachments: attachmentRows.map(a => ({ + id: a.id, + messageId: a.messageId ?? message.id, + filename: a.filename, + originalName: a.originalName, + mimetype: a.mimetype, + size: a.size, + createdAt: a.createdAt, + })), + }; +} + +export async function messageRoutes(app: FastifyInstance): Promise { + // GET /api/channels/:id/messages - Get messages with cursor pagination + app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/channels/:id/messages', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const before = request.query.before; + const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100); + + const serverId = getChannelServerId(id); + if (!serverId) { + return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); + } + + if (!isMember(serverId, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + } + + const db = getDb(); + + let messageRows: (typeof schema.messages.$inferSelect)[]; + + if (before) { + messageRows = db.select() + .from(schema.messages) + .where(eq(schema.messages.channelId, id)) + .orderBy(desc(schema.messages.createdAt)) + .all() + .filter(m => m.id < before) + .slice(0, limit); + } else { + messageRows = db.select() + .from(schema.messages) + .where(eq(schema.messages.channelId, id)) + .orderBy(desc(schema.messages.createdAt)) + .limit(limit) + .all(); + } + + // Reverse to get chronological order + messageRows.reverse(); + + if (messageRows.length === 0) { + return reply.code(200).send([]); + } + + // Batch fetch users + const userIds = [...new Set(messageRows.map(m => m.userId))]; + const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all(); + const userMap = new Map(users.map(u => [u.id, u])); + + // Batch fetch attachments + const messageIds = messageRows.map(m => m.id); + const allAttachments = db.select() + .from(schema.attachments) + .where(inArray(schema.attachments.messageId, messageIds)) + .all(); + + const attachmentMap = new Map(); + for (const att of allAttachments) { + const mid = att.messageId ?? ''; + if (!attachmentMap.has(mid)) { + attachmentMap.set(mid, []); + } + attachmentMap.get(mid)!.push(att); + } + + const messages: MessageWithUser[] = messageRows + .map(m => { + const user = userMap.get(m.userId); + if (!user) return null; + return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? []); + }) + .filter((m): m is MessageWithUser => m !== null); + + return reply.code(200).send(messages); + }); + + // POST /api/channels/:id/messages - Create a message + app.post<{ Params: { id: string }; Body: CreateMessageRequest }>('/api/channels/:id/messages', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { content, attachments: attachmentIds } = request.body; + + const serverId = getChannelServerId(id); + if (!serverId) { + return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); + } + + if (!isMember(serverId, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + } + + if ((!content || typeof content !== 'string' || content.trim().length === 0) && + (!attachmentIds || attachmentIds.length === 0)) { + return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 }); + } + + const db = getDb(); + const messageId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.messages).values({ + id: messageId, + channelId: id, + userId: request.userId, + content: content?.trim() || null, + createdAt: now, + }).run(); + + // Link attachments to message + if (attachmentIds && attachmentIds.length > 0) { + for (const attId of attachmentIds) { + db.update(schema.attachments) + .set({ messageId }) + .where(eq(schema.attachments.id, attId)) + .run(); + } + } + + const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!user) { + return reply.code(500).send({ error: 'User not found', statusCode: 500 }); + } + + const attachmentRows = db.select() + .from(schema.attachments) + .where(eq(schema.attachments.messageId, messageId)) + .all(); + + const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get(); + if (!message) { + return reply.code(500).send({ error: 'Failed to create message', statusCode: 500 }); + } + + const messageWithUser = buildMessageWithUser(message, user, attachmentRows); + + // Broadcast via WebSocket + connectionManager.sendToServer(serverId, { + type: 'message_created', + message: messageWithUser, + }); + + return reply.code(201).send(messageWithUser); + }); + + // PATCH /api/messages/:id - Edit a message (author only) + app.patch<{ Params: { id: string }; Body: UpdateMessageRequest }>('/api/messages/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { content } = request.body; + + if (!content || typeof content !== 'string' || content.trim().length === 0) { + return reply.code(400).send({ error: 'Content is required', statusCode: 400 }); + } + + const db = getDb(); + const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get(); + if (!message) { + return reply.code(404).send({ error: 'Message not found', statusCode: 404 }); + } + + if (message.userId !== request.userId) { + return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 }); + } + + const now = Date.now(); + db.update(schema.messages) + .set({ content: content.trim(), editedAt: now }) + .where(eq(schema.messages.id, id)) + .run(); + + const updatedMessage = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get(); + if (!updatedMessage) { + return reply.code(500).send({ error: 'Failed to update message', statusCode: 500 }); + } + + const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get(); + if (!user) { + return reply.code(500).send({ error: 'User not found', statusCode: 500 }); + } + + const attachmentRows = db.select() + .from(schema.attachments) + .where(eq(schema.attachments.messageId, id)) + .all(); + + const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows); + + // Broadcast edit + const serverId = getChannelServerId(message.channelId); + if (serverId) { + connectionManager.sendToServer(serverId, { + type: 'message_updated', + message: messageWithUser, + }); + } + + return reply.code(200).send(messageWithUser); + }); + + // DELETE /api/messages/:id - Delete a message (author or admin) + app.delete<{ Params: { id: string } }>('/api/messages/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get(); + if (!message) { + return reply.code(404).send({ error: 'Message not found', statusCode: 404 }); + } + + const serverId = getChannelServerId(message.channelId); + if (!serverId) { + return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); + } + + const isAuthor = message.userId === request.userId; + const isAdminUser = isAdmin(serverId, request.userId); + + if (!isAuthor && !isAdminUser) { + return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 }); + } + + // Delete attachments then message + db.delete(schema.attachments).where(eq(schema.attachments.messageId, id)).run(); + db.delete(schema.messages).where(eq(schema.messages.id, id)).run(); + + // Broadcast deletion + connectionManager.sendToServer(serverId, { + type: 'message_deleted', + messageId: id, + channelId: message.channelId, + }); + + return reply.code(200).send({ success: true }); + }); +} diff --git a/packages/server/src/routes/servers.ts b/packages/server/src/routes/servers.ts new file mode 100644 index 00000000..6d08ec5e --- /dev/null +++ b/packages/server/src/routes/servers.ts @@ -0,0 +1,528 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and, inArray } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { authenticate } from '../utils/auth.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { isMember, isOwner, isAdmin } from '../utils/permissions.js'; +import crypto from 'crypto'; +import type { + CreateServerRequest, + UpdateServerRequest, + JoinServerRequest, + UpdateMemberRequest, + User, + Server, + Channel, + MemberWithUser, + ServerWithChannelsAndMembers, +} from '@opencord/shared'; + +function sanitizeUser(row: typeof schema.users.$inferSelect): User { + return { + id: row.id, + username: row.username, + displayName: row.displayName, + avatar: row.avatar, + status: (row.status ?? 'offline') as User['status'], + customStatus: row.customStatus, + createdAt: row.createdAt, + }; +} + +function rowToServer(row: typeof schema.servers.$inferSelect): Server { + return { + id: row.id, + name: row.name, + icon: row.icon, + ownerId: row.ownerId, + inviteCode: row.inviteCode, + createdAt: row.createdAt, + }; +} + +function rowToChannel(row: typeof schema.channels.$inferSelect): Channel { + return { + id: row.id, + serverId: row.serverId, + name: row.name, + type: row.type as Channel['type'], + topic: row.topic, + position: row.position ?? 0, + createdAt: row.createdAt, + }; +} + +function generateInviteCode(): string { + return crypto.randomBytes(4).toString('hex'); +} + +export async function serverRoutes(app: FastifyInstance): Promise { + // POST /api/servers - Create a new server + app.post<{ Body: CreateServerRequest }>('/api/servers', { + preHandler: authenticate, + }, async (request, reply) => { + const { name, icon } = request.body; + + if (!name || typeof name !== 'string') { + return reply.code(400).send({ error: 'Server name is required', statusCode: 400 }); + } + + const trimmedName = name.trim(); + if (trimmedName.length < 1 || trimmedName.length > 100) { + return reply.code(400).send({ error: 'Server name must be between 1 and 100 characters', statusCode: 400 }); + } + + const db = getDb(); + const serverId = generateSnowflake(); + const channelId = generateSnowflake(); + const now = Date.now(); + const inviteCode = generateInviteCode(); + + // Create the server + db.insert(schema.servers).values({ + id: serverId, + name: trimmedName, + icon: icon ?? null, + ownerId: request.userId, + inviteCode, + createdAt: now, + }).run(); + + // Add owner as member with 'owner' role + db.insert(schema.serverMembers).values({ + serverId, + userId: request.userId, + role: 'owner', + joinedAt: now, + }).run(); + + // Create default #general text channel + db.insert(schema.channels).values({ + id: channelId, + serverId, + name: 'general', + type: 'text', + position: 0, + createdAt: now, + }).run(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get(); + if (!server) { + return reply.code(500).send({ error: 'Failed to create server', statusCode: 500 }); + } + + return reply.code(201).send(rowToServer(server)); + }); + + // GET /api/servers - List user's servers + app.get('/api/servers', { + preHandler: authenticate, + }, async (request, reply) => { + const db = getDb(); + + const memberships = db.select() + .from(schema.serverMembers) + .where(eq(schema.serverMembers.userId, request.userId)) + .all(); + + if (memberships.length === 0) { + return reply.code(200).send([]); + } + + const serverIds = memberships.map(m => m.serverId); + const servers = db.select() + .from(schema.servers) + .where(inArray(schema.servers.id, serverIds)) + .all(); + + return reply.code(200).send(servers.map(rowToServer)); + }); + + // GET /api/servers/:id - Get server detail with channels and members + app.get<{ Params: { id: string } }>('/api/servers/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (!isMember(id, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + } + + const channels = db.select() + .from(schema.channels) + .where(eq(schema.channels.serverId, id)) + .all(); + + const memberRows = db.select() + .from(schema.serverMembers) + .where(eq(schema.serverMembers.serverId, id)) + .all(); + + const memberUserIds = memberRows.map(m => m.userId); + const users = memberUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() + : []; + + const userMap = new Map(users.map(u => [u.id, u])); + + const members: MemberWithUser[] = memberRows + .map(m => { + const user = userMap.get(m.userId); + if (!user) return null; + return { + serverId: m.serverId, + userId: m.userId, + role: (m.role ?? 'member') as MemberWithUser['role'], + nickname: m.nickname, + joinedAt: m.joinedAt, + user: sanitizeUser(user), + }; + }) + .filter((m): m is MemberWithUser => m !== null); + + const result: ServerWithChannelsAndMembers = { + ...rowToServer(server), + channels: channels.map(rowToChannel), + members, + }; + + return reply.code(200).send(result); + }); + + // PATCH /api/servers/:id - Update server (owner only) + app.patch<{ Params: { id: string }; Body: UpdateServerRequest }>('/api/servers/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { name, icon } = request.body; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (!isOwner(id, request.userId)) { + return reply.code(403).send({ error: 'Only the server owner can update the server', statusCode: 403 }); + } + + const updates: Partial = {}; + + if (name !== undefined) { + const trimmedName = name.trim(); + if (trimmedName.length < 1 || trimmedName.length > 100) { + return reply.code(400).send({ error: 'Server name must be between 1 and 100 characters', statusCode: 400 }); + } + updates.name = trimmedName; + } + + if (icon !== undefined) { + updates.icon = icon; + } + + if (Object.keys(updates).length === 0) { + return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); + } + + db.update(schema.servers).set(updates).where(eq(schema.servers.id, id)).run(); + + const updated = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!updated) { + return reply.code(500).send({ error: 'Failed to update server', statusCode: 500 }); + } + + return reply.code(200).send(rowToServer(updated)); + }); + + // DELETE /api/servers/:id - Delete server (owner only) + app.delete<{ Params: { id: string } }>('/api/servers/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (!isOwner(id, request.userId)) { + return reply.code(403).send({ error: 'Only the server owner can delete the server', statusCode: 403 }); + } + + // Delete all channels (messages cascade), members, then server + db.delete(schema.channels).where(eq(schema.channels.serverId, id)).run(); + db.delete(schema.serverMembers).where(eq(schema.serverMembers.serverId, id)).run(); + db.delete(schema.servers).where(eq(schema.servers.id, id)).run(); + + return reply.code(200).send({ success: true }); + }); + + // POST /api/servers/:id/invite - Generate invite code (admin+) + app.post<{ Params: { id: string } }>('/api/servers/:id/invite', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (!isAdmin(id, request.userId)) { + return reply.code(403).send({ error: 'Only admins can generate invite codes', statusCode: 403 }); + } + + const inviteCode = generateInviteCode(); + db.update(schema.servers).set({ inviteCode }).where(eq(schema.servers.id, id)).run(); + + return reply.code(200).send({ inviteCode }); + }); + + // POST /api/servers/:id/join - Join server by invite code + app.post<{ Params: { id: string }; Body: JoinServerRequest }>('/api/servers/:id/join', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { inviteCode } = request.body; + + if (!inviteCode || typeof inviteCode !== 'string') { + return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 }); + } + + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (server.inviteCode !== inviteCode) { + return reply.code(400).send({ error: 'Invalid invite code', statusCode: 400 }); + } + + if (isMember(id, request.userId)) { + return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 }); + } + + const now = Date.now(); + db.insert(schema.serverMembers).values({ + serverId: id, + userId: request.userId, + role: 'member', + joinedAt: now, + }).run(); + + return reply.code(200).send(rowToServer(server)); + }); + + // POST /api/servers/join - Join server by invite code (no server ID needed) + app.post<{ Body: JoinServerRequest }>('/api/servers/join', { + preHandler: authenticate, + }, async (request, reply) => { + const { inviteCode } = request.body; + + if (!inviteCode || typeof inviteCode !== 'string') { + return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 }); + } + + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.inviteCode, inviteCode)).get(); + if (!server) { + return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 }); + } + + if (isMember(server.id, request.userId)) { + return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 }); + } + + const now = Date.now(); + db.insert(schema.serverMembers).values({ + serverId: server.id, + userId: request.userId, + role: 'member', + joinedAt: now, + }).run(); + + return reply.code(200).send(rowToServer(server)); + }); + + // GET /api/servers/:id/members - List server members + app.get<{ Params: { id: string } }>('/api/servers/:id/members', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (!isMember(id, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + } + + const memberRows = db.select() + .from(schema.serverMembers) + .where(eq(schema.serverMembers.serverId, id)) + .all(); + + const memberUserIds = memberRows.map(m => m.userId); + const users = memberUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() + : []; + + const userMap = new Map(users.map(u => [u.id, u])); + + const members: MemberWithUser[] = memberRows + .map(m => { + const user = userMap.get(m.userId); + if (!user) return null; + return { + serverId: m.serverId, + userId: m.userId, + role: (m.role ?? 'member') as MemberWithUser['role'], + nickname: m.nickname, + joinedAt: m.joinedAt, + user: sanitizeUser(user), + }; + }) + .filter((m): m is MemberWithUser => m !== null); + + return reply.code(200).send(members); + }); + + // PATCH /api/servers/:id/members/:uid - Update member role (owner only) + app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/servers/:id/members/:uid', { + preHandler: authenticate, + }, async (request, reply) => { + const { id, uid } = request.params; + const { role } = request.body; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (!isOwner(id, request.userId)) { + return reply.code(403).send({ error: 'Only the server owner can change member roles', statusCode: 403 }); + } + + if (uid === request.userId) { + return reply.code(400).send({ error: 'You cannot change your own role', statusCode: 400 }); + } + + if (!role || !['admin', 'member'].includes(role)) { + return reply.code(400).send({ error: 'Role must be "admin" or "member"', statusCode: 400 }); + } + + const member = db.select() + .from(schema.serverMembers) + .where(and( + eq(schema.serverMembers.serverId, id), + eq(schema.serverMembers.userId, uid), + )) + .get(); + + if (!member) { + return reply.code(404).send({ error: 'Member not found', statusCode: 404 }); + } + + db.update(schema.serverMembers) + .set({ role }) + .where(and( + eq(schema.serverMembers.serverId, id), + eq(schema.serverMembers.userId, uid), + )) + .run(); + + const updatedMember = db.select() + .from(schema.serverMembers) + .where(and( + eq(schema.serverMembers.serverId, id), + eq(schema.serverMembers.userId, uid), + )) + .get(); + + if (!updatedMember) { + return reply.code(500).send({ error: 'Failed to update member', statusCode: 500 }); + } + + const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get(); + if (!user) { + return reply.code(500).send({ error: 'User not found', statusCode: 500 }); + } + + const result: MemberWithUser = { + serverId: updatedMember.serverId, + userId: updatedMember.userId, + role: (updatedMember.role ?? 'member') as MemberWithUser['role'], + nickname: updatedMember.nickname, + joinedAt: updatedMember.joinedAt, + user: sanitizeUser(user), + }; + + return reply.code(200).send(result); + }); + + // DELETE /api/servers/:id/members/:uid - Kick member (owner) or leave (self) + app.delete<{ Params: { id: string; uid: string } }>('/api/servers/:id/members/:uid', { + preHandler: authenticate, + }, async (request, reply) => { + const { id, uid } = request.params; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + const isSelf = uid === request.userId; + const isServerOwnerUser = isOwner(id, request.userId); + + if (!isSelf && !isServerOwnerUser) { + return reply.code(403).send({ error: 'Only the server owner can kick members', statusCode: 403 }); + } + + // Owner cannot leave their own server - they must delete it + if (isSelf && isServerOwnerUser) { + return reply.code(400).send({ error: 'Server owner cannot leave. Transfer ownership or delete the server.', statusCode: 400 }); + } + + const member = db.select() + .from(schema.serverMembers) + .where(and( + eq(schema.serverMembers.serverId, id), + eq(schema.serverMembers.userId, uid), + )) + .get(); + + if (!member) { + return reply.code(404).send({ error: 'Member not found', statusCode: 404 }); + } + + // Cannot kick the owner + if (member.role === 'owner') { + return reply.code(400).send({ error: 'Cannot remove the server owner', statusCode: 400 }); + } + + db.delete(schema.serverMembers) + .where(and( + eq(schema.serverMembers.serverId, id), + eq(schema.serverMembers.userId, uid), + )) + .run(); + + return reply.code(200).send({ success: true }); + }); +} diff --git a/packages/server/src/routes/uploads.ts b/packages/server/src/routes/uploads.ts new file mode 100644 index 00000000..4face880 --- /dev/null +++ b/packages/server/src/routes/uploads.ts @@ -0,0 +1,106 @@ +import type { FastifyInstance } from 'fastify'; +import { authenticate } from '../utils/auth.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { config } from '../config.js'; +import { getDb, schema } from '../db/index.js'; +import { eq } from 'drizzle-orm'; +import fs from 'fs'; +import path from 'path'; +import { pipeline } from 'stream/promises'; +import type { Attachment } from '@opencord/shared'; + +export async function uploadRoutes(app: FastifyInstance): Promise { + // Ensure upload directory exists + if (!fs.existsSync(config.uploadDir)) { + fs.mkdirSync(config.uploadDir, { recursive: true }); + } + + // POST /api/uploads - Upload a file + app.post('/api/uploads', { + preHandler: authenticate, + }, async (request, reply) => { + const data = await request.file(); + if (!data) { + return reply.code(400).send({ error: 'No file provided', statusCode: 400 }); + } + + const originalName = data.filename; + const mimetype = data.mimetype; + + // Generate unique filename + const id = generateSnowflake(); + const ext = path.extname(originalName); + const filename = `${id}${ext}`; + const filepath = path.join(config.uploadDir, filename); + + // Save file to disk + const writeStream = fs.createWriteStream(filepath); + await pipeline(data.file, writeStream); + + // Get file size + const stats = fs.statSync(filepath); + const size = stats.size; + + // Check size limit + if (size > config.maxUploadSize) { + fs.unlinkSync(filepath); + return reply.code(413).send({ error: 'File too large', statusCode: 413 }); + } + + const now = Date.now(); + const db = getDb(); + + // Save attachment record + db.insert(schema.attachments).values({ + id, + filename, + originalName, + mimetype, + size, + createdAt: now, + }).run(); + + const attachment: Attachment = { + id, + messageId: '', + filename, + originalName, + mimetype, + size, + createdAt: now, + }; + + return reply.code(201).send(attachment); + }); + + // GET /api/uploads/:filename - Serve uploaded file + app.get<{ Params: { filename: string } }>('/api/uploads/:filename', async (request, reply) => { + const { filename } = request.params; + + // Prevent directory traversal + const safeName = path.basename(filename); + const filepath = path.join(config.uploadDir, safeName); + + if (!fs.existsSync(filepath)) { + return reply.code(404).send({ error: 'File not found', statusCode: 404 }); + } + + // Get mimetype from DB or guess from extension + const db = getDb(); + const attachment = db.select().from(schema.attachments).where(eq(schema.attachments.filename, safeName)).get(); + const mimetype = attachment?.mimetype ?? 'application/octet-stream'; + const originalName = attachment?.originalName ?? safeName; + + // Set caching headers + reply.header('Cache-Control', 'public, max-age=31536000, immutable'); + reply.header('Content-Type', mimetype); + + // For non-image files, set Content-Disposition to download + if (!mimetype.startsWith('image/') && !mimetype.startsWith('video/') && !mimetype.startsWith('audio/')) { + reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`); + } + + const stream = fs.createReadStream(filepath); + return reply.send(stream); + }); +} diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts new file mode 100644 index 00000000..549337e6 --- /dev/null +++ b/packages/server/src/routes/users.ts @@ -0,0 +1,90 @@ +import type { FastifyInstance } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { authenticate } from '../utils/auth.js'; +import type { User, UpdateUserRequest } from '@opencord/shared'; + +function sanitizeUser(row: typeof schema.users.$inferSelect): User { + return { + id: row.id, + username: row.username, + displayName: row.displayName, + avatar: row.avatar, + status: (row.status ?? 'offline') as User['status'], + customStatus: row.customStatus, + createdAt: row.createdAt, + }; +} + +export async function userRoutes(app: FastifyInstance): Promise { + app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { + const db = getDb(); + + const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!user) { + return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + } + + return reply.code(200).send(sanitizeUser(user)); + }); + + app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { + const { displayName, avatar, customStatus } = request.body; + const db = getDb(); + + const updateData: Record = {}; + + if (displayName !== undefined) { + if (displayName !== null && typeof displayName === 'string') { + const trimmed = displayName.trim(); + if (trimmed.length > 32) { + return reply.code(400).send({ error: 'Display name must be 32 characters or less', statusCode: 400 }); + } + updateData.displayName = trimmed || null; + } else { + updateData.displayName = null; + } + } + + if (avatar !== undefined) { + updateData.avatar = avatar; + } + + if (customStatus !== undefined) { + if (customStatus !== null && typeof customStatus === 'string') { + const trimmed = customStatus.trim(); + if (trimmed.length > 128) { + return reply.code(400).send({ error: 'Custom status must be 128 characters or less', statusCode: 400 }); + } + updateData.customStatus = trimmed || null; + } else { + updateData.customStatus = null; + } + } + + if (Object.keys(updateData).length === 0) { + return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); + } + + db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run(); + + const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!updatedUser) { + return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + } + + return reply.code(200).send(sanitizeUser(updatedUser)); + }); + + app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get(); + if (!user) { + return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + } + + return reply.code(200).send(sanitizeUser(user)); + }); +} diff --git a/packages/server/src/utils/auth.ts b/packages/server/src/utils/auth.ts new file mode 100644 index 00000000..c946972d --- /dev/null +++ b/packages/server/src/utils/auth.ts @@ -0,0 +1,58 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { config } from '../config.js'; +import type { FastifyRequest, FastifyReply } from 'fastify'; + +const SALT_ROUNDS = 12; + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} + +export interface JwtPayload { + userId: string; + username: string; +} + +export function signJwt(payload: JwtPayload): string { + const options: jwt.SignOptions = { + expiresIn: config.jwtExpiresIn as unknown as jwt.SignOptions['expiresIn'], + }; + return jwt.sign(payload, config.jwtSecret, options); +} + +export function verifyJwt(token: string): JwtPayload { + const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload; + return decoded; +} + +export async function authenticate( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + const authHeader = request.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + reply.code(401).send({ error: 'Missing or invalid authorization header', statusCode: 401 }); + return; + } + + const token = authHeader.slice(7); + try { + const payload = verifyJwt(token); + (request as FastifyRequest & { userId: string; username: string }).userId = payload.userId; + (request as FastifyRequest & { userId: string; username: string }).username = payload.username; + } catch { + reply.code(401).send({ error: 'Invalid or expired token', statusCode: 401 }); + } +} + +declare module 'fastify' { + interface FastifyRequest { + userId: string; + username: string; + } +} diff --git a/packages/server/src/utils/permissions.ts b/packages/server/src/utils/permissions.ts new file mode 100644 index 00000000..26df0734 --- /dev/null +++ b/packages/server/src/utils/permissions.ts @@ -0,0 +1,55 @@ +import { eq, and } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import type { MemberRole } from '@opencord/shared'; + +export function getMember(serverId: string, userId: string) { + const db = getDb(); + return db.select().from(schema.serverMembers) + .where(and( + eq(schema.serverMembers.serverId, serverId), + eq(schema.serverMembers.userId, userId), + )) + .get(); +} + +export function isMember(serverId: string, userId: string): boolean { + return getMember(serverId, userId) !== undefined; +} + +export function getMemberRole(serverId: string, userId: string): MemberRole | null { + const member = getMember(serverId, userId); + return member ? (member.role as MemberRole) : null; +} + +export function isOwner(serverId: string, userId: string): boolean { + const role = getMemberRole(serverId, userId); + return role === 'owner'; +} + +export function isAdmin(serverId: string, userId: string): boolean { + const role = getMemberRole(serverId, userId); + return role === 'owner' || role === 'admin'; +} + +export function isServerOwner(serverId: string, userId: string): boolean { + const db = getDb(); + const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get(); + return server?.ownerId === userId; +} + +export function getChannelServerId(channelId: string): string | null { + const db = getDb(); + const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get(); + return channel?.serverId ?? null; +} + +export function isDmMember(dmChannelId: string, userId: string): boolean { + const db = getDb(); + const member = db.select().from(schema.dmMembers) + .where(and( + eq(schema.dmMembers.dmChannelId, dmChannelId), + eq(schema.dmMembers.userId, userId), + )) + .get(); + return member !== undefined; +} diff --git a/packages/server/src/utils/snowflake.ts b/packages/server/src/utils/snowflake.ts new file mode 100644 index 00000000..89bdd988 --- /dev/null +++ b/packages/server/src/utils/snowflake.ts @@ -0,0 +1,50 @@ +/** + * Discord-style Snowflake ID Generator + * + * Structure (64-bit): + * - 42 bits: milliseconds since custom epoch (Jan 1, 2024) + * - 10 bits: worker/process ID + * - 12 bits: sequence number (per-millisecond) + * + * This gives us: + * - ~139 years of IDs from epoch + * - 1024 workers + * - 4096 IDs per millisecond per worker + */ + +const EPOCH = 1704067200000n; // Jan 1, 2024 00:00:00 UTC +const WORKER_ID = BigInt(process.pid % 1024); + +let sequence = 0n; +let lastTimestamp = -1n; + +export function generateSnowflake(): string { + let timestamp = BigInt(Date.now()); + + if (timestamp === lastTimestamp) { + sequence = (sequence + 1n) & 0xFFFn; // 12-bit mask + if (sequence === 0n) { + // Sequence exhausted, wait for next millisecond + while (timestamp <= lastTimestamp) { + timestamp = BigInt(Date.now()); + } + } + } else { + sequence = 0n; + } + + lastTimestamp = timestamp; + + const id = + ((timestamp - EPOCH) << 22n) | + (WORKER_ID << 12n) | + sequence; + + return id.toString(); +} + +export function snowflakeToTimestamp(snowflake: string): number { + const id = BigInt(snowflake); + const timestamp = (id >> 22n) + EPOCH; + return Number(timestamp); +} diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts new file mode 100644 index 00000000..15c4b9e9 --- /dev/null +++ b/packages/server/src/ws/events.ts @@ -0,0 +1,409 @@ +import { eq, inArray } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { connectionManager } from './handler.js'; +import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js'; +import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared'; + +function sanitizeUser(row: typeof schema.users.$inferSelect): User { + return { + id: row.id, + username: row.username, + displayName: row.displayName, + avatar: row.avatar, + status: (row.status ?? 'offline') as User['status'], + customStatus: row.customStatus, + createdAt: row.createdAt, + }; +} + +function getMessageWithUser(messageId: string): MessageWithUser | null { + const db = getDb(); + const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get(); + if (!message) return null; + + const user = db.select().from(schema.users).where(eq(schema.users.id, message.userId)).get(); + if (!user) return null; + + const attachmentRows = db.select() + .from(schema.attachments) + .where(eq(schema.attachments.messageId, messageId)) + .all(); + + const attachments: Attachment[] = attachmentRows.map(a => ({ + id: a.id, + messageId: a.messageId ?? messageId, + filename: a.filename, + originalName: a.originalName, + mimetype: a.mimetype, + size: a.size, + createdAt: a.createdAt, + })); + + return { + id: message.id, + channelId: message.channelId, + userId: message.userId, + content: message.content, + editedAt: message.editedAt, + createdAt: message.createdAt, + user: sanitizeUser(user), + attachments, + }; +} + +// Typing timeout tracking +const typingTimeouts: Map = new Map(); + +export function handleClientEvent( + event: Record, + userId: string, + username: string, +): void { + const type = event.type as string; + + switch (type) { + case 'message_create': + handleMessageCreate(event, userId); + break; + case 'message_edit': + handleMessageEdit(event, userId); + break; + case 'message_delete': + handleMessageDelete(event, userId); + break; + case 'typing_start': + handleTypingStart(event, userId, username); + break; + case 'presence_update': + handlePresenceUpdate(event, userId); + break; + case 'voice_join': + handleVoiceJoin(event, userId); + break; + case 'voice_leave': + handleVoiceLeave(userId); + break; + case 'dm_message_create': + handleDmMessageCreate(event, userId); + break; + default: + connectionManager.sendToUser(userId, { + type: 'error', + message: `Unknown event type: ${type}`, + }); + } +} + +function handleMessageCreate(event: Record, userId: string): void { + const channelId = event.channelId as string; + const content = event.content as string; + + if (!channelId || typeof channelId !== 'string') { + connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' }); + return; + } + + if (!content || typeof content !== 'string' || content.trim().length === 0) { + connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' }); + return; + } + + const serverId = getChannelServerId(channelId); + if (!serverId) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' }); + return; + } + + if (!isMember(serverId, userId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' }); + return; + } + + const db = getDb(); + const messageId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.messages).values({ + id: messageId, + channelId, + userId, + content: content.trim(), + createdAt: now, + }).run(); + + const messageWithUser = getMessageWithUser(messageId); + if (messageWithUser) { + // Broadcast to all server members (including sender) + connectionManager.sendToServer(serverId, { + type: 'message_created', + message: messageWithUser, + }); + } +} + +function handleMessageEdit(event: Record, userId: string): void { + const messageId = event.messageId as string; + const content = event.content as string; + + if (!messageId || typeof messageId !== 'string') { + connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' }); + return; + } + + if (!content || typeof content !== 'string' || content.trim().length === 0) { + connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' }); + return; + } + + const db = getDb(); + const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get(); + if (!message) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' }); + return; + } + + if (message.userId !== userId) { + connectionManager.sendToUser(userId, { type: 'error', message: 'You can only edit your own messages' }); + return; + } + + const now = Date.now(); + db.update(schema.messages) + .set({ content: content.trim(), editedAt: now }) + .where(eq(schema.messages.id, messageId)) + .run(); + + const serverId = getChannelServerId(message.channelId); + if (!serverId) return; + + const updatedMessage = getMessageWithUser(messageId); + if (updatedMessage) { + connectionManager.sendToServer(serverId, { + type: 'message_updated', + message: updatedMessage, + }); + } +} + +function handleMessageDelete(event: Record, userId: string): void { + const messageId = event.messageId as string; + + if (!messageId || typeof messageId !== 'string') { + connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' }); + return; + } + + const db = getDb(); + const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get(); + if (!message) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' }); + return; + } + + const serverId = getChannelServerId(message.channelId); + if (!serverId) return; + + // Allow author or admin to delete + const isAuthor = message.userId === userId; + const memberRow = db.select() + .from(schema.serverMembers) + .where(eq(schema.serverMembers.serverId, serverId)) + .all() + .find(m => m.userId === userId); + + const isAdminRole = memberRow?.role === 'admin' || memberRow?.role === 'owner'; + + if (!isAuthor && !isAdminRole) { + connectionManager.sendToUser(userId, { type: 'error', message: 'You cannot delete this message' }); + return; + } + + // Delete attachments then message + db.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run(); + db.delete(schema.messages).where(eq(schema.messages.id, messageId)).run(); + + connectionManager.sendToServer(serverId, { + type: 'message_deleted', + messageId, + channelId: message.channelId, + }); +} + +function handleTypingStart(event: Record, userId: string, username: string): void { + const channelId = event.channelId as string; + + if (!channelId || typeof channelId !== 'string') return; + + const serverId = getChannelServerId(channelId); + if (!serverId) return; + + if (!isMember(serverId, userId)) return; + + // Clear previous typing timeout for this user+channel + const key = `${userId}:${channelId}`; + const existing = typingTimeouts.get(key); + if (existing) { + clearTimeout(existing); + } + + // Broadcast typing event (exclude sender) + connectionManager.sendToServer(serverId, { + type: 'typing', + channelId, + userId, + username, + }, userId); + + // Auto-expire typing after 5 seconds + const timeout = setTimeout(() => { + typingTimeouts.delete(key); + }, 5000); + typingTimeouts.set(key, timeout); +} + +function handlePresenceUpdate(event: Record, userId: string): void { + const status = event.status as string; + + if (!status || !['online', 'idle', 'dnd'].includes(status)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Status must be "online", "idle", or "dnd"' }); + return; + } + + const db = getDb(); + db.update(schema.users).set({ status }).where(eq(schema.users.id, userId)).run(); + + // Broadcast to all servers user is in + const userServers = connectionManager.getUserServers(userId); + for (const serverId of userServers) { + connectionManager.sendToServer(serverId, { + type: 'presence_update', + userId, + status, + }, userId); + } + + // Also send to self (other tabs) + connectionManager.sendToUser(userId, { + type: 'presence_update', + userId, + status, + }); +} + +function handleVoiceJoin(event: Record, userId: string): void { + const channelId = event.channelId as string; + + if (!channelId || typeof channelId !== 'string') { + connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' }); + return; + } + + const serverId = getChannelServerId(channelId); + if (!serverId) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' }); + return; + } + + if (!isMember(serverId, userId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this server' }); + return; + } + + // Leave any current voice channel + const previousChannel = connectionManager.leaveAllVoice(userId); + if (previousChannel) { + const prevServerId = getChannelServerId(previousChannel); + if (prevServerId) { + connectionManager.sendToServer(prevServerId, { + type: 'voice_state_update', + channelId: previousChannel, + userId, + action: 'leave', + }); + } + } + + // Join new voice channel + connectionManager.joinVoice(channelId, userId); + + // Broadcast to server + connectionManager.sendToServer(serverId, { + type: 'voice_state_update', + channelId, + userId, + action: 'join', + }); +} + +function handleVoiceLeave(userId: string): void { + const channelId = connectionManager.leaveAllVoice(userId); + if (channelId) { + const serverId = getChannelServerId(channelId); + if (serverId) { + connectionManager.sendToServer(serverId, { + type: 'voice_state_update', + channelId, + userId, + action: 'leave', + }); + } + } +} + +function handleDmMessageCreate(event: Record, userId: string): void { + const dmChannelId = event.dmChannelId as string; + const content = event.content as string; + + if (!dmChannelId || typeof dmChannelId !== 'string') { + connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' }); + return; + } + + if (!content || typeof content !== 'string' || content.trim().length === 0) { + connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' }); + return; + } + + if (!isDmMember(dmChannelId, userId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this DM channel' }); + return; + } + + const db = getDb(); + const messageId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.dmMessages).values({ + id: messageId, + dmChannelId, + userId, + content: content.trim(), + createdAt: now, + }).run(); + + const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + if (!user) return; + + const dmMessage: DmMessageWithUser = { + id: messageId, + dmChannelId, + userId, + content: content.trim(), + createdAt: now, + user: sanitizeUser(user), + }; + + // Send to all DM members + const dmMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + + for (const member of dmMembers) { + connectionManager.sendToUser(member.userId, { + type: 'dm_message_created', + message: dmMessage, + }); + } +} diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts new file mode 100644 index 00000000..b8157a74 --- /dev/null +++ b/packages/server/src/ws/handler.ts @@ -0,0 +1,440 @@ +import type { FastifyInstance } from 'fastify'; +import type { WebSocket } from 'ws'; +import { verifyJwt } from '../utils/auth.js'; +import { getDb, schema } from '../db/index.js'; +import { eq, inArray } from 'drizzle-orm'; +import { handleClientEvent } from './events.js'; +import type { + User, + ServerWithChannelsAndMembers, + MemberWithUser, + Channel, + DmChannel, + ServerEvent, +} from '@opencord/shared'; + +function sanitizeUser(row: typeof schema.users.$inferSelect): User { + return { + id: row.id, + username: row.username, + displayName: row.displayName, + avatar: row.avatar, + status: (row.status ?? 'offline') as User['status'], + customStatus: row.customStatus, + createdAt: row.createdAt, + }; +} + +export interface AuthenticatedSocket { + ws: WebSocket; + userId: string; + username: string; +} + +class ConnectionManager { + // userId → Set of WebSocket connections (multiple tabs) + private connections: Map> = new Map(); + // userId → Set of server IDs the user belongs to + private userServers: Map> = new Map(); + // channelId → Set of userIds in voice channel + private voiceStates: Map> = new Map(); + // ws → userId (reverse lookup) + private wsToUser: Map = new Map(); + + addConnection(userId: string, ws: WebSocket): void { + if (!this.connections.has(userId)) { + this.connections.set(userId, new Set()); + } + this.connections.get(userId)!.add(ws); + this.wsToUser.set(ws, userId); + } + + removeConnection(ws: WebSocket): string | undefined { + const userId = this.wsToUser.get(ws); + if (!userId) return undefined; + + this.wsToUser.delete(ws); + const userConnections = this.connections.get(userId); + if (userConnections) { + userConnections.delete(ws); + if (userConnections.size === 0) { + this.connections.delete(userId); + } + } + return userId; + } + + getUserConnections(userId: string): Set { + return this.connections.get(userId) ?? new Set(); + } + + isUserOnline(userId: string): boolean { + const conns = this.connections.get(userId); + return conns !== undefined && conns.size > 0; + } + + setUserServers(userId: string, serverIds: string[]): void { + this.userServers.set(userId, new Set(serverIds)); + } + + addUserServer(userId: string, serverId: string): void { + if (!this.userServers.has(userId)) { + this.userServers.set(userId, new Set()); + } + this.userServers.get(userId)!.add(serverId); + } + + getUserServers(userId: string): Set { + return this.userServers.get(userId) ?? new Set(); + } + + // Voice state management + joinVoice(channelId: string, userId: string): void { + // Leave any existing voice channel first + this.leaveAllVoice(userId); + if (!this.voiceStates.has(channelId)) { + this.voiceStates.set(channelId, new Set()); + } + this.voiceStates.get(channelId)!.add(userId); + } + + leaveVoice(channelId: string, userId: string): void { + const users = this.voiceStates.get(channelId); + if (users) { + users.delete(userId); + if (users.size === 0) { + this.voiceStates.delete(channelId); + } + } + } + + leaveAllVoice(userId: string): string | null { + for (const [channelId, users] of this.voiceStates) { + if (users.has(userId)) { + users.delete(userId); + if (users.size === 0) { + this.voiceStates.delete(channelId); + } + return channelId; + } + } + return null; + } + + getVoiceUsers(channelId: string): Set { + return this.voiceStates.get(channelId) ?? new Set(); + } + + getUserVoiceChannel(userId: string): string | null { + for (const [channelId, users] of this.voiceStates) { + if (users.has(userId)) { + return channelId; + } + } + return null; + } + + // Send to a specific user (all their connections) + sendToUser(userId: string, event: ServerEvent): void { + const connections = this.getUserConnections(userId); + const message = JSON.stringify(event); + for (const ws of connections) { + if (ws.readyState === 1) { // WebSocket.OPEN + ws.send(message); + } + } + } + + // Send to all members of a server + sendToServer(serverId: string, event: ServerEvent, excludeUserId?: string): void { + const message = JSON.stringify(event); + for (const [userId, serverIds] of this.userServers) { + if (serverIds.has(serverId) && userId !== excludeUserId) { + const connections = this.getUserConnections(userId); + for (const ws of connections) { + if (ws.readyState === 1) { + ws.send(message); + } + } + } + } + } + + // Send to all connections of all online users (for global events) + sendToAll(event: ServerEvent, excludeUserId?: string): void { + const message = JSON.stringify(event); + for (const [userId, connections] of this.connections) { + if (userId !== excludeUserId) { + for (const ws of connections) { + if (ws.readyState === 1) { + ws.send(message); + } + } + } + } + } + + getAllOnlineUserIds(): string[] { + return Array.from(this.connections.keys()); + } +} + +export const connectionManager = new ConnectionManager(); + +function buildReadyPayload(userId: string): { + user: User; + servers: ServerWithChannelsAndMembers[]; + dmChannels: DmChannel[]; +} { + const db = getDb(); + + // Get user + const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + if (!userRow) { + throw new Error('User not found'); + } + const user = sanitizeUser(userRow); + + // Get user's server memberships + const memberships = db.select() + .from(schema.serverMembers) + .where(eq(schema.serverMembers.userId, userId)) + .all(); + + const serverIds = memberships.map(m => m.serverId); + + const servers: ServerWithChannelsAndMembers[] = []; + + if (serverIds.length > 0) { + const serverRows = db.select() + .from(schema.servers) + .where(inArray(schema.servers.id, serverIds)) + .all(); + + for (const serverRow of serverRows) { + const channels = db.select() + .from(schema.channels) + .where(eq(schema.channels.serverId, serverRow.id)) + .all(); + + const memberRows = db.select() + .from(schema.serverMembers) + .where(eq(schema.serverMembers.serverId, serverRow.id)) + .all(); + + const memberUserIds = memberRows.map(m => m.userId); + const users = memberUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() + : []; + const userMap = new Map(users.map(u => [u.id, u])); + + const members: MemberWithUser[] = memberRows + .map(m => { + const u = userMap.get(m.userId); + if (!u) return null; + return { + serverId: m.serverId, + userId: m.userId, + role: (m.role ?? 'member') as MemberWithUser['role'], + nickname: m.nickname, + joinedAt: m.joinedAt, + user: sanitizeUser(u), + }; + }) + .filter((m): m is MemberWithUser => m !== null); + + servers.push({ + id: serverRow.id, + name: serverRow.name, + icon: serverRow.icon, + ownerId: serverRow.ownerId, + inviteCode: serverRow.inviteCode, + createdAt: serverRow.createdAt, + channels: channels.map(ch => ({ + id: ch.id, + serverId: ch.serverId, + name: ch.name, + type: ch.type as Channel['type'], + topic: ch.topic, + position: ch.position ?? 0, + createdAt: ch.createdAt, + })), + members, + }); + } + } + + // Store user's server IDs for broadcasting + connectionManager.setUserServers(userId, serverIds); + + // Get DM channels + const dmMemberships = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.userId, userId)) + .all(); + + const dmChannels: DmChannel[] = []; + + for (const dm of dmMemberships) { + const dmChannel = db.select() + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, dm.dmChannelId)) + .get(); + + if (!dmChannel) continue; + + const dmMemberRows = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dm.dmChannelId)) + .all(); + + const dmMemberUserIds = dmMemberRows.map(m => m.userId); + const dmUsers = dmMemberUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, dmMemberUserIds)).all() + : []; + + // Get last message + const lastMessage = db.select() + .from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, dm.dmChannelId)) + .orderBy(schema.dmMessages.createdAt) + .all(); + + const last = lastMessage.length > 0 ? lastMessage[lastMessage.length - 1] : null; + + dmChannels.push({ + id: dmChannel.id, + createdAt: dmChannel.createdAt, + members: dmUsers.map(sanitizeUser), + lastMessage: last ? { + id: last.id, + dmChannelId: last.dmChannelId, + userId: last.userId, + content: last.content, + createdAt: last.createdAt, + } : null, + }); + } + + return { user, servers, dmChannels }; +} + +export async function registerWebSocket(app: FastifyInstance): Promise { + app.get('/ws', { websocket: true }, (socket, request) => { + const ws = socket as unknown as WebSocket; + let authenticated = false; + let userId: string | undefined; + let username: string | undefined; + + // Set auth timeout - must authenticate within 10 seconds + const authTimeout = setTimeout(() => { + if (!authenticated) { + ws.send(JSON.stringify({ type: 'error', message: 'Authentication timeout' })); + ws.close(); + } + }, 10000); + + ws.on('message', (data: Buffer | string) => { + let parsed: Record; + try { + const raw = typeof data === 'string' ? data : data.toString('utf-8'); + parsed = JSON.parse(raw) as Record; + } catch { + ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' })); + return; + } + + if (!authenticated) { + // First message must be auth + if (parsed.type !== 'auth' || typeof parsed.token !== 'string') { + ws.send(JSON.stringify({ type: 'error', message: 'First message must be auth' })); + ws.close(); + return; + } + + try { + const payload = verifyJwt(parsed.token); + userId = payload.userId; + username = payload.username; + authenticated = true; + clearTimeout(authTimeout); + + // Update user status to online + const db = getDb(); + db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, userId)).run(); + + // Add connection + connectionManager.addConnection(userId, ws); + + // Build and send ready payload + const readyData = buildReadyPayload(userId); + ws.send(JSON.stringify({ + type: 'ready', + ...readyData, + })); + + // Broadcast presence update to all servers + const userServers = connectionManager.getUserServers(userId); + for (const serverId of userServers) { + connectionManager.sendToServer(serverId, { + type: 'presence_update', + userId, + status: 'online', + }, userId); + } + } catch { + ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' })); + ws.close(); + } + return; + } + + // Handle authenticated events + if (userId && username) { + handleClientEvent(parsed, userId, username); + } + }); + + ws.on('close', () => { + clearTimeout(authTimeout); + if (userId) { + const removedUserId = connectionManager.removeConnection(ws); + + // If user has no more connections, set offline + if (removedUserId && !connectionManager.isUserOnline(removedUserId)) { + const db = getDb(); + db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, removedUserId)).run(); + + // Leave voice if in one + const leftChannel = connectionManager.leaveAllVoice(removedUserId); + if (leftChannel) { + // Get channel's server to broadcast + const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get(); + if (channel) { + connectionManager.sendToServer(channel.serverId, { + type: 'voice_state_update', + channelId: leftChannel, + userId: removedUserId, + action: 'leave', + }); + } + } + + // Broadcast offline to all servers + const userServers = connectionManager.getUserServers(removedUserId); + for (const serverId of userServers) { + connectionManager.sendToServer(serverId, { + type: 'presence_update', + userId: removedUserId, + status: 'offline', + }); + } + } + } + }); + + ws.on('error', () => { + clearTimeout(authTimeout); + }); + }); +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 00000000..631be638 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "references": [ + { "path": "../shared" } + ] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000..cf03ae82 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@opencord/shared", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/types.ts", + "types": "./src/types.ts", + "exports": { + ".": "./src/types.ts" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.4.0" + } +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 00000000..be0e86bc --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,229 @@ +// ─── User Types ───────────────────────────────────────────────────────────── + +export interface User { + id: string; + username: string; + displayName: string | null; + avatar: string | null; + status: UserStatus; + customStatus: string | null; + createdAt: number; +} + +export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline'; + +export interface UserWithPassword extends User { + passwordHash: string; +} + +// ─── Server (Guild) Types ─────────────────────────────────────────────────── + +export interface Server { + id: string; + name: string; + icon: string | null; + ownerId: string; + inviteCode: string | null; + createdAt: number; +} + +export interface ServerWithChannelsAndMembers extends Server { + channels: Channel[]; + members: MemberWithUser[]; +} + +// ─── Member Types ─────────────────────────────────────────────────────────── + +export type MemberRole = 'owner' | 'admin' | 'member'; + +export interface Member { + serverId: string; + userId: string; + role: MemberRole; + nickname: string | null; + joinedAt: number; +} + +export interface MemberWithUser extends Member { + user: User; +} + +// ─── Channel Types ────────────────────────────────────────────────────────── + +export type ChannelType = 'text' | 'voice' | 'video'; + +export interface Channel { + id: string; + serverId: string; + name: string; + type: ChannelType; + topic: string | null; + position: number; + createdAt: number; +} + +// ─── Message Types ────────────────────────────────────────────────────────── + +export interface Message { + id: string; + channelId: string; + userId: string; + content: string | null; + editedAt: number | null; + createdAt: number; +} + +export interface MessageWithUser extends Message { + user: User; + attachments: Attachment[]; +} + +// ─── Attachment Types ─────────────────────────────────────────────────────── + +export interface Attachment { + id: string; + messageId: string; + filename: string; + originalName: string; + mimetype: string; + size: number; + createdAt: number; +} + +// ─── DM Types ─────────────────────────────────────────────────────────────── + +export interface DmChannel { + id: string; + createdAt: number; + members: User[]; + lastMessage?: DmMessage | null; +} + +export interface DmMessage { + id: string; + dmChannelId: string; + userId: string; + content: string | null; + createdAt: number; +} + +export interface DmMessageWithUser extends DmMessage { + user: User; +} + +// ─── WebSocket Event Types ────────────────────────────────────────────────── + +// Client → Server Events +export type ClientEvent = + | { type: 'auth'; token: string } + | { type: 'message_create'; channelId: string; content: string } + | { type: 'message_edit'; messageId: string; content: string } + | { type: 'message_delete'; messageId: string } + | { type: 'typing_start'; channelId: string } + | { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' } + | { type: 'voice_join'; channelId: string } + | { type: 'voice_leave' } + | { type: 'dm_message_create'; dmChannelId: string; content: string }; + +// Server → Client Events +export type ServerEvent = + | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[] } + | { type: 'message_created'; message: MessageWithUser } + | { type: 'message_updated'; message: MessageWithUser } + | { type: 'message_deleted'; messageId: string; channelId: string } + | { type: 'typing'; channelId: string; userId: string; username: string } + | { type: 'presence_update'; userId: string; status: string } + | { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave' } + | { type: 'member_joined'; serverId: string; member: MemberWithUser } + | { type: 'member_left'; serverId: string; userId: string } + | { type: 'dm_message_created'; message: DmMessageWithUser } + | { type: 'error'; message: string }; + +// ─── API Request/Response Types ───────────────────────────────────────────── + +export interface RegisterRequest { + username: string; + password: string; + displayName?: string; +} + +export interface LoginRequest { + username: string; + password: string; +} + +export interface AuthResponse { + token: string; + user: User; +} + +export interface CreateServerRequest { + name: string; + icon?: string; +} + +export interface CreateChannelRequest { + name: string; + type: ChannelType; + topic?: string; +} + +export interface UpdateChannelRequest { + name?: string; + topic?: string; + position?: number; +} + +export interface UpdateServerRequest { + name?: string; + icon?: string; +} + +export interface UpdateUserRequest { + displayName?: string; + avatar?: string; + customStatus?: string; +} + +export interface UpdateMemberRequest { + role: MemberRole; +} + +export interface CreateMessageRequest { + content: string; + attachments?: string[]; +} + +export interface UpdateMessageRequest { + content: string; +} + +export interface JoinServerRequest { + inviteCode: string; +} + +export interface LiveKitTokenRequest { + channelId: string; +} + +export interface LiveKitTokenResponse { + token: string; +} + +export interface CreateDmRequest { + userId: string; +} + +export interface CreateDmMessageRequest { + content: string; +} + +export interface PaginatedQuery { + before?: string; + limit?: number; +} + +export interface ApiError { + error: string; + statusCode: number; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 00000000..9c5db86d --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*"] +} diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 00000000..b8cc1e31 --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Opencord + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 00000000..f7cd4da5 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,31 @@ +{ + "name": "@opencord/web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@opencord/shared": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "react-markdown": "^9.0.1", + "zustand": "^5.0.2", + "livekit-client": "^2.9.0", + "@livekit/components-react": "^2.7.4" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } +} diff --git a/packages/web/postcss.config.js b/packages/web/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/packages/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/web/src/App.js b/packages/web/src/App.js new file mode 100644 index 00000000..01153023 --- /dev/null +++ b/packages/web/src/App.js @@ -0,0 +1,21 @@ +import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { LoginPage } from './components/auth/LoginPage'; +import { RegisterPage } from './components/auth/RegisterPage'; +import { AppLayout } from './components/layout/AppLayout'; +import { useAuthStore } from './stores/authStore'; +function ProtectedRoute({ children }) { + const token = useAuthStore((s) => s.token); + if (!token) + return _jsx(Navigate, { to: "/login", replace: true }); + return _jsx(_Fragment, { children: children }); +} +function AuthRedirect({ children }) { + const token = useAuthStore((s) => s.token); + if (token) + return _jsx(Navigate, { to: "/channels/@me", replace: true }); + return _jsx(_Fragment, { children: children }); +} +export function App() { + return (_jsxs(Routes, { children: [_jsx(Route, { path: "/login", element: _jsx(AuthRedirect, { children: _jsx(LoginPage, {}) }) }), _jsx(Route, { path: "/register", element: _jsx(AuthRedirect, { children: _jsx(RegisterPage, {}) }) }), _jsx(Route, { path: "/channels/:serverId/:channelId?", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) })] })); +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx new file mode 100644 index 00000000..02bad588 --- /dev/null +++ b/packages/web/src/App.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { LoginPage } from './components/auth/LoginPage'; +import { RegisterPage } from './components/auth/RegisterPage'; +import { AppLayout } from './components/layout/AppLayout'; +import { useAuthStore } from './stores/authStore'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const token = useAuthStore((s) => s.token); + if (!token) return ; + return <>{children}; +} + +function AuthRedirect({ children }: { children: React.ReactNode }) { + const token = useAuthStore((s) => s.token); + if (token) return ; + return <>{children}; +} + +export function App() { + return ( + + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + + ); +} diff --git a/packages/web/src/api/client.js b/packages/web/src/api/client.js new file mode 100644 index 00000000..46f03b4d --- /dev/null +++ b/packages/web/src/api/client.js @@ -0,0 +1,105 @@ +const BASE_URL = '/api'; +function getToken() { + return localStorage.getItem('opencord_token'); +} +async function request(method, path, body, requireAuth = true) { + const headers = {}; + if (body) { + headers['Content-Type'] = 'application/json'; + } + if (requireAuth) { + const token = getToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + const response = await fetch(`${BASE_URL}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Request failed' })); + throw new Error(error.error || `HTTP ${response.status}`); + } + return response.json(); +} +async function uploadFile(file) { + const formData = new FormData(); + formData.append('file', file); + const token = getToken(); + const headers = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + const response = await fetch(`${BASE_URL}/uploads`, { + method: 'POST', + headers, + body: formData, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Upload failed' })); + throw new Error(error.error || `HTTP ${response.status}`); + } + return response.json(); +} +export const api = { + auth: { + register: (data) => request('POST', '/auth/register', data, false), + login: (data) => request('POST', '/auth/login', data, false), + }, + users: { + me: () => request('GET', '/users/@me'), + update: (data) => request('PATCH', '/users/@me', data), + get: (id) => request('GET', `/users/${id}`), + }, + servers: { + list: () => request('GET', '/servers'), + get: (id) => request('GET', `/servers/${id}`), + create: (data) => request('POST', '/servers', data), + update: (id, data) => request('PATCH', `/servers/${id}`, data), + delete: (id) => request('DELETE', `/servers/${id}`), + invite: (id) => request('POST', `/servers/${id}/invite`), + join: (id, data) => request('POST', `/servers/${id}/join`, data), + members: (id) => request('GET', `/servers/${id}/members`), + updateMember: (serverId, userId, data) => request('PATCH', `/servers/${serverId}/members/${userId}`, data), + removeMember: (serverId, userId) => request('DELETE', `/servers/${serverId}/members/${userId}`), + }, + channels: { + list: (serverId) => request('GET', `/servers/${serverId}/channels`), + create: (serverId, data) => request('POST', `/servers/${serverId}/channels`, data), + update: (id, data) => request('PATCH', `/channels/${id}`, data), + delete: (id) => request('DELETE', `/channels/${id}`), + messages: (id, before, limit = 50) => { + const params = new URLSearchParams(); + if (before) + params.set('before', before); + params.set('limit', String(limit)); + return request('GET', `/channels/${id}/messages?${params}`); + }, + sendMessage: (channelId, data) => request('POST', `/channels/${channelId}/messages`, data), + }, + messages: { + update: (id, data) => request('PATCH', `/messages/${id}`, data), + delete: (id) => request('DELETE', `/messages/${id}`), + }, + uploads: { + upload: uploadFile, + url: (filename) => `${BASE_URL}/uploads/${filename}`, + }, + dm: { + list: () => request('GET', '/dm'), + create: (data) => request('POST', '/dm', data), + messages: (id, before, limit = 50) => { + const params = new URLSearchParams(); + if (before) + params.set('before', before); + params.set('limit', String(limit)); + return request('GET', `/dm/${id}/messages?${params}`); + }, + sendMessage: (id, data) => request('POST', `/dm/${id}/messages`, data), + }, + livekit: { + token: (channelId) => request('POST', '/livekit/token', { channelId }), + }, +}; diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts new file mode 100644 index 00000000..49f7d2e5 --- /dev/null +++ b/packages/web/src/api/client.ts @@ -0,0 +1,163 @@ +import type { + AuthResponse, + RegisterRequest, + LoginRequest, + User, + Server, + ServerWithChannelsAndMembers, + Channel, + MessageWithUser, + MemberWithUser, + Attachment, + DmChannel, + DmMessageWithUser, + CreateServerRequest, + UpdateServerRequest, + CreateChannelRequest, + UpdateChannelRequest, + CreateMessageRequest, + UpdateMessageRequest, + UpdateUserRequest, + JoinServerRequest, + UpdateMemberRequest, + LiveKitTokenResponse, + CreateDmRequest, + CreateDmMessageRequest, +} from '@opencord/shared'; + +const BASE_URL = '/api'; + +function getToken(): string | null { + return localStorage.getItem('opencord_token'); +} + +async function request( + method: string, + path: string, + body?: unknown, + requireAuth = true, +): Promise { + const headers: Record = {}; + + if (body) { + headers['Content-Type'] = 'application/json'; + } + + if (requireAuth) { + const token = getToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + + const response = await fetch(`${BASE_URL}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Request failed' })); + throw new Error((error as { error: string }).error || `HTTP ${response.status}`); + } + + return response.json() as Promise; +} + +async function uploadFile(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const token = getToken(); + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${BASE_URL}/uploads`, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Upload failed' })); + throw new Error((error as { error: string }).error || `HTTP ${response.status}`); + } + + return response.json() as Promise; +} + +export const api = { + auth: { + register: (data: RegisterRequest) => + request('POST', '/auth/register', data, false), + login: (data: LoginRequest) => + request('POST', '/auth/login', data, false), + }, + + users: { + me: () => request('GET', '/users/@me'), + update: (data: UpdateUserRequest) => request('PATCH', '/users/@me', data), + get: (id: string) => request('GET', `/users/${id}`), + }, + + servers: { + list: () => request('GET', '/servers'), + get: (id: string) => request('GET', `/servers/${id}`), + create: (data: CreateServerRequest) => request('POST', '/servers', data), + update: (id: string, data: UpdateServerRequest) => request('PATCH', `/servers/${id}`, data), + delete: (id: string) => request<{ success: boolean }>('DELETE', `/servers/${id}`), + invite: (id: string) => request<{ inviteCode: string }>('POST', `/servers/${id}/invite`), + join: (id: string, data: JoinServerRequest) => request('POST', `/servers/${id}/join`, data), + members: (id: string) => request('GET', `/servers/${id}/members`), + updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) => + request('PATCH', `/servers/${serverId}/members/${userId}`, data), + removeMember: (serverId: string, userId: string) => + request<{ success: boolean }>('DELETE', `/servers/${serverId}/members/${userId}`), + }, + + channels: { + list: (serverId: string) => request('GET', `/servers/${serverId}/channels`), + create: (serverId: string, data: CreateChannelRequest) => + request('POST', `/servers/${serverId}/channels`, data), + update: (id: string, data: UpdateChannelRequest) => request('PATCH', `/channels/${id}`, data), + delete: (id: string) => request<{ success: boolean }>('DELETE', `/channels/${id}`), + messages: (id: string, before?: string, limit = 50) => { + const params = new URLSearchParams(); + if (before) params.set('before', before); + params.set('limit', String(limit)); + return request('GET', `/channels/${id}/messages?${params}`); + }, + sendMessage: (channelId: string, data: CreateMessageRequest) => + request('POST', `/channels/${channelId}/messages`, data), + }, + + messages: { + update: (id: string, data: UpdateMessageRequest) => request('PATCH', `/messages/${id}`, data), + delete: (id: string) => request<{ success: boolean }>('DELETE', `/messages/${id}`), + }, + + uploads: { + upload: uploadFile, + url: (filename: string) => `${BASE_URL}/uploads/${filename}`, + }, + + dm: { + list: () => request('GET', '/dm'), + create: (data: CreateDmRequest) => request('POST', '/dm', data), + messages: (id: string, before?: string, limit = 50) => { + const params = new URLSearchParams(); + if (before) params.set('before', before); + params.set('limit', String(limit)); + return request('GET', `/dm/${id}/messages?${params}`); + }, + sendMessage: (id: string, data: CreateDmMessageRequest) => + request('POST', `/dm/${id}/messages`, data), + }, + + livekit: { + token: (channelId: string) => + request('POST', '/livekit/token', { channelId }), + }, +}; diff --git a/packages/web/src/components/auth/LoginPage.js b/packages/web/src/components/auth/LoginPage.js new file mode 100644 index 00000000..c2aa5a3a --- /dev/null +++ b/packages/web/src/components/auth/LoginPage.js @@ -0,0 +1,32 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../stores/authStore'; +export function LoginPage() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const login = useAuthStore((s) => s.login); + const isLoading = useAuthStore((s) => s.isLoading); + const navigate = useNavigate(); + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + if (!username.trim()) { + setError('Username is required'); + return; + } + if (!password) { + setError('Password is required'); + return; + } + try { + await login(username.trim(), password); + navigate('/channels/@me'); + } + catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } + }; + return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-discord-bg-tertiary", children: _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl", children: [_jsxs("div", { className: "text-center mb-6", children: [_jsx("h1", { className: "text-2xl font-bold text-discord-text-primary", children: "Welcome back!" }), _jsx("p", { className: "text-discord-text-muted mt-1", children: "We're so excited to see you again!" })] }), _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Username ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "text", value: username, onChange: (e) => setUsername(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoFocus: true, autoComplete: "username" })] }), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Password ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "current-password" })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: isLoading ? 'Logging in...' : 'Log In' }), _jsxs("p", { className: "mt-3 text-sm text-discord-text-muted", children: ["Need an account?", ' ', _jsx(Link, { to: "/register", className: "text-[#00aff4] hover:underline", children: "Register" })] })] })] }) })); +} diff --git a/packages/web/src/components/auth/LoginPage.tsx b/packages/web/src/components/auth/LoginPage.tsx new file mode 100644 index 00000000..d34e8473 --- /dev/null +++ b/packages/web/src/components/auth/LoginPage.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../stores/authStore'; + +export function LoginPage() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const login = useAuthStore((s) => s.login); + const isLoading = useAuthStore((s) => s.isLoading); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!username.trim()) { + setError('Username is required'); + return; + } + if (!password) { + setError('Password is required'); + return; + } + + try { + await login(username.trim(), password); + navigate('/channels/@me'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } + }; + + return ( +
+
+
+

Welcome back!

+

We're so excited to see you again!

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all" + autoFocus + autoComplete="username" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all" + autoComplete="current-password" + /> +
+ + + +

+ Need an account?{' '} + + Register + +

+
+
+
+ ); +} diff --git a/packages/web/src/components/auth/RegisterPage.js b/packages/web/src/components/auth/RegisterPage.js new file mode 100644 index 00000000..8af8b929 --- /dev/null +++ b/packages/web/src/components/auth/RegisterPage.js @@ -0,0 +1,45 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../stores/authStore'; +export function RegisterPage() { + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const register = useAuthStore((s) => s.register); + const isLoading = useAuthStore((s) => s.isLoading); + const navigate = useNavigate(); + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + if (!username.trim()) { + setError('Username is required'); + return; + } + if (username.trim().length < 3 || username.trim().length > 32) { + setError('Username must be between 3 and 32 characters'); + return; + } + if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) { + setError('Username can only contain letters, numbers, and underscores'); + return; + } + if (!password) { + setError('Password is required'); + return; + } + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + try { + await register(username.trim(), password, displayName.trim() || undefined); + navigate('/channels/@me'); + } + catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed'); + } + }; + return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-discord-bg-tertiary", children: _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl", children: [_jsx("div", { className: "text-center mb-6", children: _jsx("h1", { className: "text-2xl font-bold text-discord-text-primary", children: "Create an account" }) }), _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Username ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "text", value: username, onChange: (e) => setUsername(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoFocus: true, autoComplete: "username" })] }), _jsxs("div", { className: "mb-5", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Display Name" }), _jsx("input", { type: "text", value: displayName, onChange: (e) => setDisplayName(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "name" })] }), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Password ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "new-password" })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: isLoading ? 'Creating account...' : 'Continue' }), _jsxs("p", { className: "mt-3 text-sm text-discord-text-muted", children: ["Already have an account?", ' ', _jsx(Link, { to: "/login", className: "text-[#00aff4] hover:underline", children: "Log In" })] })] })] }) })); +} diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx new file mode 100644 index 00000000..52747099 --- /dev/null +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../stores/authStore'; + +export function RegisterPage() { + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const register = useAuthStore((s) => s.register); + const isLoading = useAuthStore((s) => s.isLoading); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!username.trim()) { + setError('Username is required'); + return; + } + if (username.trim().length < 3 || username.trim().length > 32) { + setError('Username must be between 3 and 32 characters'); + return; + } + if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) { + setError('Username can only contain letters, numbers, and underscores'); + return; + } + if (!password) { + setError('Password is required'); + return; + } + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + try { + await register(username.trim(), password, displayName.trim() || undefined); + navigate('/channels/@me'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed'); + } + }; + + return ( +
+
+
+

Create an account

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all" + autoFocus + autoComplete="username" + /> +
+ +
+ + setDisplayName(e.target.value)} + className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all" + autoComplete="name" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all" + autoComplete="new-password" + /> +
+ + + +

+ Already have an account?{' '} + + Log In + +

+
+
+
+ ); +} diff --git a/packages/web/src/components/chat/ImagePreview.js b/packages/web/src/components/chat/ImagePreview.js new file mode 100644 index 00000000..2d2c752a --- /dev/null +++ b/packages/web/src/components/chat/ImagePreview.js @@ -0,0 +1,10 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useUIStore } from '../../stores/uiStore'; +export function ImagePreview() { + const imageUrl = useUIStore((s) => s.imagePreviewUrl); + const closeImagePreview = useUIStore((s) => s.closeImagePreview); + const activeModal = useUIStore((s) => s.activeModal); + if (activeModal !== 'imagePreview' || !imageUrl) + return null; + return (_jsxs("div", { className: "fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in cursor-pointer", onClick: closeImagePreview, children: [_jsx("button", { className: "absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10", onClick: closeImagePreview, children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) }), _jsx("img", { src: imageUrl, alt: "Preview", className: "max-w-[90vw] max-h-[90vh] object-contain rounded shadow-2xl", onClick: (e) => e.stopPropagation() })] })); +} diff --git a/packages/web/src/components/chat/ImagePreview.tsx b/packages/web/src/components/chat/ImagePreview.tsx new file mode 100644 index 00000000..f11fcfd5 --- /dev/null +++ b/packages/web/src/components/chat/ImagePreview.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useUIStore } from '../../stores/uiStore'; + +export function ImagePreview() { + const imageUrl = useUIStore((s) => s.imagePreviewUrl); + const closeImagePreview = useUIStore((s) => s.closeImagePreview); + const activeModal = useUIStore((s) => s.activeModal); + + if (activeModal !== 'imagePreview' || !imageUrl) return null; + + return ( +
+ + Preview e.stopPropagation()} + /> +
+ ); +} diff --git a/packages/web/src/components/chat/Message.js b/packages/web/src/components/chat/Message.js new file mode 100644 index 00000000..deb1dadc --- /dev/null +++ b/packages/web/src/components/chat/Message.js @@ -0,0 +1,107 @@ +import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { Avatar } from '../ui/Avatar'; +import { ContextMenu } from '../ui/ContextMenu'; +import { useAuthStore } from '../../stores/authStore'; +import { useChatStore } from '../../stores/chatStore'; +import { useServerStore } from '../../stores/serverStore'; +import { useUIStore } from '../../stores/uiStore'; +function formatTime(timestamp) { + const date = new Date(timestamp); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const isYesterday = date.toDateString() === yesterday.toDateString(); + const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + if (isToday) + return `Today at ${time}`; + if (isYesterday) + return `Yesterday at ${time}`; + return `${date.toLocaleDateString()} ${time}`; +} +function formatHoverTime(timestamp) { + return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} +export function Message({ message, isCompact, isFirstInGroup }) { + const [isEditing, setIsEditing] = useState(false); + const [editContent, setEditContent] = useState(message.content ?? ''); + const [isHovered, setIsHovered] = useState(false); + const currentUser = useAuthStore((s) => s.user); + const editMessage = useChatStore((s) => s.editMessage); + const deleteMessage = useChatStore((s) => s.deleteMessage); + const members = useServerStore((s) => s.members); + const openImagePreview = useUIStore((s) => s.openImagePreview); + const isAuthor = currentUser?.id === message.userId; + const memberRole = members.find(m => m.userId === currentUser?.id)?.role; + const isAdminUser = memberRole === 'admin' || memberRole === 'owner'; + const canDelete = isAuthor || isAdminUser; + const contextMenuItems = []; + if (isAuthor) { + contextMenuItems.push({ + label: 'Edit Message', + onClick: () => { + setEditContent(message.content ?? ''); + setIsEditing(true); + }, + }); + } + if (canDelete) { + contextMenuItems.push({ + label: 'Delete Message', + onClick: () => deleteMessage(message.id), + danger: true, + }); + } + const handleEditSubmit = async (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (editContent.trim()) { + await editMessage(message.id, editContent.trim()); + setIsEditing(false); + } + } + if (e.key === 'Escape') { + setIsEditing(false); + setEditContent(message.content ?? ''); + } + }; + const displayName = message.user.displayName ?? message.user.username; + const roleColor = (() => { + const member = members.find(m => m.userId === message.userId); + if (member?.role === 'owner') + return 'text-discord-red'; + if (member?.role === 'admin') + return 'text-discord-blurple'; + return 'text-white'; + })(); + const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [_jsx("div", { className: "w-[72px] flex-shrink-0 flex items-start justify-center", children: isFirstInGroup ? (_jsx(Avatar, { src: message.user.avatar, name: displayName, size: 40, className: "mt-0.5 cursor-pointer" })) : (_jsx("span", { className: `text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`, children: formatHoverTime(message.createdAt) })) }), _jsxs("div", { className: "flex-1 min-w-0", children: [isFirstInGroup && (_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: `font-medium cursor-pointer hover:underline ${roleColor}`, children: displayName }), _jsx("span", { className: "text-xs text-discord-text-muted", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-2 bg-discord-bg-input rounded text-discord-text-primary outline-none resize-none text-sm", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-xs text-discord-text-muted mt-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-[#00aff4] hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => { + if (editContent.trim()) { + editMessage(message.id, editContent.trim()); + setIsEditing(false); + } + }, className: "text-[#00aff4] hover:underline", children: "save" })] })] })) : (_jsxs(_Fragment, { children: [message.content && (_jsxs("div", { className: "text-discord-text-primary text-sm leading-[1.375rem] break-words", children: [_jsx(ReactMarkdown, { components: { + p: ({ children }) => _jsx("span", { children: children }), + a: ({ href, children }) => (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[#00aff4] hover:underline", children: children })), + code: ({ children }) => (_jsx("code", { className: "px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono", children: children })), + pre: ({ children }) => (_jsx("pre", { className: "mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto", children: children })), + strong: ({ children }) => _jsx("strong", { className: "font-bold", children: children }), + em: ({ children }) => _jsx("em", { className: "italic", children: children }), + }, children: message.content }), message.editedAt && (_jsx("span", { className: "text-[10px] text-discord-text-muted ml-1", children: "(edited)" }))] })), message.attachments.length > 0 && (_jsx("div", { className: "mt-1 space-y-1", children: message.attachments.map((att) => { + const isImage = att.mimetype.startsWith('image/'); + if (isImage) { + return (_jsx("div", { className: "max-w-[400px]", children: _jsx("img", { src: `/api/uploads/${att.filename}`, alt: att.originalName, className: "max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow", onClick: () => openImagePreview(`/api/uploads/${att.filename}`), loading: "lazy" }) }, att.id)); + } + return (_jsxs("a", { href: `/api/uploads/${att.filename}`, download: att.originalName, className: "flex items-center gap-2 p-3 bg-discord-bg-secondary rounded border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors max-w-[400px]", children: [_jsx("svg", { className: "w-6 h-6 text-discord-text-muted flex-shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-[#00aff4] text-sm truncate hover:underline", children: att.originalName }), _jsx("p", { className: "text-xs text-discord-text-muted", children: att.size < 1024 ? `${att.size} B` : + att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` : + `${(att.size / 1048576).toFixed(1)} MB` })] })] }, att.id)); + }) }))] }))] }), isHovered && !isEditing && contextMenuItems.length > 0 && (_jsxs("div", { className: "absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md", children: [isAuthor && (_jsx("button", { onClick: () => { + setEditContent(message.content ?? ''); + setIsEditing(true); + }, className: "p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Edit", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M13.293 1.293a1 1 0 011.414 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM12 3l1 1-8 8-1.5.5.5-1.5L12 3z" }) }) })), canDelete && (_jsx("button", { onClick: () => deleteMessage(message.id), className: "p-1.5 text-discord-text-muted hover:text-discord-red transition-colors", title: "Delete", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" }) }) }))] }))] })); + if (contextMenuItems.length > 0) { + return _jsx(ContextMenu, { items: contextMenuItems, children: content }); + } + return content; +} diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx new file mode 100644 index 00000000..c798ff13 --- /dev/null +++ b/packages/web/src/components/chat/Message.tsx @@ -0,0 +1,264 @@ +import React, { useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import type { MessageWithUser } from '@opencord/shared'; +import { Avatar } from '../ui/Avatar'; +import { ContextMenu } from '../ui/ContextMenu'; +import { useAuthStore } from '../../stores/authStore'; +import { useChatStore } from '../../stores/chatStore'; +import { useServerStore } from '../../stores/serverStore'; +import { useUIStore } from '../../stores/uiStore'; + +interface MessageProps { + message: MessageWithUser; + isCompact: boolean; + isFirstInGroup: boolean; +} + +function formatTime(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const isYesterday = date.toDateString() === yesterday.toDateString(); + + const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + if (isToday) return `Today at ${time}`; + if (isYesterday) return `Yesterday at ${time}`; + return `${date.toLocaleDateString()} ${time}`; +} + +function formatHoverTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { + const [isEditing, setIsEditing] = useState(false); + const [editContent, setEditContent] = useState(message.content ?? ''); + const [isHovered, setIsHovered] = useState(false); + const currentUser = useAuthStore((s) => s.user); + const editMessage = useChatStore((s) => s.editMessage); + const deleteMessage = useChatStore((s) => s.deleteMessage); + const members = useServerStore((s) => s.members); + const openImagePreview = useUIStore((s) => s.openImagePreview); + + const isAuthor = currentUser?.id === message.userId; + const memberRole = members.find(m => m.userId === currentUser?.id)?.role; + const isAdminUser = memberRole === 'admin' || memberRole === 'owner'; + const canDelete = isAuthor || isAdminUser; + + const contextMenuItems = []; + if (isAuthor) { + contextMenuItems.push({ + label: 'Edit Message', + onClick: () => { + setEditContent(message.content ?? ''); + setIsEditing(true); + }, + }); + } + if (canDelete) { + contextMenuItems.push({ + label: 'Delete Message', + onClick: () => deleteMessage(message.id), + danger: true, + }); + } + + const handleEditSubmit = async (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (editContent.trim()) { + await editMessage(message.id, editContent.trim()); + setIsEditing(false); + } + } + if (e.key === 'Escape') { + setIsEditing(false); + setEditContent(message.content ?? ''); + } + }; + + const displayName = message.user.displayName ?? message.user.username; + + const roleColor = (() => { + const member = members.find(m => m.userId === message.userId); + if (member?.role === 'owner') return 'text-discord-red'; + if (member?.role === 'admin') return 'text-discord-blurple'; + return 'text-white'; + })(); + + const content = ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Avatar or timestamp column */} +
+ {isFirstInGroup ? ( + + ) : ( + + {formatHoverTime(message.createdAt)} + + )} +
+ + {/* Content */} +
+ {isFirstInGroup && ( +
+ + {displayName} + + + {formatTime(message.createdAt)} + +
+ )} + + {isEditing ? ( +
+