diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..39c6b58b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug Report +about: Report a bug in OwnCord +title: "bug: " +labels: bug +--- + +## Description + + + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + + + +## Actual Behavior + + + +## Environment + +- **OS**: Windows 11 (version) +- **OwnCord Version**: +- **Component**: Server / Client / Both + +## Screenshots / Logs + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..f415b94c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,22 @@ +--- +name: Feature Request +about: Suggest a new feature for OwnCord +title: "feat: " +labels: enhancement +--- + +## Problem + + + +## Proposed Solution + + + +## Alternatives Considered + + + +## Additional Context + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..c4bb1000 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ +# Pull Request + +## Summary + + + +- + +## Changes + + + +- + +## Test Plan + +- [ ] Unit tests pass (`npm test` / `go test ./...`) +- [ ] TypeScript check passes (`npx tsc --noEmit`) +- [ ] Manual testing done (describe below) + +## Screenshots + + + +## Related Issues + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ebc11c4b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,54 @@ +version: 2 + +updates: + # Go server dependencies + - package-ecosystem: gomod + directory: /Server + schedule: + interval: weekly + day: monday + commit-message: + prefix: "chore(deps):" + labels: + - dependencies + - go + open-pull-requests-limit: 10 + + # Tauri client npm dependencies + - package-ecosystem: npm + directory: /Client/tauri-client + schedule: + interval: weekly + day: monday + commit-message: + prefix: "chore(deps):" + labels: + - dependencies + - npm + open-pull-requests-limit: 10 + + # Tauri Rust/Cargo dependencies + - package-ecosystem: cargo + directory: /Client/tauri-client/src-tauri + schedule: + interval: weekly + day: monday + commit-message: + prefix: "chore(deps):" + labels: + - dependencies + - rust + open-pull-requests-limit: 5 + + # GitHub Actions + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + commit-message: + prefix: "ci(deps):" + labels: + - dependencies + - ci + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b2d70643 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main, dev] + +# Cancel in-progress runs for the same branch/PR +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + server-build-test: + name: Server Build & Test + runs-on: windows-latest + defaults: + run: + working-directory: Server/ + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Build server + run: go build -o chatserver.exe -ldflags "-s -w" . + + - name: Run tests with coverage + run: go test ./... -coverprofile=coverage.out -cover + + - name: Upload Go coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: go-coverage + path: Server/coverage.out + retention-days: 7 + + - name: Lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.11.3 + working-directory: Server/ + + client-check: + name: Client Typecheck & Test + runs-on: windows-latest + defaults: + run: + working-directory: Client/tauri-client/ + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: Client/tauri-client/package-lock.json + + - name: Install npm dependencies + run: npm ci + + - name: TypeScript check + run: npx tsc --noEmit + + - name: Run unit tests with coverage + run: npx vitest run --coverage --reporter=default + + - name: Upload client coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: client-coverage + path: Client/tauri-client/coverage/ + retention-days: 7 + + # Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier) + tauri-build: + name: Tauri Full Build + needs: client-check + if: github.event_name == 'pull_request' && github.base_ref == 'main' + runs-on: windows-latest + defaults: + run: + working-directory: Client/tauri-client/ + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: Client/tauri-client/package-lock.json + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: Client/tauri-client/src-tauri + + - name: Install npm dependencies + run: npm ci + + - name: Build Tauri app + run: npm run tauri build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..3e415b3d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,107 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + name: Build & Release + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: Client/tauri-client/package-lock.json + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: Client/tauri-client/src-tauri + + - name: Extract version from tag + shell: bash + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Build server + shell: bash + run: cd Server && go build -o chatserver.exe -ldflags "-s -w -X main.version=$VERSION" . + + - name: Install npm dependencies + working-directory: Client/tauri-client + run: npm ci + + - name: Build Tauri app + working-directory: Client/tauri-client + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npm run tauri build + + - name: Locate artifacts + id: artifacts + shell: bash + run: | + NSIS_DIR="Client/tauri-client/src-tauri/target/release/bundle/nsis" + INSTALLER=$(find "$NSIS_DIR" -name "*.exe" | head -1) + echo "installer_path=$INSTALLER" >> "$GITHUB_OUTPUT" + echo "installer_name=$(basename $INSTALLER)" >> "$GITHUB_OUTPUT" + # Updater artifacts (produced when TAURI_SIGNING_PRIVATE_KEY is set) + NSIS_ZIP=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip" ! -name "*.sig" | head -1) + NSIS_SIG=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip.sig" | head -1) + echo "nsis_zip=${NSIS_ZIP:-}" >> "$GITHUB_OUTPUT" + echo "nsis_sig=${NSIS_SIG:-}" >> "$GITHUB_OUTPUT" + + - name: Generate SHA256 checksums + shell: pwsh + run: | + $lines = @() + $serverHash = (Get-FileHash -Path Server/chatserver.exe -Algorithm SHA256).Hash.ToLower() + $lines += "$serverHash chatserver.exe" + $installerPath = "${{ steps.artifacts.outputs.installer_path }}" + $installerName = "${{ steps.artifacts.outputs.installer_name }}" + $clientHash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLower() + $lines += "$clientHash $installerName" + $nsisZip = "${{ steps.artifacts.outputs.nsis_zip }}" + if ($nsisZip -and (Test-Path $nsisZip)) { + $zipName = Split-Path $nsisZip -Leaf + $zipHash = (Get-FileHash -Path $nsisZip -Algorithm SHA256).Hash.ToLower() + $lines += "$zipHash $zipName" + } + $lines -join "`n" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + ASSETS=( + Server/chatserver.exe + "${{ steps.artifacts.outputs.installer_path }}" + checksums.sha256 + ) + # Include updater artifacts if signing key was available + if [ -n "${{ steps.artifacts.outputs.nsis_zip }}" ]; then + ASSETS+=("${{ steps.artifacts.outputs.nsis_zip }}") + fi + if [ -n "${{ steps.artifacts.outputs.nsis_sig }}" ]; then + ASSETS+=("${{ steps.artifacts.outputs.nsis_sig }}") + fi + gh release create ${{ github.ref_name }} \ + --generate-notes \ + "${ASSETS[@]}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2c5d3f8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Claude Code local config +.claude/ +Client/.claude/ +Server/.claude/ + +# Claude Code skills +skills/ + +# AI-specific / internal planning docs +Obsidian-Brain/ +IMPROVEMENTS.md +STOAT-RESEARCH.md +PROMPTS.md +SKILL.md +AUDIT.md +LANGUAGE-REVIEW.md +MIGRATION-PLAN.md +TESTING-STRATEGY.md +CLIENT-ARCHITECTURE.md +docs/superpowers/ +docs/brain/ +# Server runtime artifacts +Server/chatserver.exe +Server/chatserver.exe~ +Server/config.yaml +Server/data/ + +# Test coverage artifacts +*.out +Server/cov.out +Server/cover.out +Server/coverage.out +Server/ws_cover.out +Server/ws_cov.out + +# Client build artifacts +Client/publish/ +Client/publish-single/ +Client/publish-release/ + +# HTML mockups (large design reference files) +Client/login-mockup.html +Client/ui-mockup.html +.gstack/ diff --git a/API.md b/API.md deleted file mode 100644 index 93149920..00000000 --- a/API.md +++ /dev/null @@ -1,237 +0,0 @@ -# REST API Spec - -Base URL: `https://{server}:{port}/api` - -Auth: session token in cookie `session` (set on login) or `Authorization: Bearer {token}` header for programmatic access. - -All responses are JSON. Errors return `{ "error": "CODE", "message": "Human-readable detail" }`. - ---- - -## Auth - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| POST | `/api/auth/register` | None (requires invite code) | Create account | -| POST | `/api/auth/login` | None | Login, returns session token | -| POST | `/api/auth/logout` | Yes | Invalidate current session | -| POST | `/api/auth/verify-totp` | Partial (after login with 2FA) | Submit TOTP code | - -### POST /api/auth/register -```json -// Request -{ "username": "alex", "password": "strongpassword", "invite_code": "abc123" } -// Response 201 -{ "user": { "id": 1, "username": "alex" }, "token": "session-token" } -``` - -### POST /api/auth/login -```json -// Request -{ "username": "alex", "password": "strongpassword" } -// Response 200 (no 2FA) -{ "token": "session-token", "requires_2fa": false } -// Response 200 (2FA required) -{ "partial_token": "temp-token", "requires_2fa": true } -``` - ---- - -## Users - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/users/me` | Yes | Get current user profile | -| PATCH | `/api/users/me` | Yes | Update own profile (username, avatar) | -| PUT | `/api/users/me/password` | Yes | Change password | -| POST | `/api/users/me/totp/enable` | Yes | Start 2FA setup, returns QR URI + backup codes | -| POST | `/api/users/me/totp/confirm` | Yes | Confirm 2FA with first TOTP code | -| DELETE | `/api/users/me/totp` | Yes | Disable 2FA | -| GET | `/api/users/me/sessions` | Yes | List active sessions | -| DELETE | `/api/users/me/sessions/{id}` | Yes | Revoke a session | - ---- - -## Channels - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/channels` | Yes | List all channels user can see | -| GET | `/api/channels/{id}/messages` | Yes | Paginated message history | -| GET | `/api/channels/{id}/pins` | Yes | Get pinned messages | -| POST | `/api/channels/{id}/pins/{msg_id}` | Yes (mod) | Pin a message | -| DELETE | `/api/channels/{id}/pins/{msg_id}` | Yes (mod) | Unpin a message | - -### GET /api/channels/{id}/messages -Query params: `before` (message ID), `limit` (1-100, default 50) -```json -// Response 200 -{ - "messages": [ - { - "id": 1042, - "channel_id": 5, - "user": { "id": 1, "username": "alex", "avatar": "uuid.png" }, - "content": "Hello!", - "reply_to": null, - "attachments": [], - "reactions": [{ "emoji": "👍", "count": 2, "me": true }], - "pinned": false, - "edited_at": null, - "deleted": false, - "timestamp": "2026-03-14T10:30:00Z" - } - ], - "has_more": true -} -``` - ---- - -## File Uploads - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| POST | `/api/uploads` | Yes | Upload a file (multipart) | -| GET | `/api/files/{uuid}` | Yes | Download a file | - -### POST /api/uploads -Multipart form data. Field: `file`. Max size from server config (default 25MB). - -```json -// Response 201 -{ "id": "upload-uuid", "filename": "photo.jpg", "size": 204800, "mime": "image/jpeg", "url": "/api/files/upload-uuid" } -``` - -Server validates: magic bytes, rejects executables, strips EXIF, stores with UUID filename. - ---- - -## Search - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/search` | Yes | Full-text search across accessible channels | - -Query params: `q` (search query), `channel_id` (optional filter), `limit` (default 25) - -```json -// Response 200 -{ - "results": [ - { - "message_id": 1042, - "channel_id": 5, - "channel_name": "general", - "user": { "id": 1, "username": "alex" }, - "content": "...matched text...", - "timestamp": "2026-03-14T10:30:00Z" - } - ] -} -``` - ---- - -## Invites - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/invites` | Yes (admin) | List all invites | -| POST | `/api/invites` | Yes (manage_invites) | Create an invite | -| DELETE | `/api/invites/{id}` | Yes (manage_invites) | Revoke an invite | - -### POST /api/invites -```json -// Request -{ "max_uses": 5, "expires_in_hours": 48 } -// Response 201 -{ "id": 1, "code": "abc123def", "url": "chatserver://invite/abc123def", "max_uses": 5, "expires_at": "2026-03-16T10:30:00Z" } -``` - ---- - -## Admin Endpoints (admin panel uses these) - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/admin/stats` | Admin | Server stats (users, messages, disk, uptime) | -| GET | `/api/admin/users` | Admin | List all users with details | -| PATCH | `/api/admin/users/{id}` | Admin | Update user (role, ban/unban) | -| DELETE | `/api/admin/users/{id}/sessions` | Admin | Force logout a user | -| POST | `/api/admin/channels` | Admin | Create channel | -| PATCH | `/api/admin/channels/{id}` | Admin | Update channel | -| DELETE | `/api/admin/channels/{id}` | Admin | Delete channel | -| GET | `/api/admin/audit-log` | Admin | View audit log (paginated) | -| POST | `/api/admin/backup` | Owner | Trigger manual backup | -| GET | `/api/admin/backups` | Owner | List available backups | -| POST | `/api/admin/backups/{id}/restore` | Owner | Restore from backup | -| GET | `/api/admin/settings` | Admin | Get server settings | -| PATCH | `/api/admin/settings` | Admin | Update server settings | -| GET | `/api/admin/update-check` | Admin | Check for new server version | - ---- - -## WebRTC / TURN Credentials - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/voice/credentials` | Yes | Get time-limited TURN credentials | - -```json -// Response 200 -{ - "ice_servers": [ - { "urls": "stun:server:3478" }, - { "urls": "turn:server:3478", "username": "timestamp:userid", "credential": "hmac-hash" } - ], - "expires_in": 86400 -} -``` - ---- - -## Custom Emoji - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/emoji` | Yes | List all custom emoji | -| POST | `/api/emoji` | Yes (admin) | Upload new emoji | -| DELETE | `/api/emoji/{id}` | Yes (admin) | Delete emoji | - ---- - -## Soundboard - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/sounds` | Yes | List all soundboard sounds | -| POST | `/api/sounds` | Yes (permission) | Upload a sound | -| DELETE | `/api/sounds/{id}` | Yes (admin) | Delete a sound | - ---- - -## Health Check - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/api/health` | None | Returns 200 if server is running | - -```json -{ "status": "ok", "version": "1.0.0", "uptime": 86400 } -``` - ---- - -## Error Codes - -| Code | HTTP Status | Meaning | -|------|-------------|---------| -| `UNAUTHORIZED` | 401 | Missing or invalid session | -| `FORBIDDEN` | 403 | Insufficient permissions | -| `NOT_FOUND` | 404 | Resource not found | -| `RATE_LIMITED` | 429 | Too many requests (includes `retry_after`) | -| `INVALID_INPUT` | 400 | Bad request body or params | -| `CONFLICT` | 409 | e.g. username already taken | -| `TOO_LARGE` | 413 | File exceeds upload limit | -| `SERVER_ERROR` | 500 | Internal server error | diff --git a/CHATSERVER.md b/CHATSERVER.md deleted file mode 100644 index 4b9d7682..00000000 --- a/CHATSERVER.md +++ /dev/null @@ -1,186 +0,0 @@ -# ChatServer — Self-Hosted Windows Chat Platform - -Native Windows desktop client + self-hosted server. Two executables: `chatserver.exe` (server) and `chatclient.exe` (client app). Server operator runs the server, friends install the client. - -## Tech Stack - -### Server (`chatserver.exe`) -- **Go** — Single exe, no dependencies. Embeds admin web UI via `go embed`. -- **SQLite** — Single `.db` file. WAL mode. Zero config. -- **Pion** — Pure Go WebRTC. Voice/video/TURN built into the exe. -- **Admin panel** — Web-based only, served at `/admin`. Browser access, not part of the client. - -### Client (`chatclient.exe`) -Choose the best language and framework for a native Windows desktop app based on these requirements: -- Must be a native desktop application, NOT browser-based (no Electron) -- Small install size (~20-40MB) and low RAM usage (~50-100MB idle) -- WebSocket client for real-time chat -- WebRTC integration for voice/video -- Low-latency audio I/O (WASAPI or equivalent) -- Global keyboard hooks for push-to-talk that work in fullscreen games -- System tray with badge overlay -- Windows toast notifications -- DXGI Desktop Duplication for screen capture -- Windows Credential Manager for secure token storage -- Installer via NSIS or WiX - -## Architecture - -``` -SERVER (chatserver.exe) — runs on the host machine -├── REST API (Go net/http) -├── WebSocket Hub (real-time messages, presence, typing) -├── WebRTC SFU + TURN Relay (Pion) -├── SQLite Database (data/chatserver.db) -├── File Storage (data/uploads/) -├── Admin Web UI (embedded, browser-based, /admin) -└── config.yaml - -CLIENT (chatclient.exe) — installed by each friend -├── Native Windows UI -├── WebSocket Client (chat connection) -├── WebRTC Client (voice/video) -├── Audio Engine (device management, noise suppression) -├── Local Settings (connection profiles, keybinds, audio config) -└── System Tray Integration -``` - -### How It Works -1. Server operator runs `chatserver.exe` on their PC/home server -2. Friends download and install `chatclient.exe` -3. Client connects to the server via IP/domain + port -4. All chat, voice, video, and file transfers go through the server -5. Admin manages the server through a browser at `https://server-ip:port/admin` - ---- - -## Phase 1: Protocol & Server Core (2–3 weeks) - -- [ ] Define client-server protocol over WebSocket (JSON messages with type/payload structure) -- [ ] Message types: auth, chat, typing, presence, channel_update, voice_signal, file_transfer -- [ ] Server: Go project with `go embed` for admin panel static files only -- [ ] SQLite setup with migrations on startup (users, channels, messages, sessions, roles, invites) -- [ ] config.yaml generation on first run (port, server name, max upload size, voice quality, TLS mode) -- [ ] Server systray icon (getlantern/systray) — minimize to tray, status indicator, open admin panel, quit -- [ ] Windows Firewall handling on first launch -- [ ] Optional: register as Windows Service for headless operation - -## Phase 2: Auth & Security (2–3 weeks) - -- [ ] Invite-only registration — server generates invite codes, client has "Redeem Invite" flow -- [ ] bcrypt (cost 12+) passwords, server-side session tokens (256-bit random) -- [ ] Client stores auth token securely via Windows Credential Manager / DPAPI -- [ ] Login rate limiting: 5 attempts/min/IP, lockout after 10 failures -- [ ] Optional TOTP 2FA (`pquerna/otp`) — client shows QR code during setup, prompts on login -- [ ] Roles: Owner, Admin, Moderator, Member + custom roles with bitfield permissions -- [ ] Per-channel permission overrides, enforced server-side on every action -- [ ] TLS modes: self-signed (default, auto-generated), Let's Encrypt, manual cert, off (Tailscale) -- [ ] Client: certificate pinning or trust-on-first-use (TOFU) for self-signed certs - -## Phase 3: Client App — Core UI (3–4 weeks) - -- [ ] Connection dialog: server address, port, login/register, invite code entry -- [ ] Save server profiles (connect to multiple servers like TeamSpeak) -- [ ] Main window layout: server list sidebar → channel list → message area → member list -- [ ] Channel tree view with categories, text channels, voice channels -- [ ] Message rendering: markdown, code blocks, timestamps, avatars, replies, reactions -- [ ] Message input: multi-line, markdown preview, emoji picker, file drag-and-drop -- [ ] Unread indicators, @mention badges per channel -- [ ] System tray: minimize to tray, notification popups, badge count -- [ ] Keyboard shortcuts: Ctrl+K quick switcher, Escape to close panels, customizable push-to-talk key -- [ ] Settings window: account, appearance (light/dark theme), notifications, audio devices, keybinds - -## Phase 4: Real-Time Chat Features (2–3 weeks) - -- [ ] WebSocket client with auto-reconnect, exponential backoff, message replay on reconnect -- [ ] Send/receive messages in real-time, append to scrollback -- [ ] Message history: paginated from server on channel switch, scroll-to-load-more -- [ ] Threads, replies (inline preview), reactions (emoji), edit, delete -- [ ] Typing indicators ("X is typing..." below input) -- [ ] Online/offline/idle/DnD presence with status icons in member list -- [ ] File uploads: drag-and-drop or clipboard paste, progress bar, inline image previews -- [ ] Client-side file validation before upload (size check, warn on large files) -- [ ] Search: query server FTS5 endpoint, display results with jump-to-message -- [ ] Windows toast notifications with action buttons (reply, mark read) -- [ ] Notification sounds (configurable, per-channel mute/override) - -## Phase 5: Voice & Video (3–5 weeks) - -- [ ] WebRTC integration in native client for voice/video -- [ ] Audio device selection: input/output dropdowns in settings, live preview -- [ ] Voice channels: click to join/leave, show connected users with speaking indicators -- [ ] Voice controls: mute (button + keybind), deafen, per-user volume sliders -- [ ] Push-to-talk: configurable global hotkey that works in fullscreen games -- [ ] Voice activity detection with configurable sensitivity -- [ ] Noise suppression (RNNoise or equivalent, bundled with client) -- [ ] Server-side: Pion SFU with DTLS-SRTP, built-in TURN relay with per-session credentials -- [ ] Voice quality: low (32kbps) / medium (64kbps) / high (128kbps Opus) -- [ ] Screen sharing via DXGI Desktop Duplication, sent as video track -- [ ] Video calls: camera capture, displayed in voice channel panel -- [ ] Soundboard: short clips, hotkey triggers, role-based permissions, play cooldown - -## Phase 6: Admin Panel — Web-Based (1–2 weeks) - -- [ ] Served by server at `/admin`, browser-only access -- [ ] Auth: admin credentials, session-based -- [ ] Dashboard: connected users, message count, disk usage, CPU/RAM, uptime -- [ ] User management: list all, edit roles, ban/unban, reset password, force disconnect -- [ ] Channel management: create, rename, reorder, set permissions, archive -- [ ] Invite management: generate, view active, set expiry/use limit, revoke -- [ ] Server settings: name, icon, MOTD, max upload size, voice quality, TLS config -- [ ] Moderation: kick, ban, temp ban, slow mode, mute, word filter, audit log -- [ ] Backup: trigger manual backup, configure schedule, view/restore from admin panel -- [ ] Built with simple HTML/CSS/JS embedded in the server binary - -## Phase 7: Distribution & Updates (1–2 weeks) - -- [ ] **Server:** GitHub Actions builds `chatserver.exe` (amd64), SHA256 checksum, GitHub Release -- [ ] **Client:** NSIS or WiX installer — Program Files, Start Menu shortcut, optional auto-start, protocol handler for `chatserver://` invite links -- [ ] Client auto-update: check GitHub releases on launch, prompt to download + install -- [ ] Server update: admin panel shows available update, one-click download + restart -- [ ] Docs: Quick Start, Port Forwarding guide, Tailscale guide, Client install guide -- [ ] Security hardening checklist for server operators -- [ ] SECURITY.md, README.md, CONTRIBUTING.md - ---- - -## Windows-Specific Details - -### Client -- **Installer:** NSIS or WiX (~20-40MB). Registers `chatserver://` protocol handler for invite links. -- **Auto-start:** Registry key `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`. -- **Credentials:** Auth tokens stored in Windows Credential Manager (DPAPI). -- **Push-to-talk:** Global keyboard hook via `SetWindowsHookEx` — works in fullscreen games. -- **Audio:** WASAPI for low-latency capture/playback. -- **Screen capture:** DXGI Desktop Duplication API. -- **Notifications:** Windows Toast notifications with action buttons. -- **Tray:** System tray icon with unread badge overlay. - -### Server -- **Firewall:** Prompt on first run. Installer can pre-register firewall rule. -- **SmartScreen:** Unsigned exe shows warning. Code signing cert resolves this. -- **Data path:** `data/` next to exe. Installer version uses `%APPDATA%/ChatServer/`. -- **Logs:** `data/logs/` with daily rotation, viewable from admin panel. -- **Service mode:** `chatserver.exe --service install` to register as Windows Service. - -## Security Priorities - -**Critical:** Invite-only registration, bcrypt auth, TLS (self-signed minimum), file upload validation (magic bytes, block executables), input sanitization server-side, credential storage via DPAPI, backup system. - -**High:** Rate limiting, TOTP 2FA, role permissions, WebSocket auth, TURN credentials, cert pinning/TOFU, update integrity (SHA256). - -## Server Libraries (Go) - -| Purpose | Library | -|---|---| -| HTTP/routing | `net/http` + `chi` | -| WebSocket | `nhooyr.io/websocket` | -| WebRTC/TURN | `pion/webrtc` + `pion/turn` | -| SQLite | `modernc.org/sqlite` (pure Go) | -| Auth | `golang.org/x/crypto/bcrypt` | -| TOTP | `pquerna/otp` | -| Sanitization | `bluemonday` | -| TLS | `golang.org/x/crypto/acme/autocert` | -| Systray | `getlantern/systray` | -| Config | `koanf` | -| Logging | `log/slog` | diff --git a/Client/CLIENT-REVIEW.md b/Client/CLIENT-REVIEW.md new file mode 100644 index 00000000..5b17dad4 --- /dev/null +++ b/Client/CLIENT-REVIEW.md @@ -0,0 +1,19 @@ +# Client Code Review Findings + +Date: 2026-03-16 + +Scope: `Client/tauri-client/src` + +## High + +- Auth token is never set in `authStore` after login. The `auth_ok` handler calls `setAuth(authStore.getState().token ?? "", ...)`, so the store token becomes an empty string. Any future logic that relies on `authStore.token` (re-auth, API helpers, telemetry) will be wrong. File: `Client/tauri-client/src/lib/dispatcher.ts`. +- If Tauri APIs are unavailable, `ws.connect` logs an error and returns early but leaves the connection state as `connecting` and never schedules a retry or notifies the UI. This can hang the client in a pseudo-connecting state in browser/test contexts. File: `Client/tauri-client/src/lib/ws.ts`. + +## Medium + +- Server-driven voice disconnects do not clear `currentChannelId`. The dispatcher handles `voice_leave` by removing users only; it does not call `leaveVoiceChannel()` when the current user is removed, so the voice widget can stay visible after kicks/disconnects. Files: `Client/tauri-client/src/lib/dispatcher.ts`, `Client/tauri-client/src/stores/voice.store.ts`, `Client/tauri-client/src/components/VoiceWidget.ts`. +- Theme/font-size/compact-mode preferences are applied only when the Settings overlay is opened. On app start, stored preferences are not applied, causing UI to render in default theme until the user opens Settings. File: `Client/tauri-client/src/components/SettingsOverlay.ts`. + +## Low + +- Infinite scroll throttling in the message list uses a fixed `500ms` timeout to reset `loadingOlder`, independent of the fetch completion. On slow responses this can trigger overlapping loads or repeated requests. File: `Client/tauri-client/src/components/MessageList.ts`. diff --git a/Client/login-mockup.html b/Client/login-mockup.html new file mode 100644 index 00000000..a26c4dbe --- /dev/null +++ b/Client/login-mockup.html @@ -0,0 +1,1967 @@ + + + + + +OwnCord — Connect + + + + + +
+ + + + + +
+ +
+

Your Servers

+
+ +
+
+ +
+ +
+ Tip: Share chatserver://host:port/invite/CODE links to invite friends +
+ + +
+ + +
+
+
+ + +
+
+
+
+ + + + + + + + + + + +
+
+
O
+
+ + + +
+
+
Connected!
+
Logged in as LordJebus
+
+
+
+ Loading server data... +
+
+ + +
+ +
+ +
+
+
+ + + + diff --git a/Client/tauri-client/.gitignore b/Client/tauri-client/.gitignore new file mode 100644 index 00000000..c261136d --- /dev/null +++ b/Client/tauri-client/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +src-tauri/target/ +src-tauri/gen/ +*.tsbuildinfo +.vite/ +coverage/ +playwright-report/ +test-results/ diff --git a/Client/tauri-client/index.html b/Client/tauri-client/index.html new file mode 100644 index 00000000..143542fa --- /dev/null +++ b/Client/tauri-client/index.html @@ -0,0 +1,12 @@ + + + + + + OwnCord + + +
+ + + diff --git a/Client/tauri-client/package-lock.json b/Client/tauri-client/package-lock.json new file mode 100644 index 00000000..f678639b --- /dev/null +++ b/Client/tauri-client/package-lock.json @@ -0,0 +1,3398 @@ +{ + "name": "owncord-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "owncord-client", + "version": "0.1.0", + "dependencies": { + "@jitsi/rnnoise-wasm": "^0.2.1", + "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-fs": "^2.4.5", + "@tauri-apps/plugin-global-shortcut": "^2", + "@tauri-apps/plugin-http": "^2.5.7", + "@tauri-apps/plugin-notification": "^2", + "@tauri-apps/plugin-opener": "^2.5.3", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-store": "^2", + "@tauri-apps/plugin-updater": "^2.10.0" + }, + "devDependencies": { + "@playwright/test": "^1", + "@tauri-apps/cli": "^2", + "@vitest/coverage-v8": "^3", + "jsdom": "^29.0.0", + "typescript": "^5.7", + "vite": "^6", + "vitest": "^3" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", + "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jitsi/rnnoise-wasm": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@jitsi/rnnoise-wasm/-/rnnoise-wasm-0.2.1.tgz", + "integrity": "sha512-iEj77www43pS2Yq+cfLZb+hFuI7L5ccisBzzPMcOjjLsG4/LAlkD1CY58/8gc84nHdLBGmD/OPIWGnvYnXvB0A==" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", + "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.1", + "@tauri-apps/cli-darwin-x64": "2.10.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", + "@tauri-apps/cli-linux-arm64-musl": "2.10.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-musl": "2.10.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", + "@tauri-apps/cli-win32-x64-msvc": "2.10.1" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", + "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", + "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", + "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", + "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", + "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", + "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", + "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", + "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", + "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", + "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", + "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz", + "integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-global-shortcut": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz", + "integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-http": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz", + "integrity": "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", + "integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", + "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-store": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz", + "integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz", + "integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", + "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.2", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz", + "integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/Client/tauri-client/package.json b/Client/tauri-client/package.json new file mode 100644 index 00000000..94131225 --- /dev/null +++ b/Client/tauri-client/package.json @@ -0,0 +1,43 @@ +{ + "name": "owncord-client", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri", + "test": "vitest run", + "test:unit": "vitest run tests/unit", + "test:integration": "vitest run tests/integration", + "test:e2e": "playwright test", + "test:e2e:prod": "npm run build && playwright test --config playwright.config.prod.ts", + "test:e2e:native": "playwright test --config playwright.config.native.ts", + "test:e2e:ui": "playwright test --ui", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@playwright/test": "^1", + "@tauri-apps/cli": "^2", + "@vitest/coverage-v8": "^3", + "jsdom": "^29.0.0", + "typescript": "^5.7", + "vite": "^6", + "vitest": "^3" + }, + "dependencies": { + "@jitsi/rnnoise-wasm": "^0.2.1", + "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-fs": "^2.4.5", + "@tauri-apps/plugin-global-shortcut": "^2", + "@tauri-apps/plugin-http": "^2.5.7", + "@tauri-apps/plugin-notification": "^2", + "@tauri-apps/plugin-opener": "^2.5.3", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-store": "^2", + "@tauri-apps/plugin-updater": "^2.10.0" + } +} diff --git a/Client/tauri-client/playwright.config.native.ts b/Client/tauri-client/playwright.config.native.ts new file mode 100644 index 00000000..328cf5d0 --- /dev/null +++ b/Client/tauri-client/playwright.config.native.ts @@ -0,0 +1,41 @@ +import { defineConfig } from "@playwright/test"; + +/** + * Playwright config for testing against the REAL Tauri production app. + * + * Connects to the WebView2 window via Chrome DevTools Protocol (CDP). + * The custom fixture in tests/e2e/native-fixture.ts launches the Tauri + * exe with WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port + * and connects Playwright to it via chromium.connectOverCDP(). + * + * Requirements: + * - Built Tauri exe: npm run tauri build + * - Running server: Server/chatserver.exe (or set OWNCORD_SERVER_URL) + * + * Usage: npm run test:e2e:native + */ +export default defineConfig({ + testDir: "./tests/e2e/native", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + // Native tests are slower (real app startup) — run sequentially + fullyParallel: false, + workers: 1, + retries: 2, + reporter: process.env.CI + ? [["html", { open: "never" }], ["junit", { outputFile: "test-results/native-junit.xml" }]] + : "html", + + use: { + actionTimeout: 15_000, + navigationTimeout: 30_000, + screenshot: "only-on-failure", + trace: "on-first-retry", + video: "on-first-retry", + }, + + // No webServer — we launch the Tauri app ourselves in the fixture. + // No projects — we connect directly to WebView2 via CDP, not via browser launch. +}); diff --git a/Client/tauri-client/playwright.config.prod.ts b/Client/tauri-client/playwright.config.prod.ts new file mode 100644 index 00000000..f7c1a757 --- /dev/null +++ b/Client/tauri-client/playwright.config.prod.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright config for testing against the PRODUCTION build. + * Uses `vite preview` to serve the built dist/ folder — the same + * HTML/CSS/JS that Tauri bundles into the exe. + * + * Usage: npm run test:e2e:prod + */ +export default defineConfig({ + testDir: "./tests/e2e", + testIgnore: ["**/native/**"], + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI + ? [["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]] + : "html", + + use: { + baseURL: "http://localhost:4173", + actionTimeout: 10_000, + navigationTimeout: 15_000, + screenshot: "only-on-failure", + trace: "on-first-retry", + video: "on-first-retry", + contextOptions: { reducedMotion: "reduce" }, + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + webServer: { + command: "npm run preview", + url: "http://localhost:4173", + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); diff --git a/Client/tauri-client/playwright.config.ts b/Client/tauri-client/playwright.config.ts new file mode 100644 index 00000000..89c572aa --- /dev/null +++ b/Client/tauri-client/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + testIgnore: ["**/native/**"], + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI + ? [["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]] + : "html", + + use: { + baseURL: "http://localhost:1420", + actionTimeout: 10_000, + navigationTimeout: 15_000, + screenshot: "only-on-failure", + trace: "on-first-retry", + video: "on-first-retry", + contextOptions: { reducedMotion: "reduce" }, + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + webServer: { + command: "npm run dev", + url: "http://localhost:1420", + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); diff --git a/Client/tauri-client/public/rnnoise-worklet.js b/Client/tauri-client/public/rnnoise-worklet.js new file mode 100644 index 00000000..0b96f205 --- /dev/null +++ b/Client/tauri-client/public/rnnoise-worklet.js @@ -0,0 +1,191 @@ +// ============================================================================= +// RNNoise AudioWorklet Processor +// +// Runs on the audio rendering thread. Receives WASM module bytes from the +// main thread, initializes RNNoise, and processes 480-sample frames at 48kHz. +// ============================================================================= + +const FRAME_SIZE = 480; + +class RNNoiseProcessor extends AudioWorkletProcessor { + constructor() { + super(); + + /** @type {WebAssembly.Instance | null} */ + this._instance = null; + /** @type {number} */ + this._state = 0; + /** @type {number} */ + this._inputPtr = 0; + /** @type {number} */ + this._outputPtr = 0; + /** @type {Float32Array | null} */ + this._heapF32 = null; + /** @type {boolean} */ + this._ready = false; + /** @type {boolean} */ + this._destroyed = false; + + // Ring buffer to accumulate 480-sample frames + this._inputRing = new Float32Array(FRAME_SIZE); + this._inputRingOffset = 0; + + // Output ring buffer (fixed-size, prevents unbounded growth) + this._outCapacity = 50; + this._outRing = new Array(this._outCapacity); + this._outWriteIdx = 0; + this._outReadIdx = 0; + this._outCount = 0; + this._outSampleOffset = 0; + + this.port.onmessage = (event) => { + if (event.data.type === "init") { + this._initWasm(event.data.wasmBytes); + } else if (event.data.type === "destroy") { + this._cleanup(); + } + }; + } + + async _initWasm(wasmBytes) { + try { + const memory = new WebAssembly.Memory({ initial: 256 }); + const importObject = { + env: { + memory, + emscripten_notify_memory_growth: () => { + this._heapF32 = new Float32Array(memory.buffer); + }, + }, + wasi_snapshot_preview1: { + proc_exit: () => {}, + fd_close: () => 0, + fd_write: () => 0, + fd_seek: () => 0, + }, + }; + + // Try instantiating with the raw WASM bytes + const { instance } = await WebAssembly.instantiate(wasmBytes, importObject); + this._instance = instance; + this._heapF32 = new Float32Array(memory.buffer); + + // Call RNNoise C API + const exports = instance.exports; + this._state = exports.rnnoise_create(); + this._inputPtr = exports.malloc(FRAME_SIZE * 4); + this._outputPtr = exports.malloc(FRAME_SIZE * 4); + + this._ready = true; + this.port.postMessage({ type: "ready" }); + } catch (err) { + // Fallback: the WASM module may use Emscripten-style exports + // that need the full runtime. Signal failure so the main thread + // can fall back to ScriptProcessorNode. + this.port.postMessage({ type: "error", message: String(err) }); + } + } + + _processFrame() { + if (!this._instance || !this._heapF32) return; + const exports = this._instance.exports; + + const inOff = this._inputPtr / 4; + for (let i = 0; i < FRAME_SIZE; i++) { + this._heapF32[inOff + i] = this._inputRing[i] * 32768; + } + + exports.rnnoise_process_frame(this._state, this._outputPtr, this._inputPtr); + + const outOff = this._outputPtr / 4; + const result = new Float32Array(FRAME_SIZE); + for (let i = 0; i < FRAME_SIZE; i++) { + result[i] = this._heapF32[outOff + i] / 32768; + } + + // Write to ring buffer, dropping oldest if full + if (this._outCount >= this._outCapacity) { + this._outReadIdx = (this._outReadIdx + 1) % this._outCapacity; + this._outCount--; + this._outSampleOffset = 0; + } + this._outRing[this._outWriteIdx] = result; + this._outWriteIdx = (this._outWriteIdx + 1) % this._outCapacity; + this._outCount++; + } + + _cleanup() { + if (this._instance && this._state) { + try { + const exports = this._instance.exports; + exports.rnnoise_destroy(this._state); + exports.free(this._inputPtr); + exports.free(this._outputPtr); + } catch { + // Best-effort cleanup + } + } + this._ready = false; + this._destroyed = true; + this._state = 0; + } + + process(inputs, outputs) { + if (this._destroyed) return false; + if (!this._ready) { + // Pass through until WASM is ready + const input = inputs[0]; + const output = outputs[0]; + if (input && output && input[0] && output[0]) { + output[0].set(input[0]); + } + return true; + } + + const input = inputs[0]; + const output = outputs[0]; + if (!input || !output || !input[0] || !output[0]) return true; + + const inData = input[0]; + const outData = output[0]; + + // Feed input into ring buffer, process complete frames + let inIdx = 0; + while (inIdx < inData.length) { + const needed = FRAME_SIZE - this._inputRingOffset; + const toCopy = Math.min(needed, inData.length - inIdx); + this._inputRing.set(inData.subarray(inIdx, inIdx + toCopy), this._inputRingOffset); + this._inputRingOffset += toCopy; + inIdx += toCopy; + + if (this._inputRingOffset >= FRAME_SIZE) { + this._processFrame(); + this._inputRingOffset = 0; + } + } + + // Drain processed frames into output + let outIdx = 0; + while (outIdx < outData.length && this._outCount > 0) { + const chunk = this._outRing[this._outReadIdx]; + const available = chunk.length - this._outSampleOffset; + const toWrite = Math.min(available, outData.length - outIdx); + outData.set(chunk.subarray(this._outSampleOffset, this._outSampleOffset + toWrite), outIdx); + outIdx += toWrite; + this._outSampleOffset += toWrite; + if (this._outSampleOffset >= chunk.length) { + this._outReadIdx = (this._outReadIdx + 1) % this._outCapacity; + this._outCount--; + this._outSampleOffset = 0; + } + } + // Fill remaining with silence + if (outIdx < outData.length) { + outData.fill(0, outIdx); + } + + return true; + } +} + +registerProcessor("rnnoise-processor", RNNoiseProcessor); diff --git a/Client/tauri-client/public/rnnoise.wasm b/Client/tauri-client/public/rnnoise.wasm new file mode 100644 index 00000000..e4c3fd9f Binary files /dev/null and b/Client/tauri-client/public/rnnoise.wasm differ diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock new file mode 100644 index 00000000..f68f959d --- /dev/null +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -0,0 +1,6512 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dom_query" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.36.1", + "precomputed-hash", + "selectors 0.35.0", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e" +dependencies = [ + "log", + "markup5ever 0.36.1", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.13.0", + "selectors 0.24.0", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril", +] + +[[package]] +name = "markup5ever" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "notify-rust" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "owncord-client" +version = "0.1.0" +dependencies = [ + "futures-util", + "ring", + "rustls", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-global-shortcut", + "tauri-plugin-http", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-store", + "tauri-plugin-updater", + "tokio", + "tokio-tungstenite", + "url", + "windows 0.58.0", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml 0.38.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.4+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store 0.22.1", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.6", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.29.6", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-http" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc" +dependencies = [ + "bytes", + "cookie_store 0.21.1", + "data-url", + "http", + "regex", + "reqwest 0.12.28", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "tokio", + "url", + "urlpattern", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows 0.61.3", + "zbus", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-store" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever 0.29.1", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 1.0.0+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow 0.7.15", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement 0.60.2", + "windows-interface 0.59.3", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.54.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a24eda84b5d488f99344e54b807138896cee8df0b2d16c793f1f6b80e6d8df1f" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml new file mode 100644 index 00000000..55581d4f --- /dev/null +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "owncord-client" +version = "0.1.0" +edition = "2021" +description = "OwnCord Desktop Client" + +[lib] +name = "owncord_client_lib" +crate-type = ["lib", "cdylib", "staticlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon"] } +tauri-plugin-store = "2" +tauri-plugin-global-shortcut = "2" +tauri-plugin-notification = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls", "dangerous-settings"] } +tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" +tauri-plugin-fs = "2" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" +url = "2" +tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] } +futures-util = "0.3.32" +tokio = { version = "1", features = ["sync"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +ring = "0.17" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = ["Win32_Security_Credentials", "Win32_Foundation"] } diff --git a/Client/tauri-client/src-tauri/build.rs b/Client/tauri-client/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/Client/tauri-client/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/Client/tauri-client/src-tauri/capabilities/default.json b/Client/tauri-client/src-tauri/capabilities/default.json new file mode 100644 index 00000000..e1ce373c --- /dev/null +++ b/Client/tauri-client/src-tauri/capabilities/default.json @@ -0,0 +1,80 @@ +{ + "identifier": "default", + "description": "Default capability granting core permissions to the main window", + "windows": [ + "main" + ], + "permissions": [ + "core:default", + "core:event:default", + "core:window:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-is-visible", + "core:window:allow-set-position", + "core:window:allow-set-size", + "core:window:allow-maximize", + "core:window:allow-is-maximized", + "core:window:allow-outer-position", + "core:window:allow-outer-size", + "store:default", + "global-shortcut:default", + "global-shortcut:allow-register", + "global-shortcut:allow-unregister", + "global-shortcut:allow-unregister-all", + "global-shortcut:allow-is-registered", + "notification:default", + "notification:allow-notify", + "notification:allow-request-permission", + "notification:allow-is-permission-granted", + "http:default", + { + "identifier": "http:allow-fetch", + "allow": [ + { + "url": "https://*:*" + }, + { + "url": "https://*" + } + ] + }, + { + "identifier": "http:allow-fetch-send", + "allow": [ + { + "url": "https://*:*" + }, + { + "url": "https://*" + } + ] + }, + { + "identifier": "http:allow-fetch-read-body", + "allow": [ + { + "url": "https://*:*" + }, + { + "url": "https://*" + } + ] + }, + "http:allow-fetch-cancel", + "opener:default", + "dialog:default", + "updater:default", + "process:allow-restart", + "fs:default", + { + "identifier": "fs:allow-write-file", + "allow": [ + { + "path": "**" + } + ] + } + ] +} diff --git a/Client/tauri-client/src-tauri/icons/128x128.png b/Client/tauri-client/src-tauri/icons/128x128.png new file mode 100644 index 00000000..76d1894f Binary files /dev/null and b/Client/tauri-client/src-tauri/icons/128x128.png differ diff --git a/Client/tauri-client/src-tauri/icons/128x128@2x.png b/Client/tauri-client/src-tauri/icons/128x128@2x.png new file mode 100644 index 00000000..ea5d816f Binary files /dev/null and b/Client/tauri-client/src-tauri/icons/128x128@2x.png differ diff --git a/Client/tauri-client/src-tauri/icons/32x32.png b/Client/tauri-client/src-tauri/icons/32x32.png new file mode 100644 index 00000000..13089491 Binary files /dev/null and b/Client/tauri-client/src-tauri/icons/32x32.png differ diff --git a/Client/tauri-client/src-tauri/icons/icon.ico b/Client/tauri-client/src-tauri/icons/icon.ico new file mode 100644 index 00000000..5a525aeb Binary files /dev/null and b/Client/tauri-client/src-tauri/icons/icon.ico differ diff --git a/Client/tauri-client/src-tauri/icons/icon.png b/Client/tauri-client/src-tauri/icons/icon.png new file mode 100644 index 00000000..97eb9a9d Binary files /dev/null and b/Client/tauri-client/src-tauri/icons/icon.png differ diff --git a/Client/tauri-client/src-tauri/src/commands.rs b/Client/tauri-client/src-tauri/src/commands.rs new file mode 100644 index 00000000..1e65aeb5 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/commands.rs @@ -0,0 +1,90 @@ +use serde_json::Value; +use tauri_plugin_store::StoreExt; + +const SETTINGS_STORE: &str = "settings.json"; +const CERTS_STORE: &str = "certs.json"; + +// --------------------------------------------------------------------------- +// Settings commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn get_settings(app: tauri::AppHandle) -> Result { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("failed to open settings store: {e}"))?; + + let keys = store.keys(); + let mut map = serde_json::Map::new(); + for key in keys { + if let Some(val) = store.get(&key) { + map.insert(key, val); + } + } + Ok(Value::Object(map)) +} + +#[tauri::command] +pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result<(), String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("failed to open settings store: {e}"))?; + + store.set(&key, value); + store + .save() + .map_err(|e| format!("failed to persist settings: {e}"))?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Certificate fingerprint commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn store_cert_fingerprint( + app: tauri::AppHandle, + host: String, + fingerprint: String, +) -> Result<(), String> { + if host.is_empty() { + return Err("host must not be empty".into()); + } + if fingerprint.is_empty() { + return Err("fingerprint must not be empty".into()); + } + + let store = app + .store(CERTS_STORE) + .map_err(|e| format!("failed to open certs store: {e}"))?; + + store.set(&host, Value::String(fingerprint)); + store + .save() + .map_err(|e| format!("failed to persist cert fingerprint: {e}"))?; + Ok(()) +} + +#[tauri::command] +pub fn get_cert_fingerprint( + app: tauri::AppHandle, + host: String, +) -> Result, String> { + if host.is_empty() { + return Err("host must not be empty".into()); + } + + let store = app + .store(CERTS_STORE) + .map_err(|e| format!("failed to open certs store: {e}"))?; + + let value = store.get(&host).and_then(|v| { + if let Value::String(s) = v { + Some(s) + } else { + None + } + }); + + Ok(value) +} diff --git a/Client/tauri-client/src-tauri/src/credentials.rs b/Client/tauri-client/src-tauri/src/credentials.rs new file mode 100644 index 00000000..92ea931d --- /dev/null +++ b/Client/tauri-client/src-tauri/src/credentials.rs @@ -0,0 +1,181 @@ +use serde::Serialize; +use std::ptr; +use windows::core::{PCWSTR, PWSTR}; +use windows::Win32::Foundation::ERROR_NOT_FOUND; +use windows::Win32::Security::Credentials::{ + CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_FLAGS, + CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC, +}; + +/// Data returned from `load_credential`. +#[derive(Serialize, Clone, Debug)] +pub struct CredentialData { + pub username: String, + pub token: String, + /// Optional saved password (only present when user opted in). + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, +} + +/// Build the target name used in Windows Credential Manager. +fn target_name(host: &str) -> Vec { + let name = format!("OwnCord/{host}"); + name.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Encode a Rust string as a null-terminated UTF-16 vector. +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +/// Save a credential (username + token) to Windows Credential Manager. +/// +/// Target name: `OwnCord/{host}` +/// Blob: JSON `{"username":"...","token":"..."}` +#[tauri::command] +pub fn save_credential(host: String, username: String, token: String, password: Option) -> Result<(), String> { + if host.is_empty() { + return Err("host must not be empty".into()); + } + if token.is_empty() { + return Err("token must not be empty".into()); + } + if username.is_empty() { + return Err("username must not be empty".into()); + } + + let target = target_name(&host); + let wide_user = to_wide(&username); + + let mut payload = serde_json::json!({ + "username": username, + "token": token, + }); + if let Some(ref pw) = password { + payload["password"] = serde_json::Value::String(pw.clone()); + } + let blob = payload.to_string().into_bytes(); + + let mut cred = CREDENTIALW { + Flags: CRED_FLAGS(0), + Type: CRED_TYPE_GENERIC, + TargetName: PWSTR(target.as_ptr() as *mut u16), + Comment: PWSTR::null(), + LastWritten: Default::default(), + CredentialBlobSize: blob.len() as u32, + CredentialBlob: blob.as_ptr() as *mut u8, + Persist: CRED_PERSIST_LOCAL_MACHINE, + AttributeCount: 0, + Attributes: ptr::null_mut(), + TargetAlias: PWSTR::null(), + UserName: PWSTR(wide_user.as_ptr() as *mut u16), + }; + + unsafe { + CredWriteW(&mut cred, 0) + .map_err(|e| format!("CredWriteW failed: {e}"))?; + } + + Ok(()) +} + +/// Load a credential from Windows Credential Manager. +/// +/// Returns `None` when no credential exists for the given host. +#[tauri::command] +pub fn load_credential(host: String) -> Result, String> { + if host.is_empty() { + return Err("host must not be empty".into()); + } + + let target = target_name(&host); + let mut pcred: *mut CREDENTIALW = ptr::null_mut(); + + let read_result = unsafe { + CredReadW( + PCWSTR(target.as_ptr()), + CRED_TYPE_GENERIC, + 0, + &mut pcred, + ) + }; + + match read_result { + Ok(()) => {} + Err(e) => { + if e.code() == ERROR_NOT_FOUND.to_hresult() { + return Ok(None); + } + return Err(format!("CredReadW failed: {e}")); + } + } + + // SAFETY: `pcred` is valid after a successful CredReadW call. + let result = unsafe { + let cred = &*pcred; + let blob_slice = std::slice::from_raw_parts( + cred.CredentialBlob, + cred.CredentialBlobSize as usize, + ); + let json_str = String::from_utf8(blob_slice.to_vec()) + .map_err(|e| format!("credential blob is not valid UTF-8: {e}"))?; + + let parsed: serde_json::Value = serde_json::from_str(&json_str) + .map_err(|e| format!("credential blob is not valid JSON: {e}"))?; + + let username = parsed + .get("username") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let token = parsed + .get("token") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let password = parsed + .get("password") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // Free the credential memory allocated by Windows. + CredFree(pcred as *const std::ffi::c_void); + + Ok(Some(CredentialData { username, token, password })) + }; + + result +} + +/// Delete a credential from Windows Credential Manager. +#[tauri::command] +pub fn delete_credential(host: String) -> Result<(), String> { + if host.is_empty() { + return Err("host must not be empty".into()); + } + + let target = target_name(&host); + + let delete_result = unsafe { + CredDeleteW( + PCWSTR(target.as_ptr()), + CRED_TYPE_GENERIC, + 0, + ) + }; + + match delete_result { + Ok(()) => Ok(()), + Err(e) => { + if e.code() == ERROR_NOT_FOUND.to_hresult() { + // Deleting a non-existent credential is not an error. + return Ok(()); + } + Err(format!("CredDeleteW failed: {e}")) + } + } +} diff --git a/Client/tauri-client/src-tauri/src/hotkeys.rs b/Client/tauri-client/src-tauri/src/hotkeys.rs new file mode 100644 index 00000000..cf184bfd --- /dev/null +++ b/Client/tauri-client/src-tauri/src/hotkeys.rs @@ -0,0 +1,35 @@ +use tauri::{Emitter, Runtime}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; + +/// Registers a global push-to-talk shortcut that emits `ptt-press` and +/// `ptt-release` events to the frontend webview. +pub fn register_push_to_talk( + app: &tauri::AppHandle, + shortcut_str: &str, +) -> Result<(), Box> { + let shortcut: tauri_plugin_global_shortcut::Shortcut = shortcut_str.parse()?; + + // Remove any previous binding for this shortcut before registering. + if app.global_shortcut().is_registered(shortcut) { + app.global_shortcut().unregister(shortcut)?; + } + + let handle = app.clone(); + app.global_shortcut().on_shortcut(shortcut, move |_app, _shortcut, event| { + let event_name = match event.state { + ShortcutState::Pressed => "ptt-press", + ShortcutState::Released => "ptt-release", + }; + let _ = handle.emit(event_name, ()); + })?; + + Ok(()) +} + +/// Removes all registered global shortcuts. +pub fn unregister_all( + app: &tauri::AppHandle, +) -> Result<(), Box> { + app.global_shortcut().unregister_all()?; + Ok(()) +} diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs new file mode 100644 index 00000000..1e446051 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -0,0 +1,42 @@ +mod commands; +mod credentials; +mod hotkeys; +mod tray; +mod update_commands; +mod ws_proxy; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) + .manage(ws_proxy::WsState::new()) + .invoke_handler(tauri::generate_handler![ + commands::get_settings, + commands::save_settings, + commands::store_cert_fingerprint, + commands::get_cert_fingerprint, + ws_proxy::ws_connect, + ws_proxy::ws_send, + ws_proxy::ws_disconnect, + ws_proxy::accept_cert_fingerprint, + credentials::save_credential, + credentials::load_credential, + credentials::delete_credential, + update_commands::check_client_update, + update_commands::download_and_install_update, + ]) + .setup(|app| { + tray::create_tray(app.handle())?; + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/Client/tauri-client/src-tauri/src/main.rs b/Client/tauri-client/src-tauri/src/main.rs new file mode 100644 index 00000000..a427a3ef --- /dev/null +++ b/Client/tauri-client/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + owncord_client_lib::run() +} diff --git a/Client/tauri-client/src-tauri/src/tray.rs b/Client/tauri-client/src-tauri/src/tray.rs new file mode 100644 index 00000000..6624c442 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/tray.rs @@ -0,0 +1,92 @@ +use tauri::{ + menu::{Menu, MenuItem, Submenu}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Emitter, Manager, Runtime, +}; + +const SHOW_HIDE_ID: &str = "show_hide"; +const STATUS_ONLINE_ID: &str = "status_online"; +const STATUS_IDLE_ID: &str = "status_idle"; +const STATUS_DND_ID: &str = "status_dnd"; +const STATUS_OFFLINE_ID: &str = "status_offline"; +const QUIT_ID: &str = "quit"; + +pub fn create_tray(app: &tauri::AppHandle) -> Result<(), tauri::Error> { + let show_hide = MenuItem::with_id(app, SHOW_HIDE_ID, "Show/Hide", true, None::<&str>)?; + + let status_online = + MenuItem::with_id(app, STATUS_ONLINE_ID, "Online", true, None::<&str>)?; + let status_idle = MenuItem::with_id(app, STATUS_IDLE_ID, "Idle", true, None::<&str>)?; + let status_dnd = MenuItem::with_id(app, STATUS_DND_ID, "Do Not Disturb", true, None::<&str>)?; + let status_offline = + MenuItem::with_id(app, STATUS_OFFLINE_ID, "Offline", true, None::<&str>)?; + + let status_submenu = Submenu::with_items( + app, + "Status", + true, + &[ + &status_online, + &status_idle, + &status_dnd, + &status_offline, + ], + )?; + + let quit = MenuItem::with_id(app, QUIT_ID, "Quit", true, None::<&str>)?; + + let menu = Menu::with_items(app, &[&show_hide, &status_submenu, &quit])?; + + let app_handle = app.clone(); + let app_handle_menu = app.clone(); + + TrayIconBuilder::new() + .icon(app.default_window_icon().cloned().unwrap_or_else(|| tauri::image::Image::new(&[], 1, 1))) + .menu(&menu) + .tooltip("OwnCord") + .on_tray_icon_event(move |_tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + toggle_window_visibility(&app_handle); + } + }) + .on_menu_event(move |_tray, event| { + handle_menu_event(&app_handle_menu, event.id().as_ref()); + }) + .build(app)?; + + Ok(()) +} + +fn toggle_window_visibility(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + } + } +} + +fn handle_menu_event(app_handle: &tauri::AppHandle, id: &str) { + match id { + SHOW_HIDE_ID => toggle_window_visibility(app_handle), + STATUS_ONLINE_ID => emit_status_change(app_handle, "online"), + STATUS_IDLE_ID => emit_status_change(app_handle, "idle"), + STATUS_DND_ID => emit_status_change(app_handle, "dnd"), + STATUS_OFFLINE_ID => emit_status_change(app_handle, "offline"), + QUIT_ID => { + app_handle.exit(0); + } + _ => {} + } +} + +fn emit_status_change(app: &tauri::AppHandle, status: &str) { + let _ = app.emit("status-change", status); +} diff --git a/Client/tauri-client/src-tauri/src/update_commands.rs b/Client/tauri-client/src-tauri/src/update_commands.rs new file mode 100644 index 00000000..e404ab6d --- /dev/null +++ b/Client/tauri-client/src-tauri/src/update_commands.rs @@ -0,0 +1,107 @@ +use serde::Serialize; +use tauri::AppHandle; +use tauri_plugin_updater::UpdaterExt; + +#[derive(Serialize)] +pub struct UpdateCheckResult { + pub available: bool, + pub version: Option, + pub body: Option, +} + +/// Check for a client update using the given server URL to build the endpoint +/// dynamically. This is required because OwnCord is self-hosted and the +/// server address varies per user. +#[tauri::command] +pub async fn check_client_update( + app: AppHandle, + server_url: String, +) -> Result { + let current_version = app + .config() + .version + .clone() + .unwrap_or_else(|| "0.0.0".to_string()); + + let endpoint = format!( + "{}/api/v1/client-update/{{{{target}}}}/{}", + server_url.trim_end_matches('/'), + current_version, + ); + + let url: url::Url = endpoint + .parse() + .map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?; + + let updater = app + .updater_builder() + .endpoints(vec![url]) + .map_err(|e| format!("failed to set endpoints: {e}"))? + .build() + .map_err(|e| format!("failed to build updater: {e}"))?; + + let update = updater + .check() + .await + .map_err(|e| format!("update check failed: {e}"))?; + + match update { + Some(u) => Ok(UpdateCheckResult { + available: true, + version: Some(u.version.clone()), + body: Some(u.body.clone().unwrap_or_default()), + }), + None => Ok(UpdateCheckResult { + available: false, + version: None, + body: None, + }), + } +} + +/// Download and install a pending update, then signal the frontend. +/// The frontend should call `relaunch()` from @tauri-apps/plugin-process +/// after this completes. +#[tauri::command] +pub async fn download_and_install_update( + app: AppHandle, + server_url: String, +) -> Result<(), String> { + let current_version = app + .config() + .version + .clone() + .unwrap_or_else(|| "0.0.0".to_string()); + + let endpoint = format!( + "{}/api/v1/client-update/{{{{target}}}}/{}", + server_url.trim_end_matches('/'), + current_version, + ); + + let url: url::Url = endpoint + .parse() + .map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?; + + let updater = app + .updater_builder() + .endpoints(vec![url]) + .map_err(|e| format!("failed to set endpoints: {e}"))? + .build() + .map_err(|e| format!("failed to build updater: {e}"))?; + + let update = updater + .check() + .await + .map_err(|e| format!("update check failed: {e}"))?; + + match update { + Some(u) => { + u.download_and_install(|_chunk_len, _total| {}, || {}) + .await + .map_err(|e| format!("download/install failed: {e}"))?; + Ok(()) + } + None => Err("no update available".into()), + } +} diff --git a/Client/tauri-client/src-tauri/src/ws_proxy.rs b/Client/tauri-client/src-tauri/src/ws_proxy.rs new file mode 100644 index 00000000..f3145ede --- /dev/null +++ b/Client/tauri-client/src-tauri/src/ws_proxy.rs @@ -0,0 +1,375 @@ +// WebSocket proxy — routes WSS through Rust to bypass self-signed cert rejection. +// JS sends/receives messages via Tauri events instead of native WebSocket. +// +// Implements TOFU (Trust On First Use) certificate pinning: +// - On first connect to a host, the cert SHA-256 fingerprint is stored. +// - On subsequent connects, the fingerprint is compared with the stored value. +// - If the fingerprint changes, the connection is rejected (potential MitM). + +use futures_util::{SinkExt, StreamExt}; +use ring::digest::{digest, SHA256}; +use serde_json::Value; +use std::sync::Arc; +use std::time::Duration; +use tauri::{AppHandle, Emitter, Runtime}; +use tauri_plugin_store::StoreExt; +use tokio::sync::{mpsc, Mutex}; +use tokio_tungstenite::tungstenite::Message; + +/// Maximum time to wait for the WebSocket handshake to complete. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Tauri store file for certificate fingerprints. +const CERTS_STORE: &str = "certs.json"; + +/// Sender half kept in Tauri state so `ws_send` can push messages. +pub struct WsState { + tx: Mutex>>, +} + +impl WsState { + pub fn new() -> Self { + Self { + tx: Mutex::new(None), + } + } +} + +/// Shared fingerprint captured during TLS handshake. +type CapturedFingerprint = Arc>>; + +/// TOFU certificate verifier that captures the server cert fingerprint +/// during the TLS handshake. Still accepts self-signed certs (required +/// for self-hosted servers), but records the fingerprint for comparison +/// with the stored value after the connection is established. +#[derive(Debug)] +struct TofuVerifier { + captured: CapturedFingerprint, +} + +impl TofuVerifier { + fn new() -> (Self, CapturedFingerprint) { + let fp = Arc::new(std::sync::Mutex::new(None)); + (Self { captured: fp.clone() }, fp) + } +} + +impl rustls::client::danger::ServerCertVerifier for TofuVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + // Compute SHA-256 fingerprint of the DER-encoded leaf certificate. + let hash = digest(&SHA256, end_entity.as_ref()); + let hex = hash + .as_ref() + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":"); + + if let Ok(mut guard) = self.captured.lock() { + *guard = Some(hex); + } + + // Accept the cert — TOFU check happens after the handshake completes. + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::ECDSA_NISTP521_SHA512, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::ED25519, + rustls::SignatureScheme::ED448, + ] + } +} + +/// Extract the host (with port) from a wss:// URL. +fn extract_host(url: &str) -> String { + url.strip_prefix("wss://") + .unwrap_or(url) + .split('/') + .next() + .unwrap_or(url) + .to_string() +} + +/// Perform TOFU fingerprint check against the Tauri cert store. +/// Returns Ok(()) if trusted, Err(message) if fingerprint mismatch. +fn tofu_check( + app: &AppHandle, + host: &str, + fingerprint: &str, +) -> Result { + let store = app + .store(CERTS_STORE) + .map_err(|e| format!("failed to open certs store: {e}"))?; + + let stored = store.get(host).and_then(|v| { + if let Value::String(s) = v { + Some(s) + } else { + None + } + }); + + match stored { + None => { + // First use — store the fingerprint. + store.set(host, Value::String(fingerprint.to_string())); + if let Err(e) = store.save() { + return Err(format!("failed to persist cert fingerprint: {e}")); + } + Ok("trusted_first_use".to_string()) + } + Some(ref stored_fp) if stored_fp == fingerprint => { + Ok("trusted".to_string()) + } + Some(stored_fp) => { + Err(format!( + "Certificate fingerprint changed for {host}.\n\ + Stored: {stored_fp}\n\ + Current: {fingerprint}\n\ + This may indicate a man-in-the-middle attack or a server certificate rotation.\n\ + Use accept_cert_fingerprint to trust the new certificate." + )) + } + } +} + +/// Connect to a WSS server. Spawns a background task that: +/// - Emits `ws-message` events for incoming server messages +/// - Emits `ws-state` events for connection state changes +/// - Emits `cert-tofu` events for TOFU fingerprint status +/// - Reads from an mpsc channel for outgoing messages +#[tauri::command] +pub async fn ws_connect( + app: AppHandle, + state: tauri::State<'_, WsState>, + url: String, +) -> Result<(), String> { + // Drop any existing connection + { + let mut tx_lock = state.tx.lock().await; + *tx_lock = None; + } + + // Only allow secure WebSocket connections + if !url.starts_with("wss://") { + return Err("Only wss:// connections are permitted".into()); + } + + let _ = app.emit("ws-state", "connecting"); + + // Create TOFU verifier that captures the cert fingerprint during handshake. + let (verifier, captured_fp) = TofuVerifier::new(); + + let tls_config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + + let connector = + tokio_tungstenite::Connector::Rustls(Arc::new(tls_config)); + + let connect_future = tokio_tungstenite::connect_async_tls_with_config( + &url, + None, + false, + Some(connector), + ); + + let (ws_stream, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_future) + .await + .map_err(|_| format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs()))? + .map_err(|e| format!("ws connect failed: {e}"))?; + + // ── TOFU check ─────────────────────────────────────────────────────── + let host = extract_host(&url); + let fingerprint = captured_fp + .lock() + .map_err(|e| format!("failed to read captured fingerprint: {e}"))? + .clone() + .unwrap_or_default(); + + if fingerprint.is_empty() { + return Err("TLS handshake completed but no certificate fingerprint was captured".into()); + } + + match tofu_check(&app, &host, &fingerprint) { + Ok(status) => { + let _ = app.emit( + "cert-tofu", + serde_json::json!({ + "host": host, + "fingerprint": fingerprint, + "status": status, + }), + ); + } + Err(mismatch_msg) => { + let _ = app.emit( + "cert-tofu", + serde_json::json!({ + "host": host, + "fingerprint": fingerprint, + "status": "mismatch", + "message": mismatch_msg, + }), + ); + // Reject the connection — do not proceed. + return Err(mismatch_msg); + } + } + // ── End TOFU check ─────────────────────────────────────────────────── + + let _ = app.emit("ws-state", "open"); + + let (mut sink, mut stream) = ws_stream.split(); + + // Channel for JS → server messages (bounded for backpressure) + let (tx, mut rx) = mpsc::channel::(256); + { + let mut tx_lock = state.tx.lock().await; + *tx_lock = Some(tx); + } + + let app_read = app.clone(); + let app_state = app.clone(); + + // Task: forward server → JS + let mut read_task = tokio::spawn(async move { + while let Some(msg) = stream.next().await { + match msg { + Ok(Message::Text(text)) => { + let _ = app_read.emit("ws-message", text.to_string()); + } + Ok(Message::Close(_)) => break, + Err(e) => { + let _ = app_read.emit("ws-error", format!("{e}")); + break; + } + _ => {} // ignore binary/ping/pong + } + } + }); + + // Task: forward JS → server + let mut write_task = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if sink.send(Message::Text(msg.into())).await.is_err() { + break; + } + } + }); + + // When either task ends, abort sibling and emit closed + tokio::spawn(async move { + tokio::select! { + _ = &mut read_task => { write_task.abort(); } + _ = &mut write_task => { read_task.abort(); } + } + let _ = app_state.emit("ws-state", "closed"); + }); + + Ok(()) +} + +/// Send a text message through the proxy WebSocket. +#[tauri::command] +pub async fn ws_send( + state: tauri::State<'_, WsState>, + message: String, +) -> Result<(), String> { + let tx_lock = state.tx.lock().await; + if let Some(tx) = tx_lock.as_ref() { + tx.try_send(message).map_err(|e| format!("ws send failed: {e}")) + } else { + Err("WebSocket not connected".into()) + } +} + +/// Disconnect the proxy WebSocket. +#[tauri::command] +pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> { + let mut tx_lock = state.tx.lock().await; + *tx_lock = None; // dropping the sender closes the channel → write task ends + Ok(()) +} + +/// Accept a changed certificate fingerprint for a host. +/// Call this after the user acknowledges a cert-mismatch warning. +#[tauri::command] +pub fn accept_cert_fingerprint( + app: AppHandle, + host: String, + fingerprint: String, +) -> Result<(), String> { + if host.is_empty() || fingerprint.is_empty() { + return Err("host and fingerprint must not be empty".into()); + } + + // Validate SHA-256 colon-hex format: XX:XX:XX:... (32 pairs = 95 chars) + let valid = fingerprint.len() == 95 + && fingerprint.bytes().enumerate().all(|(i, b)| { + if (i + 1) % 3 == 0 { + b == b':' + } else { + b.is_ascii_hexdigit() + } + }); + if !valid { + return Err("fingerprint must be SHA-256 colon-hex format (e.g. aa:bb:cc:...)".into()); + } + + let store = app + .store(CERTS_STORE) + .map_err(|e| format!("failed to open certs store: {e}"))?; + + store.set(&host, Value::String(fingerprint)); + store + .save() + .map_err(|e| format!("failed to persist cert fingerprint: {e}"))?; + Ok(()) +} diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json new file mode 100644 index 00000000..c01d5012 --- /dev/null +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -0,0 +1,56 @@ +{ + "productName": "OwnCord", + "version": "0.1.0", + "identifier": "com.owncord.client", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:1420", + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build" + }, + "app": { + "windows": [ + { + "title": "OwnCord", + "width": 1280, + "height": 720, + "minWidth": 940, + "minHeight": 500, + "decorations": true, + "resizable": true, + "center": true + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: wss:; img-src 'self' https: data:; frame-src https://www.youtube.com https://youtube.com" + } + }, + "bundle": { + "active": true, + "targets": [ + "nsis" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/icon.ico" + ], + "windows": { + "nsis": { + "displayLanguageSelector": false, + "installerIcon": "icons/icon.ico" + } + } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxMkZGMTUzMDBBNkFCNDAKUldSQXE2WUFVL0V2Z2NjekFXaVI1elpQYVBmOUdKNmZrTzZwRC80RHlBMkQyYzZWYXdTK00xK0wK", + "endpoints": [], + "dangerousAcceptInvalidCerts": true, + "dangerousAcceptInvalidHostnames": true, + "windows": { + "installMode": "passive" + } + } + } +} diff --git a/Client/tauri-client/src/components/AdminActions.ts b/Client/tauri-client/src/components/AdminActions.ts new file mode 100644 index 00000000..4432b556 --- /dev/null +++ b/Client/tauri-client/src/components/AdminActions.ts @@ -0,0 +1,188 @@ +/** + * AdminActions — context menu helpers for admin operations on members and channels. + * Provides confirmation steps for destructive actions (kick, ban, delete). + */ + +import { createElement, appendChildren, setText } from "@lib/dom"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface MemberContextMenuOptions { + userId: number; + username: string; + currentRole: string; + availableRoles: readonly string[]; + onKick(): Promise; + onBan(): Promise; + onChangeRole(newRole: string): Promise; +} + +export interface ChannelContextMenuOptions { + channelId: number; + channelName: string; + onEdit(): void; + onDelete(): Promise; + onCreate(): void; +} + +interface ContextMenuResult { + readonly element: HTMLDivElement; + destroy(): void; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMenuItem( + label: string, + className: string, + onClick: () => void, + signal: AbortSignal, +): HTMLDivElement { + const item = createElement("div", { class: className }, label); + item.addEventListener("click", onClick, { signal }); + return item; +} + +function createSeparator(): HTMLDivElement { + return createElement("div", { class: "context-menu__separator" }); +} + +function withConfirmation( + item: HTMLDivElement, + confirmLabel: string, + onConfirm: () => void, + signal: AbortSignal, +): void { + let confirming = false; + const originalLabel = item.textContent ?? ""; + + item.addEventListener("click", (e) => { + e.stopPropagation(); + if (confirming) { + confirming = false; + setText(item, originalLabel); + onConfirm(); + } else { + confirming = true; + setText(item, confirmLabel); + } + }, { signal }); +} + +// --------------------------------------------------------------------------- +// Member Context Menu +// --------------------------------------------------------------------------- + +export function createMemberContextMenu( + options: MemberContextMenuOptions, +): ContextMenuResult { + const ac = new AbortController(); + const menu = createElement("div", { class: "context-menu" }); + + // Role submenu trigger + const roleItem = createElement("div", { + class: "context-menu__item", + }, "Change Role"); + + const roleSub = createElement("div", { class: "context-menu__submenu" }); + for (const role of options.availableRoles) { + const cls = role === options.currentRole + ? "context-menu__item context-menu__item--active" + : "context-menu__item"; + const roleOption = createMenuItem(role, cls, () => { + if (role !== options.currentRole) { + void options.onChangeRole(role); + } + }, ac.signal); + roleSub.appendChild(roleOption); + } + + roleItem.addEventListener("mouseenter", () => { + roleSub.style.display = ""; + }, { signal: ac.signal }); + roleItem.addEventListener("mouseleave", () => { + roleSub.style.display = "none"; + }, { signal: ac.signal }); + + roleSub.style.display = "none"; + appendChildren(roleItem, roleSub); + menu.appendChild(roleItem); + + menu.appendChild(createSeparator()); + + // Kick with confirmation + const kickItem = createElement("div", { + class: "context-menu__item context-menu__item--danger", + }, "Kick"); + withConfirmation(kickItem, "Are you sure?", () => { + void options.onKick(); + }, ac.signal); + menu.appendChild(kickItem); + + // Ban with confirmation + const banItem = createElement("div", { + class: "context-menu__item context-menu__item--danger", + }, "Ban"); + withConfirmation(banItem, "Are you sure?", () => { + void options.onBan(); + }, ac.signal); + menu.appendChild(banItem); + + function destroy(): void { + ac.abort(); + menu.remove(); + } + + return { element: menu, destroy }; +} + +// --------------------------------------------------------------------------- +// Channel Context Menu +// --------------------------------------------------------------------------- + +export function createChannelContextMenu( + options: ChannelContextMenuOptions, +): ContextMenuResult { + const ac = new AbortController(); + const menu = createElement("div", { class: "context-menu" }); + + // Edit Channel + const editItem = createMenuItem( + "Edit Channel", + "context-menu__item", + () => options.onEdit(), + ac.signal, + ); + menu.appendChild(editItem); + + // Create Channel + const createItem = createMenuItem( + "Create Channel", + "context-menu__item", + () => options.onCreate(), + ac.signal, + ); + menu.appendChild(createItem); + + menu.appendChild(createSeparator()); + + // Delete Channel with confirmation + const deleteItem = createElement("div", { + class: "context-menu__item context-menu__item--danger", + }, "Delete Channel"); + withConfirmation(deleteItem, "Are you sure?", () => { + void options.onDelete(); + }, ac.signal); + menu.appendChild(deleteItem); + + function destroy(): void { + ac.abort(); + menu.remove(); + } + + return { element: menu, destroy }; +} diff --git a/Client/tauri-client/src/components/CertMismatchModal.ts b/Client/tauri-client/src/components/CertMismatchModal.ts new file mode 100644 index 00000000..216bff0d --- /dev/null +++ b/Client/tauri-client/src/components/CertMismatchModal.ts @@ -0,0 +1,124 @@ +/** + * CertMismatchModal — shows a warning when the server TLS certificate + * fingerprint has changed (TOFU mismatch). Gives the user the choice + * to accept the new certificate or disconnect. + * + * Uses the existing .modal-overlay / .cert-* CSS classes from login.css. + */ + +import { createElement, setText, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export interface CertMismatchModalOptions { + readonly host: string; + readonly storedFingerprint: string; + readonly newFingerprint: string; + readonly onAccept: () => void; + readonly onReject: () => void; +} + +export function createCertMismatchModal( + options: CertMismatchModalOptions, +): MountableComponent { + const { host, storedFingerprint, newFingerprint, onAccept, onReject } = options; + let overlay: HTMLDivElement | null = null; + const ac = new AbortController(); + + function mount(container: Element): void { + overlay = createElement("div", { class: "modal-overlay visible" }); + + const modal = createElement("div", { class: "modal" }); + + // Header + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Certificate Warning"); + const closeBtn = createElement("button", { class: "modal-close", type: "button" }); + setText(closeBtn, "\u2715"); + closeBtn.addEventListener("click", onReject, { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + // Body + const body = createElement("div", { class: "modal-body" }); + + const warning = createElement("div", { class: "cert-warning" }); + warning.innerHTML = ''; + + const certTitle = createElement("div", { class: "cert-title" }); + setText(certTitle, "Certificate Changed"); + + const desc = createElement("div", { class: "cert-desc" }); + setText( + desc, + "The server's TLS certificate fingerprint has changed. " + + "This could mean the server regenerated its certificate, " + + "or it could indicate a security issue.", + ); + + const details = createElement("div", { class: "cert-details" }); + + const hostRow = buildRow("Host", host, false); + const storedRow = buildRow("Previous", storedFingerprint, true); + const newRow = buildRow("Current", newFingerprint, true); + appendChildren(details, hostRow, storedRow, newRow); + + appendChildren(body, warning, certTitle, desc, details); + + // Footer + const footer = createElement("div", { class: "modal-footer" }); + + const rejectBtn = createElement("button", { + class: "btn-ghost", + type: "button", + }); + setText(rejectBtn, "Disconnect"); + rejectBtn.addEventListener("click", onReject, { signal: ac.signal }); + + const acceptBtn = createElement("button", { + class: "btn-danger", + type: "button", + }); + setText(acceptBtn, "Accept New Certificate"); + acceptBtn.addEventListener("click", onAccept, { signal: ac.signal }); + + appendChildren(footer, rejectBtn, acceptBtn); + + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + // Close on backdrop click + overlay.addEventListener( + "click", + (e) => { + if (e.target === overlay) onReject(); + }, + { signal: ac.signal }, + ); + + container.appendChild(overlay); + } + + function destroy(): void { + ac.abort(); + if (overlay !== null) { + overlay.remove(); + overlay = null; + } + } + + return { mount, destroy }; +} + +function buildRow( + label: string, + value: string, + isFingerprint: boolean, +): HTMLDivElement { + const row = createElement("div", { class: "cert-row" }); + const labelEl = createElement("span", { class: "cert-label" }); + setText(labelEl, label); + const valueClass = isFingerprint ? "cert-value cert-fingerprint" : "cert-value"; + const valueEl = createElement("span", { class: valueClass }); + setText(valueEl, value || "Unknown"); + appendChildren(row, labelEl, valueEl); + return row; +} diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts new file mode 100644 index 00000000..2f3bd758 --- /dev/null +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -0,0 +1,748 @@ +/** + * ChannelSidebar component — channel list sidebar with categories, + * unread indicators, and collapse/expand behavior. + * Voice channels show connected users and join/leave on click. + */ + +import { + createElement, + setText, + clearChildren, + appendChildren, +} from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import { + channelsStore, + getChannelsByCategory, + setActiveChannel, + clearUnread, + updateChannelPosition, +} from "@stores/channels.store"; +import type { Channel } from "@stores/channels.store"; +import { authStore, getCurrentUser } from "@stores/auth.store"; +import { + uiStore, + toggleCategory, + isCategoryCollapsed, +} from "@stores/ui.store"; +import { voiceStore, getChannelVoiceUsers } from "@stores/voice.store"; +import { setUserVolume, getUserVolume } from "@lib/voiceSession"; + +// --------------------------------------------------------------------------- +// Per-user volume context menu (right-click on voice user row) +// --------------------------------------------------------------------------- + +function showUserVolumeMenu( + userId: number, + username: string, + x: number, + y: number, + signal: AbortSignal, +): void { + // Remove any existing context menus + document.querySelectorAll(".user-vol-menu").forEach((el) => el.remove()); + + const menu = createElement("div", { class: "context-menu user-vol-menu" }); + + const header = createElement("div", { + class: "context-menu-item", + style: "font-weight:600;cursor:default;pointer-events:none", + }, username); + menu.appendChild(header); + + const sep = createElement("div", { class: "context-menu-sep" }); + menu.appendChild(sep); + + const currentVol = getUserVolume(userId); + const volLabel = createElement("div", { + class: "context-menu-item", + style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none", + }, `User Volume: ${currentVol}%`); + menu.appendChild(volLabel); + + const sliderRow = createElement("div", { + style: "padding:4px 10px;display:flex;align-items:center;gap:8px", + }); + const slider = createElement("input", { + type: "range", + class: "settings-slider", + min: "0", + max: "200", + value: String(currentVol), + style: "flex:1", + }); + const valLabel = createElement("span", { + class: "slider-val", + style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)", + }, `${currentVol}%`); + + slider.addEventListener("input", () => { + const val = Number(slider.value); + setText(valLabel, `${val}%`); + setText(volLabel, `User Volume: ${val}%`); + setUserVolume(userId, val); + }); + + appendChildren(sliderRow, slider, valLabel); + menu.appendChild(sliderRow); + + const resetBtn = createElement("div", { class: "context-menu-item" }, "Reset Volume"); + resetBtn.addEventListener("click", () => { + setUserVolume(userId, 100); + slider.value = "100"; + setText(valLabel, "100%"); + setText(volLabel, "User Volume: 100%"); + }); + menu.appendChild(resetBtn); + + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + document.body.appendChild(menu); + + // Close on click outside + const dismissAc = new AbortController(); + const combinedSignal = signal; + setTimeout(() => { + document.addEventListener("mousedown", (e: MouseEvent) => { + if (!menu.contains(e.target as Node)) { + menu.remove(); + dismissAc.abort(); + } + }, { signal: dismissAc.signal }); + }, 0); + + // Also clean up if the parent component is destroyed + combinedSignal.addEventListener("abort", () => { + menu.remove(); + dismissAc.abort(); + }); +} + +export interface ChannelReorderData { + readonly channelId: number; + readonly newPosition: number; +} + +export interface ChannelSidebarOptions { + readonly onVoiceJoin: (channelId: number) => void; + readonly onVoiceLeave: () => void; + /** Called when the user clicks the "+" on a category header. */ + readonly onCreateChannel?: (category: string) => void; + /** Called when the user right-clicks a channel and selects Edit. */ + readonly onEditChannel?: (channel: Channel) => void; + /** Called when the user right-clicks a channel and selects Delete. */ + readonly onDeleteChannel?: (channel: Channel) => void; + /** Called when the user drags a channel to a new position. */ + readonly onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void; +} + +// ── Drag state (mouse-based, avoids WebView2 HTML5 DnD issues) ── +interface DragState { + channelId: number; + sourceEl: HTMLElement; + containerEl: HTMLElement; + channels: readonly Channel[]; + onReorder: (reorders: readonly ChannelReorderData[]) => void; +} +let activeDrag: DragState | null = null; + +const AVATAR_COLORS = ["#5865f2", "#57f287", "#fee75c", "#eb459e", "#ed4245"]; + +function pickAvatarColor(username: string): string { + let hash = 0; + for (let i = 0; i < username.length; i++) { + hash = (hash * 31 + username.charCodeAt(i)) | 0; + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] ?? "#5865f2"; +} + +function renderTextChannelItem( + channel: Channel, + isActive: boolean, + signal: AbortSignal, +): HTMLDivElement { + const classes = [ + "channel-item", + isActive ? "active" : "", + channel.unreadCount > 0 ? "unread" : "", + ] + .filter(Boolean) + .join(" "); + + const item = createElement("div", { class: classes, "data-testid": `channel-${channel.id}` }); + item.dataset.channelId = String(channel.id); + + const prefix = createElement("span", { class: "ch-icon" }, "#"); + const name = createElement("span", { class: "ch-name" }, channel.name); + + appendChildren(item, prefix, name); + + if (channel.unreadCount > 0) { + const badge = createElement( + "span", + { class: "unread-badge" }, + String(channel.unreadCount), + ); + item.appendChild(badge); + } + + item.addEventListener( + "click", + () => { + setActiveChannel(channel.id); + clearUnread(channel.id); + }, + { signal }, + ); + + return item; +} + +function renderVoiceChannelItem( + channel: Channel, + signal: AbortSignal, + onVoiceJoin: (channelId: number) => void, + onVoiceLeave: () => void, +): HTMLDivElement { + const voiceState = voiceStore.getState(); + const isJoined = voiceState.currentChannelId === channel.id; + + const wrapper = createElement("div", {}); + + const classes = ["channel-item", "voice", isJoined ? "active" : ""] + .filter(Boolean) + .join(" "); + + const item = createElement("div", { class: classes, "data-testid": `channel-${channel.id}` }); + item.dataset.channelId = String(channel.id); + + const prefix = createElement("span", { class: "ch-icon" }, "\uD83D\uDD0A"); + const name = createElement("span", { class: "ch-name" }, channel.name); + + appendChildren(item, prefix, name); + + item.addEventListener( + "click", + () => { + if (isJoined) { + onVoiceLeave(); + } else { + onVoiceJoin(channel.id); + } + }, + { signal }, + ); + + wrapper.appendChild(item); + + // Render connected voice users below the channel + const voiceUsers = getChannelVoiceUsers(channel.id); + if (voiceUsers.length > 0) { + const usersContainer = createElement("div", { class: "voice-users-list" }); + for (const user of voiceUsers) { + const rowClasses = user.speaking + ? "voice-user-item speaking" + : "voice-user-item"; + const row = createElement("div", { class: rowClasses }); + + const initial = user.username.length > 0 + ? user.username.charAt(0).toUpperCase() + : "?"; + const avatar = createElement("div", { class: "vu-avatar" }, initial); + avatar.style.background = pickAvatarColor(user.username); + row.appendChild(avatar); + + const nameEl = createElement( + "span", + { class: "vu-name" }, + user.username || "Unknown", + ); + row.appendChild(nameEl); + + if (user.deafened) { + // Deafened: show both crossed mic and crossed headphone + const muteIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA4"); + const deafIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA7"); + row.appendChild(muteIcon); + row.appendChild(deafIcon); + } else if (user.muted) { + // Muted only: show crossed mic + const muteIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA4"); + row.appendChild(muteIcon); + } + + // Right-click for per-user volume (skip for own user) + const currentUser = getCurrentUser(); + if (currentUser === null || currentUser.id !== user.userId) { + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + showUserVolumeMenu(user.userId, user.username || "Unknown", e.clientX, e.clientY, signal); + }, { signal }); + } + + usersContainer.appendChild(row); + } + wrapper.appendChild(usersContainer); + } + + return wrapper; +} + +/** Attach a right-click context menu to a channel element for edit/delete. */ +function attachChannelContextMenu( + el: HTMLElement, + channel: Channel, + signal: AbortSignal, + onEdit?: (channel: Channel) => void, + onDelete?: (channel: Channel) => void, +): void { + if (onEdit === undefined && onDelete === undefined) { + return; + } + const user = getCurrentUser(); + const role = user?.role?.toLowerCase() ?? ""; + if (role !== "owner" && role !== "admin") { + return; + } + + el.addEventListener( + "contextmenu", + (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Remove any existing context menu + document.querySelector(".channel-ctx-menu")?.remove(); + + const menu = createElement("div", { + class: "context-menu channel-ctx-menu", + "data-testid": "channel-context-menu", + }); + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + + if (onEdit !== undefined) { + const editItem = createElement( + "div", + { class: "context-menu-item", "data-testid": "ctx-edit-channel" }, + "Edit Channel", + ); + editItem.addEventListener( + "click", + () => { + menu.remove(); + onEdit(channel); + }, + { signal }, + ); + menu.appendChild(editItem); + } + + if (onDelete !== undefined) { + if (onEdit !== undefined) { + menu.appendChild(createElement("div", { class: "context-menu-sep" })); + } + const deleteItem = createElement( + "div", + { class: "context-menu-item danger", "data-testid": "ctx-delete-channel" }, + "Delete Channel", + ); + deleteItem.addEventListener( + "click", + () => { + menu.remove(); + onDelete(channel); + }, + { signal }, + ); + menu.appendChild(deleteItem); + } + + document.body.appendChild(menu); + + // Close menu on click elsewhere + const closeMenu = (): void => { + menu.remove(); + document.removeEventListener("click", closeMenu); + }; + // Defer so this click event doesn't immediately close it + setTimeout(() => { + document.addEventListener("click", closeMenu, { signal }); + }, 0); + }, + { signal }, + ); +} + +/** Global mousemove/mouseup handlers for drag reordering. Registered once. */ +let globalDragListenersAttached = false; + +function ensureGlobalDragListeners(): void { + if (globalDragListenersAttached) { + return; + } + globalDragListenersAttached = true; + + document.addEventListener("mousemove", (e) => { + if (activeDrag === null) { + return; + } + // Clear old indicators + activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => { + x.classList.remove("channel-drop-indicator"); + }); + + // Find which channel item we're hovering over + const items = activeDrag.containerEl.querySelectorAll("[data-drag-channel-id]"); + for (const item of items) { + const rect = item.getBoundingClientRect(); + if (e.clientY >= rect.top && e.clientY <= rect.bottom) { + const targetId = Number((item as HTMLElement).dataset.dragChannelId); + if (targetId !== activeDrag.channelId) { + item.classList.add("channel-drop-indicator"); + } + break; + } + } + }); + + document.addEventListener("mouseup", (e) => { + if (activeDrag === null) { + return; + } + const drag = activeDrag; + activeDrag = null; + + // Clean up visual state + drag.sourceEl.classList.remove("dragging"); + document.body.classList.remove("channel-reordering"); + drag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => { + x.classList.remove("channel-drop-indicator"); + }); + + // Find drop target + const items = drag.containerEl.querySelectorAll("[data-drag-channel-id]"); + let dropTargetId: number | null = null; + let dropBefore = false; + for (const item of items) { + const rect = item.getBoundingClientRect(); + if (e.clientY >= rect.top && e.clientY <= rect.bottom) { + dropTargetId = Number((item as HTMLElement).dataset.dragChannelId); + dropBefore = e.clientY < rect.top + rect.height / 2; + break; + } + } + + if (dropTargetId === null || dropTargetId === drag.channelId) { + return; + } + + // Compute new order + const orderedIds = drag.channels.map((ch) => ch.id); + const dragIdx = orderedIds.indexOf(drag.channelId); + if (dragIdx === -1) { + return; + } + orderedIds.splice(dragIdx, 1); + + const targetIdx = orderedIds.indexOf(dropTargetId); + if (targetIdx === -1) { + return; + } + const insertIdx = dropBefore ? targetIdx : targetIdx + 1; + orderedIds.splice(insertIdx, 0, drag.channelId); + + // Build reorder data and update store immediately + const reorders: ChannelReorderData[] = []; + for (let i = 0; i < orderedIds.length; i++) { + const id = orderedIds[i]; + if (id === undefined) { + continue; + } + const ch = drag.channels.find((c) => c.id === id); + if (ch !== undefined && ch.position !== i) { + reorders.push({ channelId: id, newPosition: i }); + updateChannelPosition(id, i); + } + } + + if (reorders.length > 0) { + drag.onReorder(reorders); + } + }); +} + +/** Make a channel element draggable via mousedown (admin/owner only). */ +function attachDragHandlers( + el: HTMLElement, + channel: Channel, + containerEl: HTMLElement, + channels: readonly Channel[], + signal: AbortSignal, + onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void, +): void { + if (onReorderChannel === undefined) { + return; + } + const user = getCurrentUser(); + const role = user?.role?.toLowerCase() ?? ""; + if (role !== "owner" && role !== "admin") { + return; + } + + ensureGlobalDragListeners(); + + el.classList.add("channel-draggable"); + el.dataset.dragChannelId = String(channel.id); + + let pendingDrag: { startX: number; startY: number } | null = null; + + el.addEventListener( + "mousedown", + (e) => { + if (e.button !== 0) { + return; + } + // Start tracking — only activate drag after movement threshold + pendingDrag = { startX: e.clientX, startY: e.clientY }; + }, + { signal }, + ); + + el.addEventListener( + "mousemove", + (e) => { + if (pendingDrag === null || activeDrag !== null) { + return; + } + const dx = Math.abs(e.clientX - pendingDrag.startX); + const dy = Math.abs(e.clientY - pendingDrag.startY); + // Require 5px movement to start drag (avoids hijacking clicks) + if (dx + dy < 5) { + return; + } + pendingDrag = null; + activeDrag = { + channelId: channel.id, + sourceEl: el, + containerEl, + channels, + onReorder: onReorderChannel, + }; + el.classList.add("dragging"); + document.body.classList.add("channel-reordering"); + }, + { signal }, + ); + + el.addEventListener( + "mouseup", + () => { + pendingDrag = null; + }, + { signal }, + ); +} + +function renderChannelItem( + channel: Channel, + isActive: boolean, + signal: AbortSignal, + onVoiceJoin: (channelId: number) => void, + onVoiceLeave: () => void, + onEditChannel?: (channel: Channel) => void, + onDeleteChannel?: (channel: Channel) => void, + containerEl?: HTMLElement, + channels?: readonly Channel[], + onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void, +): HTMLDivElement { + let el: HTMLDivElement; + if (channel.type === "voice") { + el = renderVoiceChannelItem(channel, signal, onVoiceJoin, onVoiceLeave); + } else { + el = renderTextChannelItem(channel, isActive, signal); + } + attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel); + if (containerEl !== undefined && channels !== undefined) { + attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel); + } + return el; +} + +function renderCategoryGroup( + categoryName: string | null, + channels: readonly Channel[], + activeChannelId: number | null, + signal: AbortSignal, + onVoiceJoin: (channelId: number) => void, + onVoiceLeave: () => void, + onCreateChannel?: (category: string) => void, + onEditChannel?: (channel: Channel) => void, + onDeleteChannel?: (channel: Channel) => void, + onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void, +): HTMLDivElement { + const group = createElement("div", {}); + + if (categoryName !== null) { + const collapsed = isCategoryCollapsed(categoryName); + const header = createElement("div", { + class: collapsed ? "category collapsed" : "category", + }); + header.dataset.category = categoryName; + + const arrow = createElement( + "span", + { class: "category-arrow" }, + collapsed ? "\u25B6" : "\u25BC", + ); + const label = createElement("span", { class: "category-name" }, categoryName); + + appendChildren(header, arrow, label); + + if (onCreateChannel !== undefined) { + const user = getCurrentUser(); + const role = user?.role?.toLowerCase() ?? ""; + const canManageChannels = role === "owner" || role === "admin"; + + if (canManageChannels) { + const addBtn = createElement("span", { + class: "category-add-btn", + title: "Create Channel", + "data-testid": `create-channel-${categoryName.toLowerCase().replace(/\s+/g, "-")}`, + }, "+"); + addBtn.addEventListener( + "click", + (e) => { + e.stopPropagation(); + onCreateChannel(categoryName); + }, + { signal }, + ); + header.appendChild(addBtn); + } + } + + header.addEventListener( + "click", + () => { + toggleCategory(categoryName); + }, + { signal }, + ); + + group.appendChild(header); + + if (!collapsed) { + const channelsContainer = createElement("div", { class: "category-channels-container" }); + for (const ch of channels) { + channelsContainer.appendChild( + renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave, onEditChannel, onDeleteChannel, channelsContainer, channels, onReorderChannel), + ); + } + group.appendChild(channelsContainer); + } + } else { + // Uncategorized channels render directly + const channelsContainer = createElement("div", { class: "category-channels-container" }); + for (const ch of channels) { + channelsContainer.appendChild( + renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave, onEditChannel, onDeleteChannel, channelsContainer, channels, onReorderChannel), + ); + } + group.appendChild(channelsContainer); + } + + return group; +} + +export function createChannelSidebar(options: ChannelSidebarOptions): MountableComponent { + const { onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel } = options; + const ac = new AbortController(); + let root: HTMLDivElement | null = null; + let channelList: HTMLDivElement | null = null; + let serverNameEl: HTMLSpanElement | null = null; + + const unsubscribers: Array<() => void> = []; + + function renderChannels(): void { + if (channelList === null) { + return; + } + clearChildren(channelList); + + const grouped = getChannelsByCategory(); + const state = channelsStore.getState(); + + for (const [category, channels] of grouped) { + channelList.appendChild( + renderCategoryGroup(category, channels, state.activeChannelId, ac.signal, onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel), + ); + } + } + + function mount(container: Element): void { + root = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" }); + + // Header + const header = createElement("div", { class: "channel-sidebar-header" }); + const authState = authStore.getState(); + serverNameEl = createElement( + "h2", + {}, + authState.serverName ?? "Server Name", + ); + header.appendChild(serverNameEl); + + // Channel list + channelList = createElement("div", { class: "channel-list" }); + + appendChildren(root, header, channelList); + container.appendChild(root); + + // Initial render + renderChannels(); + + // Subscribe to channels store changes + const unsubChannels = channelsStore.subscribe(() => { + renderChannels(); + }); + unsubscribers.push(unsubChannels); + + // Subscribe to auth store for server name updates + const unsubAuth = authStore.subscribe((state) => { + if (serverNameEl !== null) { + setText(serverNameEl, state.serverName ?? "Server Name"); + } + }); + unsubscribers.push(unsubAuth); + + // Subscribe to UI store for category collapse changes + const unsubUi = uiStore.subscribe(() => { + renderChannels(); + }); + unsubscribers.push(unsubUi); + + // Subscribe to voice store for connected user updates + const unsubVoice = voiceStore.subscribe(() => { + renderChannels(); + }); + unsubscribers.push(unsubVoice); + } + + function destroy(): void { + ac.abort(); + for (const unsub of unsubscribers) { + unsub(); + } + unsubscribers.length = 0; + if (root !== null) { + root.remove(); + root = null; + } + channelList = null; + serverNameEl = null; + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/ConnectedOverlay.ts b/Client/tauri-client/src/components/ConnectedOverlay.ts new file mode 100644 index 00000000..08c661b5 --- /dev/null +++ b/Client/tauri-client/src/components/ConnectedOverlay.ts @@ -0,0 +1,118 @@ +/** + * ConnectedOverlay — full-screen overlay shown after auth_ok, + * displays server info while waiting for the ready event. + * Matches login-mockup.html connected overlay structure. + */ + +import { createElement, setText, appendChildren } from "@lib/dom"; + +export interface ConnectedOverlayOptions { + readonly serverName: string; + readonly username: string; + readonly motd: string; + readonly onReady: () => void; +} + +export interface ConnectedOverlayControl { + readonly element: HTMLDivElement; + /** Call when ready payload is received. */ + markReady(): void; + /** Show the overlay (adds .visible class). */ + show(): void; + destroy(): void; +} + +const READY_DELAY_MS = 800; + +function serverIconColor(name: string): string { + const palette = [ + "#5865f2", "#57f287", "#fee75c", "#eb459e", + "#ed4245", "#f0b232", "#2ecc71", "#e74c3c", + ] as const; + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash * 31 + name.charCodeAt(i)) | 0; + } + return palette[Math.abs(hash) % palette.length] ?? palette[0]; +} + +export function createConnectedOverlay( + options: ConnectedOverlayOptions, +): ConnectedOverlayControl { + const { serverName, username, motd, onReady } = options; + const ac = new AbortController(); + + // Root overlay (hidden by default, .visible to show) + const overlay = createElement("div", { class: "connected-overlay", "data-testid": "connected-overlay" }); + + // Server icon with check badge + const iconWrap = createElement("div", { class: "connected-icon-wrap" }); + const srvIcon = createElement("div", { + class: "connected-srv-icon", + style: `background:${serverIconColor(serverName)}`, + }); + setText(srvIcon, serverName.charAt(0).toUpperCase()); + + // SVG checkmark badge (matches mockup) + const checkBadge = createElement("div", { class: "connected-check-badge" }); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "3"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); + polyline.setAttribute("points", "20 6 9 17 4 12"); + svg.appendChild(polyline); + checkBadge.appendChild(svg); + appendChildren(iconWrap, srvIcon, checkBadge); + + // Text elements + const connectedText = createElement("div", { + class: "connected-text", + }, "Connected!"); + + const userText = createElement("div", { + class: "connected-user", + }, `Logged in as ${username}`); + + const motdEl = createElement("div", { class: "connected-motd" }); + if (motd) { + setText(motdEl, motd); + } + + // Loader with spinner + const loader = createElement("div", { class: "connected-loader" }); + const spinner = createElement("div", { class: "spinner" }); + const loaderText = createElement("span", {}, "Loading server data..."); + appendChildren(loader, spinner, loaderText); + + appendChildren(overlay, iconWrap, connectedText, userText, motdEl, loader); + + function show(): void { + overlay.classList.add("visible"); + } + + function markReady(): void { + if (ac.signal.aborted) return; + + spinner.style.display = "none"; + setText(loaderText, "\u2714 Ready!"); + + const timer = setTimeout(() => { + if (!ac.signal.aborted) { + onReady(); + } + }, READY_DELAY_MS); + + ac.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); + } + + function destroy(): void { + ac.abort(); + overlay.remove(); + } + + return { element: overlay, markReady, show, destroy }; +} diff --git a/Client/tauri-client/src/components/CreateChannelModal.ts b/Client/tauri-client/src/components/CreateChannelModal.ts new file mode 100644 index 00000000..6a13ecb4 --- /dev/null +++ b/Client/tauri-client/src/components/CreateChannelModal.ts @@ -0,0 +1,208 @@ +/** + * CreateChannelModal — modal for creating a new channel under a specific + * category. The channel type is automatically restricted based on the + * category: voice categories only allow voice channels, text categories + * allow text and announcement channels. + */ + +import { createElement, setText, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import type { ChannelType } from "@lib/types"; + +export interface CreateChannelModalOptions { + /** The category this channel will be created under. */ + readonly category: string; + /** Called when the user submits the form. */ + readonly onCreate: (data: { + name: string; + type: ChannelType; + category: string; + }) => Promise; + /** Called when the modal is closed without creating. */ + readonly onClose: () => void; +} + +/** Returns true if the category name indicates a voice section. */ +export function isVoiceCategory(category: string): boolean { + return category.toLowerCase().includes("voice"); +} + +/** Returns the allowed channel types for a given category. */ +export function allowedTypesForCategory( + category: string, +): readonly ChannelType[] { + if (isVoiceCategory(category)) { + return ["voice"] as const; + } + return ["text", "announcement"] as const; +} + +export function createCreateChannelModal( + options: CreateChannelModalOptions, +): MountableComponent { + const { category, onCreate, onClose } = options; + const ac = new AbortController(); + let overlay: HTMLDivElement | null = null; + + const allowedTypes = allowedTypesForCategory(category); + + function mount(container: Element): void { + overlay = createElement("div", { + class: "modal-overlay visible", + "data-testid": "create-channel-modal", + }); + + const modal = createElement("div", { class: "modal" }); + + // Header + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Create Channel"); + const closeBtn = createElement("button", { + class: "modal-close", + type: "button", + }); + setText(closeBtn, "\u2715"); + closeBtn.addEventListener("click", onClose, { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + // Body + const body = createElement("div", { class: "modal-body" }); + + // Category (read-only display) + const categoryGroup = createElement("div", { class: "form-group" }); + const categoryLabel = createElement( + "label", + { class: "form-label" }, + "Category", + ); + const categoryDisplay = createElement("div", { + class: "form-input", + style: "opacity: 0.7; cursor: default;", + }); + setText(categoryDisplay, category); + appendChildren(categoryGroup, categoryLabel, categoryDisplay); + + // Channel name + const nameGroup = createElement("div", { class: "form-group" }); + const nameLabel = createElement("label", { class: "form-label" }, "Name"); + const nameInput = createElement("input", { + class: "form-input", + type: "text", + placeholder: isVoiceCategory(category) ? "lounge" : "general", + "data-testid": "channel-name-input", + }) as HTMLInputElement; + appendChildren(nameGroup, nameLabel, nameInput); + + // Channel type + const typeGroup = createElement("div", { class: "form-group" }); + const typeLabel = createElement("label", { class: "form-label" }, "Type"); + const typeSelect = createElement("select", { + class: "form-input", + "data-testid": "channel-type-select", + }) as HTMLSelectElement; + + for (const t of allowedTypes) { + const opt = createElement( + "option", + { value: t }, + t.charAt(0).toUpperCase() + t.slice(1), + ); + typeSelect.appendChild(opt); + } + appendChildren(typeGroup, typeLabel, typeSelect); + + // Error display + const errorEl = createElement("div", { + class: "form-group", + style: "color: var(--red); font-size: 13px; display: none;", + "data-testid": "channel-create-error", + }); + + appendChildren(body, categoryGroup, nameGroup, typeGroup, errorEl); + + // Footer + const footer = createElement("div", { class: "modal-footer" }); + const cancelBtn = createElement( + "button", + { class: "btn-modal-cancel", type: "button" }, + "Cancel", + ); + cancelBtn.addEventListener("click", onClose, { signal: ac.signal }); + + const createBtn = createElement( + "button", + { + class: "btn-modal-save", + type: "button", + "data-testid": "channel-create-submit", + }, + "Create Channel", + ); + + createBtn.addEventListener( + "click", + async () => { + const name = nameInput.value.trim(); + if (name === "") { + errorEl.style.display = "block"; + setText(errorEl, "Channel name is required"); + nameInput.classList.add("error"); + return; + } + + // Clear previous errors + errorEl.style.display = "none"; + nameInput.classList.remove("error"); + createBtn.setAttribute("disabled", "true"); + setText(createBtn, "Creating..."); + + try { + await onCreate({ + name, + type: typeSelect.value as ChannelType, + category, + }); + } catch (err) { + errorEl.style.display = "block"; + setText( + errorEl, + err instanceof Error ? err.message : "Failed to create channel", + ); + createBtn.removeAttribute("disabled"); + setText(createBtn, "Create Channel"); + } + }, + { signal: ac.signal }, + ); + + appendChildren(footer, cancelBtn, createBtn); + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + // Close on backdrop click + overlay.addEventListener( + "click", + (e) => { + if (e.target === overlay) { + onClose(); + } + }, + { signal: ac.signal }, + ); + + container.appendChild(overlay); + + // Focus the name input + nameInput.focus(); + } + + function destroy(): void { + ac.abort(); + if (overlay !== null) { + overlay.remove(); + overlay = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/DeleteChannelModal.ts b/Client/tauri-client/src/components/DeleteChannelModal.ts new file mode 100644 index 00000000..3469a0ac --- /dev/null +++ b/Client/tauri-client/src/components/DeleteChannelModal.ts @@ -0,0 +1,122 @@ +/** + * DeleteChannelModal — confirmation dialog for deleting a channel. + * Shows channel name and requires explicit confirmation. + */ + +import { createElement, setText, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export interface DeleteChannelModalOptions { + readonly channelId: number; + readonly channelName: string; + readonly onConfirm: () => Promise; + readonly onClose: () => void; +} + +export function createDeleteChannelModal( + options: DeleteChannelModalOptions, +): MountableComponent { + const { channelName, onConfirm, onClose } = options; + const ac = new AbortController(); + let overlay: HTMLDivElement | null = null; + + function mount(container: Element): void { + overlay = createElement("div", { + class: "modal-overlay visible", + "data-testid": "delete-channel-modal", + }); + + const modal = createElement("div", { class: "modal" }); + + // Header + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Delete Channel"); + const closeBtn = createElement("button", { + class: "modal-close", + type: "button", + }); + setText(closeBtn, "\u2715"); + closeBtn.addEventListener("click", onClose, { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + // Body + const body = createElement("div", { class: "modal-body" }); + const warning = createElement("div", { class: "modal-danger-text" }); + warning.innerHTML = `Are you sure you want to delete #${channelName}? This action cannot be undone and all messages in this channel will be lost.`; + body.appendChild(warning); + + // Error display + const errorEl = createElement("div", { + style: "color: var(--red); font-size: 13px; display: none; margin-top: 8px;", + "data-testid": "delete-channel-error", + }); + body.appendChild(errorEl); + + // Footer + const footer = createElement("div", { class: "modal-footer" }); + const cancelBtn = createElement( + "button", + { class: "btn-modal-cancel", type: "button" }, + "Cancel", + ); + cancelBtn.addEventListener("click", onClose, { signal: ac.signal }); + + const deleteBtn = createElement( + "button", + { + class: "btn-danger", + type: "button", + "data-testid": "delete-channel-confirm", + }, + "Delete Channel", + ); + + deleteBtn.addEventListener( + "click", + async () => { + deleteBtn.setAttribute("disabled", "true"); + setText(deleteBtn, "Deleting..."); + + try { + await onConfirm(); + } catch (err) { + errorEl.style.display = "block"; + setText( + errorEl, + err instanceof Error ? err.message : "Failed to delete channel", + ); + deleteBtn.removeAttribute("disabled"); + setText(deleteBtn, "Delete Channel"); + } + }, + { signal: ac.signal }, + ); + + appendChildren(footer, cancelBtn, deleteBtn); + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + // Close on backdrop click + overlay.addEventListener( + "click", + (e) => { + if (e.target === overlay) { + onClose(); + } + }, + { signal: ac.signal }, + ); + + container.appendChild(overlay); + } + + function destroy(): void { + ac.abort(); + if (overlay !== null) { + overlay.remove(); + overlay = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/DmSidebar.ts b/Client/tauri-client/src/components/DmSidebar.ts new file mode 100644 index 00000000..a318c923 --- /dev/null +++ b/Client/tauri-client/src/components/DmSidebar.ts @@ -0,0 +1,190 @@ +/** + * DmSidebar component — direct messages sidebar showing conversations + * sorted by most recent, with unread indicators. + * + * Uses the `channel-sidebar` container class (shared with channel sidebar) + * and DM-specific classes from app.css: dm-sidebar-header, dm-search, + * dm-nav-item, dm-section-label, dm-add, dm-item, dm-avatar, dm-status, + * dm-name, dm-close, dm-unread. + */ + +import { + createElement, + setText, + clearChildren, + appendChildren, +} from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export interface DmConversation { + readonly userId: number; + readonly username: string; + readonly avatar: string | null; + readonly avatarColor?: string; + readonly status?: "online" | "idle" | "dnd" | "offline"; + readonly lastMessage: string; + readonly timestamp: string; + readonly unread: boolean; + readonly active?: boolean; +} + +export interface DmSidebarOptions { + readonly conversations: readonly DmConversation[]; + readonly onSelectConversation: (userId: number) => void; + readonly onNewDm: () => void; + readonly onCloseDm?: (userId: number) => void; + readonly onFriendsClick?: () => void; + readonly friendsActive?: boolean; +} + +const STATUS_COLORS: Record = { + online: "var(--green)", + idle: "var(--yellow)", + dnd: "var(--red)", + offline: "var(--text-micro)", +}; + +function renderDmItem( + convo: DmConversation, + onSelect: (userId: number) => void, + onClose: ((userId: number) => void) | undefined, + signal: AbortSignal, +): HTMLDivElement { + const item = createElement("div", { class: "dm-item" }); + if (convo.active === true) { + item.classList.add("active"); + } + item.dataset.userId = String(convo.userId); + + // Avatar with status dot + const avatarBg = convo.avatarColor ?? "#5865F2"; + const avatar = createElement("div", { class: "dm-avatar" }); + avatar.style.background = avatarBg; + + if (convo.avatar !== null) { + const img = createElement("img", { + src: convo.avatar, + alt: convo.username, + }); + img.style.width = "100%"; + img.style.height = "100%"; + img.style.borderRadius = "50%"; + avatar.appendChild(img); + } else { + setText(avatar, convo.username.charAt(0).toUpperCase()); + } + + // Status indicator dot + const statusKey = convo.status ?? "offline"; + const statusDot = createElement("span", { class: "dm-status" }); + statusDot.style.background = STATUS_COLORS[statusKey] ?? "var(--text-micro)"; + avatar.appendChild(statusDot); + + // Username + const name = createElement("span", { class: "dm-name" }, convo.username); + + // Close button (hidden by default, shown on hover via CSS) + const closeBtn = createElement("button", { + class: "dm-close", + title: "Close DM", + }); + setText(closeBtn, "\u00d7"); + closeBtn.addEventListener( + "click", + (e: Event) => { + e.stopPropagation(); + if (onClose !== undefined) { + onClose(convo.userId); + } + }, + { signal }, + ); + + appendChildren(item, avatar, name, closeBtn); + + // Unread dot + if (convo.unread) { + const unreadDot = createElement("span", { class: "dm-unread" }); + item.appendChild(unreadDot); + } + + item.addEventListener("click", () => { + const parent = item.parentElement; + if (parent !== null) { + for (const sibling of parent.querySelectorAll(".dm-item.active")) { + sibling.classList.remove("active"); + } + } + item.classList.add("active"); + onSelect(convo.userId); + }, { signal }); + + return item; +} + +export function createDmSidebar(options: DmSidebarOptions): MountableComponent { + const ac = new AbortController(); + let root: HTMLDivElement | null = null; + + function mount(container: Element): void { + // Reuse channel-sidebar container class per mockup + root = createElement("div", { class: "channel-sidebar" }); + + // Search header + const header = createElement("div", { class: "dm-sidebar-header" }); + const searchInput = createElement("input", { + class: "dm-search", + placeholder: "Find a conversation", + }); + header.appendChild(searchInput); + + // Friends nav item + const friendsNav = createElement("div", { class: "dm-nav-item" }); + if (options.friendsActive === true) { + friendsNav.classList.add("active"); + } + setText(friendsNav, "Friends"); + friendsNav.addEventListener( + "click", + () => { + if (options.onFriendsClick !== undefined) { + options.onFriendsClick(); + } + }, + { signal: ac.signal }, + ); + + // Section label with + button + const sectionLabel = createElement("div", { class: "dm-section-label" }); + setText(sectionLabel, "Direct Messages"); + const addBtn = createElement("button", { + class: "dm-add", + title: "New DM", + }); + setText(addBtn, "+"); + addBtn.addEventListener("click", () => options.onNewDm(), { signal: ac.signal }); + sectionLabel.appendChild(addBtn); + + // Conversation list + const sorted = [...options.conversations].sort( + (a, b) => (b.unread ? 1 : 0) - (a.unread ? 1 : 0), + ); + + const items = sorted.map((convo) => + renderDmItem(convo, options.onSelectConversation, options.onCloseDm, ac.signal), + ); + + appendChildren(root, header, friendsNav, sectionLabel, ...items); + container.appendChild(root); + } + + function destroy(): void { + ac.abort(); + if (root !== null) { + root.remove(); + root = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/EditChannelModal.ts b/Client/tauri-client/src/components/EditChannelModal.ts new file mode 100644 index 00000000..9e145c96 --- /dev/null +++ b/Client/tauri-client/src/components/EditChannelModal.ts @@ -0,0 +1,161 @@ +/** + * EditChannelModal — modal for editing an existing channel's name and topic. + * Only visible to admin/owner users. + */ + +import { createElement, setText, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export interface EditChannelModalOptions { + /** Current channel ID. */ + readonly channelId: number; + /** Current channel name. */ + readonly channelName: string; + /** Current channel type (displayed, not editable). */ + readonly channelType: string; + /** Called when the user saves changes. */ + readonly onSave: (data: { name: string }) => Promise; + /** Called when the modal is closed. */ + readonly onClose: () => void; +} + +export function createEditChannelModal( + options: EditChannelModalOptions, +): MountableComponent { + const { channelName, channelType, onSave, onClose } = options; + const ac = new AbortController(); + let overlay: HTMLDivElement | null = null; + + function mount(container: Element): void { + overlay = createElement("div", { + class: "modal-overlay visible", + "data-testid": "edit-channel-modal", + }); + + const modal = createElement("div", { class: "modal" }); + + // Header + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Edit Channel"); + const closeBtn = createElement("button", { + class: "modal-close", + type: "button", + }); + setText(closeBtn, "\u2715"); + closeBtn.addEventListener("click", onClose, { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + // Body + const body = createElement("div", { class: "modal-body" }); + + // Channel type (read-only) + const typeGroup = createElement("div", { class: "form-group" }); + const typeLabel = createElement("label", { class: "form-label" }, "Type"); + const typeDisplay = createElement("div", { + class: "form-input", + style: "opacity: 0.7; cursor: default;", + }); + setText(typeDisplay, channelType.charAt(0).toUpperCase() + channelType.slice(1)); + appendChildren(typeGroup, typeLabel, typeDisplay); + + // Channel name + const nameGroup = createElement("div", { class: "form-group" }); + const nameLabel = createElement("label", { class: "form-label" }, "Name"); + const nameInput = createElement("input", { + class: "form-input", + type: "text", + value: channelName, + "data-testid": "edit-channel-name-input", + }) as HTMLInputElement; + nameInput.value = channelName; + appendChildren(nameGroup, nameLabel, nameInput); + + // Error display + const errorEl = createElement("div", { + class: "form-group", + style: "color: var(--red); font-size: 13px; display: none;", + "data-testid": "edit-channel-error", + }); + + appendChildren(body, typeGroup, nameGroup, errorEl); + + // Footer + const footer = createElement("div", { class: "modal-footer" }); + const cancelBtn = createElement( + "button", + { class: "btn-modal-cancel", type: "button" }, + "Cancel", + ); + cancelBtn.addEventListener("click", onClose, { signal: ac.signal }); + + const saveBtn = createElement( + "button", + { + class: "btn-modal-save", + type: "button", + "data-testid": "edit-channel-submit", + }, + "Save Changes", + ); + + saveBtn.addEventListener( + "click", + async () => { + const name = nameInput.value.trim(); + if (name === "") { + errorEl.style.display = "block"; + setText(errorEl, "Channel name is required"); + nameInput.classList.add("error"); + return; + } + + errorEl.style.display = "none"; + nameInput.classList.remove("error"); + saveBtn.setAttribute("disabled", "true"); + setText(saveBtn, "Saving..."); + + try { + await onSave({ name }); + } catch (err) { + errorEl.style.display = "block"; + setText( + errorEl, + err instanceof Error ? err.message : "Failed to update channel", + ); + saveBtn.removeAttribute("disabled"); + setText(saveBtn, "Save Changes"); + } + }, + { signal: ac.signal }, + ); + + appendChildren(footer, cancelBtn, saveBtn); + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + // Close on backdrop click + overlay.addEventListener( + "click", + (e) => { + if (e.target === overlay) { + onClose(); + } + }, + { signal: ac.signal }, + ); + + container.appendChild(overlay); + nameInput.focus(); + nameInput.select(); + } + + function destroy(): void { + ac.abort(); + if (overlay !== null) { + overlay.remove(); + overlay = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/EmojiPicker.ts b/Client/tauri-client/src/components/EmojiPicker.ts new file mode 100644 index 00000000..93da6514 --- /dev/null +++ b/Client/tauri-client/src/components/EmojiPicker.ts @@ -0,0 +1,326 @@ +// EmojiPicker — grid-based emoji selector with search and scrollable categories. +// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. + +import { createElement, setText, appendChildren, clearChildren } from "@lib/dom"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CustomEmoji { + readonly shortcode: string; + readonly url: string; +} + +export interface EmojiPickerOptions { + readonly customEmoji?: readonly CustomEmoji[]; + readonly onSelect: (emoji: string) => void; + readonly onClose: () => void; +} + +// --------------------------------------------------------------------------- +// Built-in emoji data (common subset by category) +// --------------------------------------------------------------------------- + +interface EmojiCategory { + readonly name: string; + readonly emoji: readonly string[]; +} + +const CATEGORIES: readonly EmojiCategory[] = [ + { + name: "Recent", + emoji: [], // populated at runtime from localStorage + }, + { + name: "Smileys", + emoji: [ + "😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "😊", + "😇", "🥰", "😍", "🤩", "😘", "😗", "😋", "😛", "😜", "🤪", + "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑", + "😶", "😏", "😒", "🙄", "😬", "🤥", "😌", "😔", "😪", "🤤", + "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🥵", "🥶", "🥴", "😵", + "🤯", "🤠", "🥳", "😎", "🤓", "🧐", "😕", "😟", "🙁", "😮", + "😲", "😳", "🥺", "😢", "😭", "😤", "😠", "😡", "🤬", "💀", + ], + }, + { + name: "People", + emoji: [ + "👋", "🤚", "🖐", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞", + "🤟", "🤘", "🤙", "👈", "👉", "👆", "👇", "☝️", "👍", "👎", + "✊", "👊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏", + ], + }, + { + name: "Nature", + emoji: [ + "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", + "🦁", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🐤", "🦄", + "🌸", "🌹", "🌺", "🌻", "🌼", "🌷", "🌱", "🌲", "🌳", "🍀", + ], + }, + { + name: "Food", + emoji: [ + "🍎", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍒", "🍑", "🍍", + "🥝", "🍔", "🍟", "🍕", "🌭", "🍿", "🧀", "🥚", "🍳", "🥓", + "☕", "🍵", "🍺", "🍻", "🥂", "🍷", "🍸", "🍹", "🍾", "🧁", + ], + }, + { + name: "Objects", + emoji: [ + "⚽", "🏀", "🏈", "⚾", "🎾", "🎮", "🎲", "🎯", "🎵", "🎶", + "💡", "🔥", "⭐", "🌟", "💫", "✨", "💥", "❤️", "🧡", "💛", + "💚", "💙", "💜", "🖤", "🤍", "💯", "💢", "💬", "👁‍🗨", "🗨", + ], + }, + { + name: "Symbols", + emoji: [ + "✅", "❌", "❓", "❗", "‼️", "⁉️", "💤", "💮", "♻️", "🔰", + "⚠️", "🚫", "🔴", "🟠", "🟡", "🟢", "🔵", "🟣", "⚫", "⚪", + ], + }, +]; + +/** Emoji name lookup for search. Maps emoji character → searchable keywords. */ +const EMOJI_NAMES: Readonly> = { + "😀": "grinning face happy smile", "😃": "smiley face happy smile", "😄": "smile happy grin", + "😁": "beaming grin teeth smile", "😆": "laughing happy squint smile", "😅": "sweat smile nervous", + "🤣": "rofl laughing rolling floor", "😂": "joy tears laughing cry happy", "🙂": "slightly smiling", + "😊": "blush happy smile shy", "😇": "innocent angel halo", "🥰": "love hearts face smiling", + "😍": "heart eyes love", "🤩": "star struck excited", "😘": "kiss blowing wink", + "😗": "kissing face", "😋": "yummy delicious tongue food", "😛": "tongue out", + "😜": "wink tongue playful", "🤪": "zany crazy wild", "😝": "squinting tongue", + "🤑": "money face rich dollar", "🤗": "hugging hug hands", "🤭": "hand over mouth oops giggle", + "🤫": "shushing quiet secret shh", "🤔": "thinking hmm wonder", "🤐": "zipper mouth shut secret", + "🤨": "raised eyebrow skeptical", "😐": "neutral face blank", "😑": "expressionless blank", + "😶": "no mouth silent mute", "😏": "smirk smug", "😒": "unamused bored annoyed", + "🙄": "eye roll whatever", "😬": "grimace awkward teeth", "🤥": "lying pinocchio nose", + "😌": "relieved calm peaceful", "😔": "pensive sad thoughtful", "😪": "sleepy tired", + "🤤": "drooling hungry", "😴": "sleeping zzz tired", "😷": "mask sick medical face", + "🤒": "thermometer sick fever", "🤕": "bandage hurt injured", "🤢": "nauseous sick green", + "🤮": "vomiting throw up sick", "🥵": "hot face overheated", "🥶": "cold face freezing", + "🥴": "woozy drunk dizzy", "😵": "dizzy spiral knocked out", "🤯": "mind blown exploding head", + "🤠": "cowboy hat yeehaw", "🥳": "party celebration birthday", "😎": "sunglasses cool", + "🤓": "nerd glasses geek", "🧐": "monocle detective inspect", "😕": "confused puzzled", + "😟": "worried concerned", "🙁": "frowning sad", "😮": "open mouth surprised", + "😲": "astonished shocked wow", "😳": "flushed embarrassed", "🥺": "pleading puppy eyes please", + "😢": "crying sad tear", "😭": "sobbing crying loud", "😤": "steam nose angry huffing", + "😠": "angry mad", "😡": "rage furious red", "🤬": "cursing swearing symbols angry", + "💀": "skull dead death skeleton", + "👋": "wave hello hi bye hand", "🤚": "raised back hand", "🖐": "hand fingers splayed five", + "✋": "raised hand stop high five", "🖖": "vulcan spock", "👌": "ok okay perfect", + "🤌": "pinched fingers italian", "🤏": "pinching small little", "✌️": "peace victory two", + "🤞": "crossed fingers luck hope", "🤟": "love you gesture rock", + "🤘": "rock on horns metal", "🤙": "call me hang loose shaka", "👈": "pointing left", + "👉": "pointing right", "👆": "pointing up", "👇": "pointing down", "☝️": "index pointing up", + "👍": "thumbs up like good yes", "👎": "thumbs down dislike bad no", + "✊": "raised fist power", "👊": "fist bump punch", "🤛": "left fist bump", + "🤜": "right fist bump", "👏": "clap applause bravo", "🙌": "raising hands hooray celebrate", + "👐": "open hands jazz", "🤲": "palms up together prayer", "🤝": "handshake deal agreement", + "🙏": "pray thanks please folded hands", + "🐶": "dog puppy pet", "🐱": "cat kitten pet", "🐭": "mouse rat", "🐹": "hamster", + "🐰": "rabbit bunny", "🦊": "fox", "🐻": "bear", "🐼": "panda bear", + "🐨": "koala", "🐯": "tiger", "🦁": "lion king", "🐮": "cow moo", + "🐷": "pig oink", "🐸": "frog toad", "🐵": "monkey face", "🐔": "chicken hen", + "🐧": "penguin", "🐦": "bird", "🐤": "chick baby bird", "🦄": "unicorn magic", + "🌸": "cherry blossom flower pink", "🌹": "rose flower red", "🌺": "hibiscus flower", + "🌻": "sunflower", "🌼": "blossom flower", "🌷": "tulip flower", + "🌱": "seedling sprout plant", "🌲": "evergreen tree pine", "🌳": "tree deciduous", "🍀": "four leaf clover luck", + "🍎": "red apple fruit", "🍊": "orange tangerine fruit", "🍋": "lemon fruit", "🍌": "banana fruit", + "🍉": "watermelon fruit", "🍇": "grapes fruit", "🍓": "strawberry fruit", "🍒": "cherries fruit", + "🍑": "peach fruit butt", "🍍": "pineapple fruit", "🥝": "kiwi fruit", + "🍔": "hamburger burger food", "🍟": "fries french food", "🍕": "pizza food slice", + "🌭": "hot dog food", "🍿": "popcorn snack movie", "🧀": "cheese wedge", + "🥚": "egg", "🍳": "cooking fried egg", "🥓": "bacon", + "☕": "coffee hot drink", "🍵": "tea hot drink", "🍺": "beer mug drink", + "🍻": "clinking beers cheers drink", "🥂": "champagne toast celebrate drink", + "🍷": "wine glass drink red", "🍸": "cocktail martini drink", "🍹": "tropical drink", + "🍾": "bottle popping champagne celebrate", "🧁": "cupcake dessert sweet", + "⚽": "soccer football ball sport", "🏀": "basketball ball sport", "🏈": "football american sport", + "⚾": "baseball ball sport", "🎾": "tennis ball sport", "🎮": "video game controller gaming", + "🎲": "dice game random", "🎯": "bullseye target dart", "🎵": "music note", + "🎶": "music notes", "💡": "light bulb idea", "🔥": "fire hot flame lit", + "⭐": "star yellow", "🌟": "glowing star sparkle", "💫": "dizzy star shooting", + "✨": "sparkles magic shine", "💥": "boom collision crash", "❤️": "red heart love", + "🧡": "orange heart love", "💛": "yellow heart love", "💚": "green heart love", + "💙": "blue heart love", "💜": "purple heart love", "🖤": "black heart dark love", + "🤍": "white heart love", "💯": "hundred percent perfect score", "💢": "anger symbol mad", + "💬": "speech bubble chat talk", "👁‍🗨": "eye speech bubble witness", "🗨": "speech balloon left", + "✅": "check mark yes done complete", "❌": "cross mark no wrong cancel", + "❓": "question mark red", "❗": "exclamation mark red alert", "‼️": "double exclamation", + "⁉️": "exclamation question", "💤": "sleeping zzz tired", "💮": "white flower", + "♻️": "recycle green environment", "🔰": "beginner new japanese", "⚠️": "warning caution alert", + "🚫": "prohibited forbidden no", "🔴": "red circle", "🟠": "orange circle", + "🟡": "yellow circle", "🟢": "green circle", "🔵": "blue circle", + "🟣": "purple circle", "⚫": "black circle", "⚪": "white circle", +}; + +const MAX_RECENT = 20; +const RECENT_KEY = "owncord:recent-emoji"; + +// --------------------------------------------------------------------------- +// Recent emoji persistence +// --------------------------------------------------------------------------- + +function getRecentEmoji(): string[] { + try { + const raw = localStorage.getItem(RECENT_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((e): e is string => typeof e === "string").slice(0, MAX_RECENT); + } catch { + return []; + } +} + +function addRecentEmoji(emoji: string): void { + const recent = getRecentEmoji().filter((e) => e !== emoji); + recent.unshift(emoji); + try { + localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT))); + } catch { + // localStorage full or unavailable — ignore + } +} + +// --------------------------------------------------------------------------- +// EmojiPicker +// --------------------------------------------------------------------------- + +export function createEmojiPicker(options: EmojiPickerOptions): { + readonly element: HTMLDivElement; + destroy(): void; +} { + const abortController = new AbortController(); + const signal = abortController.signal; + + let searchQuery = ""; + + // Build DOM — matches mockup structure: + // .emoji-picker.open > .ep-header > input.ep-search + // then repeating: .ep-category-label + .ep-grid > span.ep-emoji + const root = createElement("div", { class: "emoji-picker open" }); + + const header = createElement("div", { class: "ep-header" }); + const searchInput = createElement("input", { + class: "ep-search", + type: "text", + placeholder: "Search emoji...", + }); + header.appendChild(searchInput); + root.appendChild(header); + + // Scrollable content area (holds category labels + grids) + const scrollArea = createElement("div", { + style: "overflow-y: auto; max-height: 320px;", + }); + root.appendChild(scrollArea); + + // Build categories with recent + custom + function getAllCategories(): readonly EmojiCategory[] { + const recent = getRecentEmoji(); + const cats: EmojiCategory[] = [ + { name: "Recent", emoji: recent }, + ]; + + // Custom server emoji + if (options.customEmoji && options.customEmoji.length > 0) { + cats.push({ + name: "Custom", + emoji: options.customEmoji.map((e) => `:${e.shortcode}:`), + }); + } + + // Add built-in categories (skip the empty "Recent" placeholder) + for (const cat of CATEGORIES) { + if (cat.name === "Recent") continue; + cats.push(cat); + } + + return cats; + } + + function handleEmojiClick(emoji: string): void { + addRecentEmoji(emoji); + options.onSelect(emoji); + } + + function buildEmojiSpan(emoji: string): HTMLSpanElement { + const span = createElement("span", { + class: "ep-emoji", + title: emoji, + }); + setText(span, emoji); + span.addEventListener("click", () => handleEmojiClick(emoji), { signal }); + return span; + } + + function renderAllCategories(categories: readonly EmojiCategory[]): void { + clearChildren(scrollArea); + + for (const cat of categories) { + if (cat.emoji.length === 0) continue; + + const filtered = searchQuery + ? cat.emoji.filter((e) => { + const q = searchQuery.toLowerCase(); + // Match against emoji name/keywords, or the character itself + const name = EMOJI_NAMES[e]; + if (name !== undefined && name.includes(q)) return true; + // Also match custom emoji shortcodes like :wave: + return e.toLowerCase().includes(q); + }) + : cat.emoji; + + if (filtered.length === 0) continue; + + const label = createElement("div", { class: "ep-category-label" }); + setText(label, cat.name); + scrollArea.appendChild(label); + + const grid = createElement("div", { class: "ep-grid" }); + for (const emoji of filtered) { + grid.appendChild(buildEmojiSpan(emoji)); + } + scrollArea.appendChild(grid); + } + + // If nothing rendered at all, show empty state + if (scrollArea.children.length === 0) { + const empty = createElement("div", { + style: "padding: 24px; text-align: center; color: var(--text-faint); font-size: 13px;", + }, "No emoji found"); + scrollArea.appendChild(empty); + } + } + + // Initial render + renderAllCategories(getAllCategories()); + + // Search handler + searchInput.addEventListener("input", () => { + searchQuery = searchInput.value.trim(); + renderAllCategories(getAllCategories()); + }, { signal }); + + // Close on Escape + root.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + options.onClose(); + } + }, { signal }); + + // Focus search on mount + requestAnimationFrame(() => searchInput.focus()); + + function destroy(): void { + abortController.abort(); + } + + return { element: root, destroy }; +} diff --git a/Client/tauri-client/src/components/FileUpload.ts b/Client/tauri-client/src/components/FileUpload.ts new file mode 100644 index 00000000..fa180079 --- /dev/null +++ b/Client/tauri-client/src/components/FileUpload.ts @@ -0,0 +1,166 @@ +// Step 8.59 — File upload component with drag-and-drop, preview, and progress. +// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. + +import { createElement, setText, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export interface FileUploadOptions { + readonly onUpload: (file: File) => Promise; + readonly maxSizeMb?: number; +} + +const DEFAULT_MAX_SIZE_MB = 10; + +export type FileUploadComponent = MountableComponent & { openPicker(): void }; + +export function createFileUpload(options: FileUploadOptions): FileUploadComponent { + const maxBytes = (options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB) * 1024 * 1024; + const ac = new AbortController(); + const signal = ac.signal; + + let root: HTMLDivElement | null = null; + let dropzone: HTMLDivElement; + let fileInput: HTMLInputElement; + let preview: HTMLDivElement; + let thumb: HTMLImageElement; + let nameSpan: HTMLSpanElement; + let sizeSpan: HTMLSpanElement; + let progressBar: HTMLDivElement; + let cancelBtn: HTMLButtonElement; + let errorDiv: HTMLDivElement; + let uploadAbort: AbortController | null = null; + + function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + function showError(message: string): void { + setText(errorDiv, message); + errorDiv.classList.remove("file-upload__error--hidden"); + preview.classList.add("file-upload__preview--hidden"); + } + + function resetPreview(): void { + preview.classList.add("file-upload__preview--hidden"); + thumb.src = ""; + thumb.style.display = "none"; + setText(nameSpan, ""); + setText(sizeSpan, ""); + progressBar.style.width = "0%"; + uploadAbort = null; + errorDiv.classList.add("file-upload__error--hidden"); + } + + function showPreview(file: File): void { + resetPreview(); + setText(nameSpan, file.name); + setText(sizeSpan, formatSize(file.size)); + if (file.type.startsWith("image/")) { + const url = URL.createObjectURL(file); + thumb.src = url; + thumb.style.display = "block"; + thumb.onload = () => URL.revokeObjectURL(url); + } + preview.classList.remove("file-upload__preview--hidden"); + } + + async function handleFile(file: File): Promise { + errorDiv.classList.add("file-upload__error--hidden"); + if (file.size > maxBytes) { + showError(`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`); + return; + } + showPreview(file); + uploadAbort = new AbortController(); + try { + progressBar.style.width = "50%"; + await options.onUpload(file); + progressBar.style.width = "100%"; + setTimeout(() => resetPreview(), 1500); + } catch (err) { + if (uploadAbort?.signal.aborted) return; + showError(err instanceof Error ? err.message : "Upload failed"); + resetPreview(); + } + } + + function buildDom(): void { + root = createElement("div", { class: "file-upload" }); + + dropzone = createElement("div", { class: "file-upload__dropzone file-upload__dropzone--hidden" }); + appendChildren(dropzone, createElement("span", { class: "file-upload__droptext" }, "Drop files here")); + + fileInput = createElement("input", { class: "file-upload__input", type: "file" }) as HTMLInputElement; + fileInput.style.display = "none"; + + preview = createElement("div", { class: "file-upload__preview file-upload__preview--hidden" }); + thumb = createElement("img", { class: "file-upload__thumb" }) as HTMLImageElement; + thumb.style.display = "none"; + thumb.alt = ""; + nameSpan = createElement("span", { class: "file-upload__name" }) as HTMLSpanElement; + sizeSpan = createElement("span", { class: "file-upload__size" }) as HTMLSpanElement; + const progressContainer = createElement("div", { class: "file-upload__progress" }); + progressBar = createElement("div", { class: "file-upload__progress-bar" }); + progressBar.style.width = "0%"; + appendChildren(progressContainer, progressBar); + cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" }, "\u00d7") as HTMLButtonElement; + appendChildren(preview, thumb, nameSpan, sizeSpan, progressContainer, cancelBtn); + + errorDiv = createElement("div", { class: "file-upload__error file-upload__error--hidden" }); + appendChildren(root, dropzone, fileInput, preview, errorDiv); + } + + function attachListeners(): void { + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + if (file) { void handleFile(file); fileInput.value = ""; } + }, { signal }); + + cancelBtn.addEventListener("click", () => { + if (uploadAbort !== null) uploadAbort.abort(); + resetPreview(); + }, { signal }); + + let dragCounter = 0; + root!.addEventListener("dragenter", (e) => { + e.preventDefault(); + dragCounter++; + dropzone.classList.remove("file-upload__dropzone--hidden"); + }, { signal }); + + root!.addEventListener("dragleave", (e) => { + e.preventDefault(); + dragCounter--; + if (dragCounter <= 0) { dragCounter = 0; dropzone.classList.add("file-upload__dropzone--hidden"); } + }, { signal }); + + root!.addEventListener("dragover", (e) => e.preventDefault(), { signal }); + + root!.addEventListener("drop", (e) => { + e.preventDefault(); + dragCounter = 0; + dropzone.classList.add("file-upload__dropzone--hidden"); + const file = e.dataTransfer?.files[0]; + if (file) void handleFile(file); + }, { signal }); + } + + function mount(container: Element): void { + buildDom(); + attachListeners(); + container.appendChild(root!); + } + + function destroy(): void { + ac.abort(); + if (uploadAbort !== null) uploadAbort.abort(); + root?.remove(); + root = null; + } + + function openPicker(): void { fileInput.click(); } + + return { mount, destroy, openPicker }; +} diff --git a/Client/tauri-client/src/components/InviteManager.ts b/Client/tauri-client/src/components/InviteManager.ts new file mode 100644 index 00000000..062c2dbc --- /dev/null +++ b/Client/tauri-client/src/components/InviteManager.ts @@ -0,0 +1,161 @@ +/** + * InviteManager component — modal overlay for managing server invites. + * Create, copy, and revoke invite codes. + */ + +import { createElement, appendChildren, clearChildren, setText } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface InviteItem { + readonly code: string; + readonly createdBy: string; + readonly createdAt: string; + readonly uses: number; + readonly maxUses: number | null; + readonly expiresAt: string | null; +} + +export interface InviteManagerOptions { + invites: readonly InviteItem[]; + onCreateInvite(): Promise; + onRevokeInvite(code: string): Promise; + onCopyLink(code: string): void; + onClose(): void; + onError?(message: string): void; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function maskCode(code: string): string { + if (code.length <= 6) return code; + return `${code.slice(0, 3)}...${code.slice(-3)}`; +} + +function formatInviteInfo(invite: InviteItem): string { + const uses = invite.maxUses !== null + ? `${invite.uses}/${invite.maxUses} uses` + : `${invite.uses} uses`; + return `Created by ${invite.createdBy} \u00B7 ${uses}`; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createInviteManager( + options: InviteManagerOptions, +): MountableComponent { + const ac = new AbortController(); + let root: HTMLDivElement | null = null; + let listEl: HTMLDivElement | null = null; + let emptyEl: HTMLDivElement | null = null; + let invites: readonly InviteItem[] = options.invites; + + function renderList(): void { + if (listEl === null || emptyEl === null) return; + clearChildren(listEl); + + if (invites.length === 0) { + emptyEl.style.display = ""; + return; + } + + emptyEl.style.display = "none"; + + for (const invite of invites) { + const row = createElement("div", { class: "invite-item" }); + const code = createElement("span", { class: "invite-item__code" }, maskCode(invite.code)); + const info = createElement("span", { class: "invite-item__info" }, formatInviteInfo(invite)); + + const copyBtn = createElement("button", { class: "invite-item__copy" }, "Copy"); + copyBtn.addEventListener("click", () => { + options.onCopyLink(invite.code); + }, { signal: ac.signal }); + + const revokeBtn = createElement("button", { class: "invite-item__revoke" }, "Revoke"); + revokeBtn.addEventListener("click", () => { + void options.onRevokeInvite(invite.code).then(() => { + invites = invites.filter((i) => i.code !== invite.code); + renderList(); + }).catch(() => { + options.onError?.("Failed to revoke invite"); + }); + }, { signal: ac.signal }); + + appendChildren(row, code, info, copyBtn, revokeBtn); + listEl.appendChild(row); + } + } + + function mount(container: Element): void { + root = createElement("div", { + class: "invite-manager-overlay", + style: "position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:1000;display:flex;justify-content:center;align-items:center;", + }); + + const modal = createElement("div", { + class: "invite-manager", + style: "background:var(--bg-secondary,#2f3136);border-radius:8px;padding:16px;min-width:400px;max-width:520px;", + }); + + // Header + const header = createElement("div", { class: "invite-manager__header" }); + const title = createElement("h2", {}, "Server Invites"); + const closeBtn = createElement("button", { class: "invite-manager__close" }, "\u00D7"); + closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + // Create button + const createBtn = createElement("button", { class: "invite-manager__create" }, "Create Invite"); + createBtn.addEventListener("click", () => { + void options.onCreateInvite().then((newInvite) => { + invites = [...invites, newInvite]; + renderList(); + }).catch(() => { + options.onError?.("Failed to create invite"); + }); + }, { signal: ac.signal }); + + // List + listEl = createElement("div", { class: "invite-manager__list" }); + emptyEl = createElement("div", { class: "invite-manager__empty" }, "No active invites"); + + // Escape key + document.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Escape") { + options.onClose(); + } + }, { signal: ac.signal }); + + // Click overlay to close + root.addEventListener("click", (e) => { + if (e.target === root) { + options.onClose(); + } + }, { signal: ac.signal }); + + appendChildren(modal, header, createBtn, listEl, emptyEl); + root.appendChild(modal); + renderList(); + + container.appendChild(root); + } + + function destroy(): void { + ac.abort(); + if (root !== null) { + root.remove(); + root = null; + } + listEl = null; + emptyEl = null; + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/MemberList.ts b/Client/tauri-client/src/components/MemberList.ts new file mode 100644 index 00000000..c1e6065a --- /dev/null +++ b/Client/tauri-client/src/components/MemberList.ts @@ -0,0 +1,128 @@ +/** + * MemberList component — shows server members grouped by role with online status. + * Subscribes to membersStore for reactive updates. + */ + +import { createElement, appendChildren, clearChildren, setText } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import { membersStore, type Member } from "@stores/members.store"; +import type { UserStatus } from "@lib/types"; + +/** Ordered role groups with display names and CSS color variables. */ +const ROLE_GROUPS: readonly { + readonly role: string; + readonly label: string; + readonly colorVar: string; +}[] = [ + { role: "owner", label: "OWNER", colorVar: "var(--role-owner, #e74c3c)" }, + { role: "admin", label: "ADMIN", colorVar: "var(--role-admin, #f39c12)" }, + { role: "moderator", label: "MODERATOR", colorVar: "var(--role-mod, #2ecc71)" }, + { role: "member", label: "MEMBER", colorVar: "var(--role-member, #949ba4)" }, +] as const; + +/** Status priority for sorting: lower = higher priority (shown first). */ +function statusPriority(status: UserStatus): number { + switch (status) { + case "online": return 0; + case "idle": return 1; + case "dnd": return 2; + case "offline": return 3; + } +} + +function statusColor(status: UserStatus): string { + switch (status) { + case "online": return "var(--green)"; + case "idle": return "var(--yellow)"; + case "dnd": return "var(--red)"; + case "offline": return "var(--text-micro)"; + } +} + +function createMemberItem(member: Member, colorVar: string): HTMLDivElement { + const item = createElement("div", { + class: member.status === "offline" ? "member-item offline" : "member-item", + "data-testid": `member-${member.id}`, + }); + + const initial = member.username.charAt(0).toUpperCase() || "?"; + const avatar = createElement( + "div", + { class: "mi-avatar", style: `background: ${colorVar}` }, + initial, + ); + + const statusDot = createElement("div", { + class: "mi-status", + style: `background: ${statusColor(member.status)}`, + }); + avatar.appendChild(statusDot); + + const name = createElement( + "span", + { class: "mi-name", style: `color: ${colorVar}` }, + ); + setText(name, member.username); + + appendChildren(item, avatar, name); + return item; +} + +function renderList(root: HTMLDivElement): void { + clearChildren(root); + + const state = membersStore.getState(); + const allMembers = Array.from(state.members.values()); + + for (const group of ROLE_GROUPS) { + const groupMembers = allMembers + .filter((m) => m.role.toLowerCase() === group.role) + .sort((a, b) => statusPriority(a.status) - statusPriority(b.status)); + + if (groupMembers.length === 0) continue; + + const header = createElement( + "div", + { class: "member-role-group" }, + `${group.label} \u2014 ${groupMembers.length}`, + ); + root.appendChild(header); + + for (const member of groupMembers) { + root.appendChild(createMemberItem(member, group.colorVar)); + } + } +} + +export function createMemberList(): MountableComponent { + const ac = new AbortController(); + let root: HTMLDivElement | null = null; + let unsubscribe: (() => void) | null = null; + + function mount(container: Element): void { + root = createElement("div", { class: "member-list", "data-testid": "member-list" }); + renderList(root); + + unsubscribe = membersStore.subscribe(() => { + if (root !== null) { + renderList(root); + } + }); + + container.appendChild(root); + } + + function destroy(): void { + ac.abort(); + if (unsubscribe !== null) { + unsubscribe(); + unsubscribe = null; + } + if (root !== null) { + root.remove(); + root = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/MessageInput.ts b/Client/tauri-client/src/components/MessageInput.ts new file mode 100644 index 00000000..2168ff7f --- /dev/null +++ b/Client/tauri-client/src/components/MessageInput.ts @@ -0,0 +1,399 @@ +/** + * MessageInput component — textarea with send, reply bar, and edit mode. + * Step 5.42 of the Tauri v2 migration. + */ + +import { createElement, appendChildren, setText } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import { createEmojiPicker } from "@components/EmojiPicker"; + +export interface MessageInputOptions { + readonly channelId: number; + readonly channelName: string; + readonly onSend: (content: string, replyTo: number | null, attachments: readonly string[]) => void; + readonly onUploadFile?: (file: File) => Promise<{ id: string; url: string; filename: string }>; + readonly onTyping: () => void; + readonly onEditMessage: (messageId: number, content: string) => void; +} + +export type MessageInputComponent = MountableComponent & { + setReplyTo(messageId: number, username: string): void; + clearReply(): void; + startEdit(messageId: number, content: string): void; + cancelEdit(): void; +}; + +const TYPING_THROTTLE_MS = 3_000; +const MAX_TEXTAREA_HEIGHT = 200; +const SEND_DEBOUNCE_MS = 200; + +export function createMessageInput( + options: MessageInputOptions, +): MessageInputComponent { + const ac = new AbortController(); + const signal = ac.signal; + let root: HTMLDivElement | null = null; + let state = { replyTo: null as { messageId: number; username: string } | null, + editing: null as { messageId: number } | null }; + let lastTypingTime = 0; + let lastSendTime = 0; + + let textarea: HTMLTextAreaElement | null = null; + let replyBar: HTMLDivElement | null = null; + let replyText: HTMLSpanElement | null = null; + let editBar: HTMLDivElement | null = null; + let attachmentPreviewBar: HTMLDivElement | null = null; + + /** Pending attachment IDs to send with the next message. */ + const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] = []; + + function showReplyBar(username: string): void { + if (replyBar === null || replyText === null) return; + setText(replyText, `Replying to @${username}`); + replyBar.classList.add("visible"); + } + + function hideReplyBar(): void { replyBar?.classList.remove("visible"); } + function showEditBar(): void { editBar?.classList.add("visible"); } + function hideEditBar(): void { editBar?.classList.remove("visible"); } + + function autoResize(): void { + if (textarea === null) return; + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`; + } + + function maybeEmitTyping(): void { + const now = Date.now(); + if (now - lastTypingTime >= TYPING_THROTTLE_MS) { + lastTypingTime = now; + options.onTyping(); + } + } + + function clearPendingAttachments(): void { + for (const att of pendingAttachments) { + att.previewEl.remove(); + } + pendingAttachments.length = 0; + if (attachmentPreviewBar !== null) { + attachmentPreviewBar.classList.remove("visible"); + } + } + + function handleSend(): void { + if (textarea === null) return; + const content = textarea.value.trim(); + const hasAttachments = pendingAttachments.length > 0; + if (content.length === 0 && !hasAttachments) return; + + // Debounce to prevent double-click duplicate sends + const now = Date.now(); + if (now - lastSendTime < SEND_DEBOUNCE_MS) return; + lastSendTime = now; + + if (state.editing !== null) { + options.onEditMessage(state.editing.messageId, content); + cancelEdit(); + } else { + // Only include attachments that have finished uploading (have a real server ID) + const attachmentIds = pendingAttachments + .filter((a) => !a.id.startsWith("pending-")) + .map((a) => a.id); + options.onSend(content, state.replyTo?.messageId ?? null, attachmentIds); + clearReply(); + clearPendingAttachments(); + } + + textarea.value = ""; + autoResize(); + textarea.focus(); + } + + /** Unique counter for preview items (before upload completes and we have a server ID). */ + let previewCounter = 0; + + function removePreviewItem(tempId: string): void { + const idx = pendingAttachments.findIndex((a) => a.id === tempId); + const att = idx !== -1 ? pendingAttachments[idx] : undefined; + if (att !== undefined) { + const img = att.previewEl.querySelector("img"); + if (img !== null && img.src.startsWith("blob:")) { + URL.revokeObjectURL(img.src); + } + att.previewEl.remove(); + pendingAttachments.splice(idx, 1); + if (pendingAttachments.length === 0) { + attachmentPreviewBar?.classList.remove("visible"); + } + } + } + + /** Read a File as a data: URL (more reliable than createObjectURL in WebView2). */ + function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsDataURL(file); + }); + } + + async function handlePasteFile(file: File): Promise { + if (options.onUploadFile === undefined || attachmentPreviewBar === null) return; + + const tempId = `pending-${++previewCounter}`; + const isImage = file.type.startsWith("image/"); + + attachmentPreviewBar.classList.add("visible"); + + const item = createElement("div", { class: "attachment-preview-item uploading" }); + + if (isImage) { + // Read file as data URL for preview (works reliably in WebView2) + const img = createElement("img", { + class: "attachment-preview-img", + alt: file.name, + }) as HTMLImageElement; + item.appendChild(img); + readFileAsDataUrl(file).then((dataUrl) => { + img.src = dataUrl; + }).catch(() => { + // Fallback: show filename + const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name); + img.replaceWith(nameEl); + }); + } else { + const icon = createElement("div", { class: "attachment-preview-file" }, "\uD83D\uDCC4"); + const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name); + appendChildren(item, icon, nameEl); + } + + // Loading spinner overlay + const spinner = createElement("div", { class: "attachment-preview-spinner" }, "\u23F3"); + item.appendChild(spinner); + + const removeBtn = createElement("button", { + class: "attachment-preview-remove", + "data-testid": "attachment-remove", + }, "\u00D7"); + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + removePreviewItem(tempId); + }, { signal }); + item.appendChild(removeBtn); + + attachmentPreviewBar.appendChild(item); + pendingAttachments.push({ id: tempId, filename: file.name, previewEl: item }); + + // Upload in background + try { + const result = await options.onUploadFile(file); + // Replace temp ID with real server ID + const att = pendingAttachments.find((a) => a.id === tempId); + if (att !== undefined) { + att.id = result.id; + att.filename = result.filename; + item.classList.remove("uploading"); + spinner.remove(); + } + } catch (err) { + // Upload failed — remove preview and show error + removePreviewItem(tempId); + const errMsg = err instanceof Error ? err.message : "Upload failed"; + // Show error inline since we may not have toast access here + const errEl = createElement("div", { + class: "attachment-upload-error", + }, `Upload failed: ${errMsg}`); + attachmentPreviewBar.appendChild(errEl); + setTimeout(() => errEl.remove(), 4000); + } + } + + function setReplyTo(messageId: number, username: string): void { + if (state.editing !== null) hideEditBar(); + state = { replyTo: { messageId, username }, editing: null }; + showReplyBar(username); + textarea?.focus(); + } + + function clearReply(): void { + state = { ...state, replyTo: null }; + hideReplyBar(); + } + + function startEdit(messageId: number, content: string): void { + if (state.replyTo !== null) hideReplyBar(); + state = { replyTo: null, editing: { messageId } }; + showEditBar(); + if (textarea !== null) { + textarea.value = content; + autoResize(); + textarea.focus(); + } + } + + function cancelEdit(): void { + state = { ...state, editing: null }; + hideEditBar(); + if (textarea !== null) { textarea.value = ""; autoResize(); } + } + + function mount(container: Element): void { + root = createElement("div", { class: "message-input-wrap", "data-testid": "message-input" }); + + replyBar = createElement("div", { class: "reply-bar" }); + const replyInner = createElement("div", { class: "reply-bar-inner" }); + replyText = createElement("strong", {}); + replyInner.appendChild(replyText); + const replyClose = createElement("button", { class: "reply-close" }, "\u00D7"); + replyClose.addEventListener("click", clearReply, { signal }); + replyInner.appendChild(replyClose); + replyBar.appendChild(replyInner); + + editBar = createElement("div", { class: "reply-bar" }); + const editInner = createElement("div", { class: "reply-bar-inner" }); + const editText = createElement("strong", {}, "Editing message"); + editInner.appendChild(editText); + const editClose = createElement("button", { class: "reply-close" }, "\u00D7"); + editClose.addEventListener("click", () => cancelEdit(), { signal }); + editInner.appendChild(editClose); + editBar.appendChild(editInner); + + attachmentPreviewBar = createElement("div", { class: "attachment-preview-bar" }); + + const inputBox = createElement("div", { class: "message-input-box" }); + const attachBtn = createElement("button", + { class: "input-btn attach-btn", "aria-label": "Attach file" }, "+"); + + // File picker via attach button + if (options.onUploadFile !== undefined) { + const fileInput = createElement("input", { + type: "file", + style: "display: none;", + accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z", + }) as HTMLInputElement; + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + if (file !== undefined) { + void handlePasteFile(file); + } + fileInput.value = ""; + }, { signal }); + attachBtn.addEventListener("click", () => fileInput.click(), { signal }); + root?.appendChild(fileInput); + } else { + attachBtn.setAttribute("disabled", "true"); + attachBtn.title = "File uploads not available"; + } + textarea = createElement("textarea", { + class: "msg-textarea", placeholder: `Message #${options.channelName}`, rows: "1", + "data-testid": "msg-textarea", + }); + const emojiBtn = createElement("button", + { class: "input-btn emoji-btn", "aria-label": "Emoji" }, "\uD83D\uDE00"); + const sendBtn = createElement("button", + { class: "input-btn send-btn", "aria-label": "Send message", "data-testid": "send-btn" }, "\u27A4"); + + textarea.addEventListener("input", () => { autoResize(); maybeEmitTyping(); }, { signal }); + textarea.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } + if (e.key === "ArrowUp" && textarea !== null && textarea.value.length === 0) { + root?.dispatchEvent(new CustomEvent("edit-last-message", { bubbles: true })); + } + }, { signal }); + + // Clipboard paste: detect images/files + textarea.addEventListener("paste", (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (items === undefined) return; + for (const item of items) { + if (item.kind !== "file") continue; + const file = item.getAsFile(); + if (file === null) continue; + e.preventDefault(); + void handlePasteFile(file); + } + }, { signal }); + + sendBtn.addEventListener("click", handleSend, { signal }); + + // Emoji picker toggle + let emojiPicker: { element: HTMLDivElement; destroy(): void } | null = null; + + function closeEmojiPicker(): void { + if (emojiPicker !== null) { + emojiPicker.element.remove(); + emojiPicker.destroy(); + emojiPicker = null; + document.removeEventListener("mousedown", handleClickOutside); + } + } + + function handleClickOutside(e: MouseEvent): void { + if (emojiPicker === null) return; + const target = e.target as Node; + // Close if click is outside both the picker and the emoji button + if (!emojiPicker.element.contains(target) && target !== emojiBtn && !emojiBtn.contains(target)) { + closeEmojiPicker(); + } + } + + function toggleEmojiPicker(): void { + if (emojiPicker !== null) { + closeEmojiPicker(); + return; + } + emojiPicker = createEmojiPicker({ + onSelect: (emoji: string) => { + if (textarea !== null) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const before = textarea.value.slice(0, start); + const after = textarea.value.slice(end); + textarea.value = before + emoji + after; + textarea.selectionStart = textarea.selectionEnd = start + emoji.length; + textarea.focus(); + } + closeEmojiPicker(); + }, + onClose: () => { + closeEmojiPicker(); + }, + }); + root?.appendChild(emojiPicker.element); + // Defer so this click doesn't immediately close it + setTimeout(() => { + document.addEventListener("mousedown", handleClickOutside); + }, 0); + } + + emojiBtn.addEventListener("click", toggleEmojiPicker, { signal }); + + appendChildren(inputBox, attachBtn, textarea, emojiBtn, sendBtn); + appendChildren(root, replyBar, editBar, attachmentPreviewBar, inputBox); + container.appendChild(root); + textarea.focus(); + } + + function destroy(): void { + ac.abort(); + // Revoke any blob URLs for image previews + for (const att of pendingAttachments) { + const img = att.previewEl.querySelector("img"); + if (img !== null && img.src.startsWith("blob:")) { + URL.revokeObjectURL(img.src); + } + } + pendingAttachments.length = 0; + root?.remove(); + root = null; + textarea = null; + replyBar = null; + replyText = null; + editBar = null; + attachmentPreviewBar = null; + } + + return { mount, destroy, setReplyTo, clearReply, startEdit, cancelEdit }; +} diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts new file mode 100644 index 00000000..0887acdb --- /dev/null +++ b/Client/tauri-client/src/components/MessageList.ts @@ -0,0 +1,377 @@ +/** + * MessageList component — renders chat messages with grouping, day dividers, + * role-colored usernames, @mention highlighting, infinite scroll, and + * virtual scrolling (DOM windowing) for performance with large message counts. + */ +import { createElement, clearChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import { messagesStore, getChannelMessages, hasMoreMessages } from "@stores/messages.store"; +import type { Message } from "@stores/messages.store"; +import { membersStore } from "@stores/members.store"; +import { + shouldGroup, + isSameDay, + renderDayDivider, + renderMessage, +} from "./message-list/renderers"; + +// -- Options ------------------------------------------------------------------ + +export interface MessageListOptions { + readonly channelId: number; + readonly currentUserId: number; + readonly onScrollTop: () => void; + readonly onReplyClick: (messageId: number) => void; + readonly onEditClick: (messageId: number) => void; + readonly onDeleteClick: (messageId: number) => void; + readonly onReactionClick: (messageId: number, emoji: string) => void; +} + +// -- Constants ---------------------------------------------------------------- + +const SCROLL_TOP_THRESHOLD = 50; +const SCROLL_BOTTOM_THRESHOLD = 100; + +/** Number of items to render beyond visible viewport in each direction. */ +const OVERSCAN = 10; + +/** Estimated pixel height per row (message or day divider) for initial layout. */ +const ESTIMATED_ROW_HEIGHT = 52; + +// -- Virtual item types ------------------------------------------------------- + +interface VirtualItemMessage { + readonly kind: "message"; + readonly message: Message; + readonly isGrouped: boolean; +} + +interface VirtualItemDivider { + readonly kind: "divider"; + readonly timestamp: string; +} + +type VirtualItem = VirtualItemMessage | VirtualItemDivider; + +// -- Pre-process messages into virtual items ---------------------------------- + +function buildVirtualItems(messages: readonly Message[]): readonly VirtualItem[] { + const items: VirtualItem[] = []; + let lastTimestamp: string | null = null; + let prevMsg: Message | null = null; + + for (const msg of messages) { + if (lastTimestamp === null || !isSameDay(lastTimestamp, msg.timestamp)) { + items.push({ kind: "divider", timestamp: msg.timestamp }); + } + const isGrouped = prevMsg !== null && shouldGroup(prevMsg, msg); + items.push({ kind: "message", message: msg, isGrouped }); + lastTimestamp = msg.timestamp; + prevMsg = msg; + } + return items; +} + +// -- Factory ------------------------------------------------------------------ + +export type MessageListComponent = MountableComponent & { + /** Scroll to a message by ID. Returns false if the message is not in the loaded window. */ + scrollToMessage(messageId: number): boolean; +}; + +export function createMessageList(options: MessageListOptions): MessageListComponent { + const ac = new AbortController(); + const unsubscribers: Array<() => void> = []; + let root: HTMLDivElement | null = null; + let wasAtBottom = true; + + // Virtual scroll state + let virtualItems: readonly VirtualItem[] = []; + let allMessages: readonly Message[] = []; + const heightCache = new Map(); // itemKey → measured px + let topSpacer: HTMLDivElement | null = null; + let bottomSpacer: HTMLDivElement | null = null; + let contentContainer: HTMLDivElement | null = null; + let renderedStart = 0; + let renderedEnd = 0; + + // --------------------------------------------------------------------------- + // Height estimation + // --------------------------------------------------------------------------- + + function itemKey(index: number): string { + const item = virtualItems[index]; + if (item === undefined) return `idx-${index}`; + if (item.kind === "divider") return `div-${item.timestamp}`; + return `msg-${item.message.id}`; + } + + function getItemHeight(index: number): number { + return heightCache.get(itemKey(index)) ?? ESTIMATED_ROW_HEIGHT; + } + + function totalHeight(): number { + let h = 0; + for (let i = 0; i < virtualItems.length; i++) { + h += getItemHeight(i); + } + return h; + } + + function offsetToIndex(scrollTop: number): number { + let offset = 0; + for (let i = 0; i < virtualItems.length; i++) { + const h = getItemHeight(i); + if (offset + h > scrollTop) return i; + offset += h; + } + return virtualItems.length - 1; + } + + function offsetBefore(index: number): number { + let offset = 0; + for (let i = 0; i < index && i < virtualItems.length; i++) { + offset += getItemHeight(i); + } + return offset; + } + + // --------------------------------------------------------------------------- + // Scroll helpers + // --------------------------------------------------------------------------- + + function isNearBottom(): boolean { + if (root === null) return true; + const { scrollTop, scrollHeight, clientHeight } = root; + return scrollHeight - scrollTop - clientHeight < SCROLL_BOTTOM_THRESHOLD; + } + + function scrollToBottom(): void { + if (root === null) return; + root.scrollTop = root.scrollHeight; + } + + // --------------------------------------------------------------------------- + // Render visible window + // --------------------------------------------------------------------------- + + function measureRendered(): void { + if (contentContainer === null) return; + const children = contentContainer.children; + for (let i = 0; i < children.length; i++) { + const globalIdx = renderedStart + i; + const el = children[i] as HTMLElement; + const h = el.offsetHeight; + if (h > 0) { + heightCache.set(itemKey(globalIdx), h); + } + } + } + + function renderWindow(): void { + if (root === null || contentContainer === null || topSpacer === null || bottomSpacer === null) return; + + const scrollTop = root.scrollTop; + const clientHeight = root.clientHeight; + + if (virtualItems.length === 0) { + clearChildren(contentContainer); + topSpacer.style.height = "0px"; + bottomSpacer.style.height = "0px"; + renderedStart = 0; + renderedEnd = 0; + return; + } + + // Determine visible range + const firstVisible = offsetToIndex(scrollTop); + const lastVisible = offsetToIndex(scrollTop + clientHeight); + + const start = Math.max(0, firstVisible - OVERSCAN); + const end = Math.min(virtualItems.length, lastVisible + OVERSCAN + 1); + + // Skip re-render if the range hasn't changed + if (start === renderedStart && end === renderedEnd) return; + + // Measure current elements before replacing + measureRendered(); + + renderedStart = start; + renderedEnd = end; + + // Rebuild content + clearChildren(contentContainer); + const fragment = document.createDocumentFragment(); + for (let i = start; i < end; i++) { + const item = virtualItems[i]!; + if (item.kind === "divider") { + fragment.appendChild(renderDayDivider(item.timestamp)); + } else { + fragment.appendChild( + renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal), + ); + } + } + contentContainer.appendChild(fragment); + + // Set spacer heights + topSpacer.style.height = `${offsetBefore(start)}px`; + + let bottomHeight = 0; + for (let i = end; i < virtualItems.length; i++) { + bottomHeight += getItemHeight(i); + } + bottomSpacer.style.height = `${bottomHeight}px`; + + // Measure newly rendered elements + measureRendered(); + } + + // --------------------------------------------------------------------------- + // Full rebuild (on data change) + // --------------------------------------------------------------------------- + + function rebuildItems(): void { + allMessages = getChannelMessages(options.channelId); + virtualItems = buildVirtualItems(allMessages); + } + + function renderAll(): void { + if (root === null) return; + wasAtBottom = isNearBottom(); + + rebuildItems(); + + // Reset rendered range to force full re-render + renderedStart = -1; + renderedEnd = -1; + + renderWindow(); + + if (wasAtBottom) { + scrollToBottom(); + } + } + + // --------------------------------------------------------------------------- + // Scroll / load-more handling + // --------------------------------------------------------------------------- + + let loadingOlder = false; + let prevMessageCount = 0; + + const unsubLoadingReset = messagesStore.subscribe(() => { + const msgs = getChannelMessages(options.channelId); + if (msgs.length !== prevMessageCount) { + prevMessageCount = msgs.length; + loadingOlder = false; + } + }); + + let scrollRafId = 0; + + function handleScroll(): void { + if (root === null) return; + + // Load older messages when near top + if ( + root.scrollTop < SCROLL_TOP_THRESHOLD + && !loadingOlder + && hasMoreMessages(options.channelId) + ) { + loadingOlder = true; + options.onScrollTop(); + } + + // Debounce virtual window updates to animation frames + if (scrollRafId === 0) { + scrollRafId = requestAnimationFrame(() => { + scrollRafId = 0; + renderWindow(); + }); + } + } + + // --------------------------------------------------------------------------- + // Mount / Destroy + // --------------------------------------------------------------------------- + + function mount(parentContainer: Element): void { + root = createElement("div", { class: "messages-container" }); + + topSpacer = createElement("div", { class: "virtual-spacer-top" }); + contentContainer = createElement("div", { class: "virtual-content" }); + bottomSpacer = createElement("div", { class: "virtual-spacer-bottom" }); + + root.appendChild(topSpacer); + root.appendChild(contentContainer); + root.appendChild(bottomSpacer); + + root.addEventListener("scroll", handleScroll, { + signal: ac.signal, + passive: true, + }); + + parentContainer.appendChild(root); + + renderAll(); + // Scroll to bottom on initial mount — use multiple deferred calls to handle + // layout shifts from images/embeds loading after the initial render. + scrollToBottom(); + requestAnimationFrame(() => scrollToBottom()); + setTimeout(() => scrollToBottom(), 100); + setTimeout(() => scrollToBottom(), 500); + + unsubscribers.push(messagesStore.subscribe(() => { renderAll(); })); + + // Only re-render when member roles change, not on typing updates + let prevMembers = membersStore.getState().members; + unsubscribers.push(membersStore.subscribe((state) => { + if (state.members !== prevMembers) { + prevMembers = state.members; + renderAll(); + } + })); + } + + function destroy(): void { + ac.abort(); + if (scrollRafId !== 0) { + cancelAnimationFrame(scrollRafId); + scrollRafId = 0; + } + unsubLoadingReset(); + for (const unsub of unsubscribers) { unsub(); } + unsubscribers.length = 0; + heightCache.clear(); + if (root !== null) { root.remove(); root = null; } + contentContainer = null; + topSpacer = null; + bottomSpacer = null; + } + + function scrollToMessage(messageId: number): boolean { + if (root === null) return false; + const idx = virtualItems.findIndex( + (item) => item.kind === "message" && item.message.id === messageId, + ); + if (idx === -1) return false; + + root.scrollTop = offsetBefore(idx); + renderWindow(); + + // Briefly highlight the target message element + if (contentContainer !== null) { + const localIdx = idx - renderedStart; + const el = contentContainer.children[localIdx] as HTMLElement | undefined; + if (el !== undefined) { + el.classList.add("highlight-flash"); + setTimeout(() => { el.classList.remove("highlight-flash"); }, 1500); + } + } + + return true; + } + + return { mount, destroy, scrollToMessage }; +} diff --git a/Client/tauri-client/src/components/PinnedMessages.ts b/Client/tauri-client/src/components/PinnedMessages.ts new file mode 100644 index 00000000..13fad368 --- /dev/null +++ b/Client/tauri-client/src/components/PinnedMessages.ts @@ -0,0 +1,95 @@ +/** + * PinnedMessages component — slide-out panel showing pinned messages + * for a channel with jump-to and unpin actions. + */ + +import { + createElement, + setText, + clearChildren, + appendChildren, +} from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export interface PinnedMessage { + readonly id: number; + readonly content: string; + readonly author: string; + readonly timestamp: string; +} + +export interface PinnedMessagesOptions { + readonly channelId: number; + readonly pinnedMessages: readonly PinnedMessage[]; + readonly onUnpin: (messageId: number) => void; + readonly onJumpToMessage: (messageId: number) => void; + readonly onClose: () => void; +} + +function renderPinnedItem( + msg: PinnedMessage, + options: PinnedMessagesOptions, + signal: AbortSignal, +): HTMLDivElement { + const item = createElement("div", { class: "pinned-msg" }); + item.dataset.messageId = String(msg.id); + + const author = createElement("div", { class: "pinned-msg__author" }, msg.author); + const content = createElement("div", { class: "pinned-msg__content" }, msg.content); + const time = createElement("div", { class: "pinned-msg__time" }, msg.timestamp); + + const actions = createElement("div", { class: "pinned-msg__actions" }); + const jumpBtn = createElement("button", {}, "Jump"); + const unpinBtn = createElement("button", {}, "Unpin"); + + jumpBtn.addEventListener("click", () => options.onJumpToMessage(msg.id), { signal }); + unpinBtn.addEventListener("click", () => options.onUnpin(msg.id), { signal }); + + appendChildren(actions, jumpBtn, unpinBtn); + appendChildren(item, author, content, time, actions); + return item; +} + +export function createPinnedMessages( + options: PinnedMessagesOptions, +): MountableComponent { + const ac = new AbortController(); + let root: HTMLDivElement | null = null; + + function mount(container: Element): void { + root = createElement("div", { class: "pinned-panel" }); + + const header = createElement("div", { class: "pinned-panel__header" }); + const title = createElement("h3", {}, "Pinned Messages"); + const closeBtn = createElement("button", { class: "pinned-panel__close" }, "\u00D7"); + closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + const list = createElement("div", { class: "pinned-panel__list" }); + const empty = createElement("div", { class: "pinned-panel__empty" }, "No pinned messages"); + + if (options.pinnedMessages.length === 0) { + empty.style.display = ""; + list.style.display = "none"; + } else { + empty.style.display = "none"; + list.style.display = ""; + for (const msg of options.pinnedMessages) { + list.appendChild(renderPinnedItem(msg, options, ac.signal)); + } + } + + appendChildren(root, header, list, empty); + container.appendChild(root); + } + + function destroy(): void { + ac.abort(); + if (root !== null) { + root.remove(); + root = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/QuickSwitcher.ts b/Client/tauri-client/src/components/QuickSwitcher.ts new file mode 100644 index 00000000..c8419e7e --- /dev/null +++ b/Client/tauri-client/src/components/QuickSwitcher.ts @@ -0,0 +1,193 @@ +// Step 8.60 — Quick switcher modal (Ctrl+K) for fast channel navigation. +// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. + +import { createElement, setText, appendChildren, clearChildren } from "@lib/dom"; +import { channelsStore } from "@stores/channels.store"; +import type { Channel } from "@stores/channels.store"; +import type { MountableComponent } from "@lib/safe-render"; + +export interface QuickSwitcherOptions { + readonly onSelectChannel: (channelId: number) => void; + readonly onClose: () => void; +} + +export function createQuickSwitcher(options: QuickSwitcherOptions): MountableComponent { + const ac = new AbortController(); + const signal = ac.signal; + + let root: HTMLDivElement | null = null; + let resultsDiv: HTMLDivElement; + let input: HTMLInputElement; + let activeIndex = 0; + let filteredChannels: readonly Channel[] = []; + let unsubscribe: (() => void) | null = null; + + function getChannelIcon(ch: Channel): string { + return ch.type === "voice" ? "\ud83d\udd0a" : "#"; + } + + function getFilteredChannels(query: string): readonly Channel[] { + const state = channelsStore.getState(); + const all = Array.from(state.channels.values()); + const sorted = [...all].sort((a, b) => a.position - b.position); + + if (query.length === 0) return sorted; + + const lower = query.toLowerCase(); + return sorted.filter((ch) => ch.name.toLowerCase().includes(lower)); + } + + function renderResults(): void { + clearChildren(resultsDiv); + activeIndex = Math.min(activeIndex, Math.max(0, filteredChannels.length - 1)); + + for (let i = 0; i < filteredChannels.length; i++) { + const ch = filteredChannels[i]!; + const isActive = i === activeIndex; + + const item = createElement("div", { + class: isActive + ? "quick-switcher__item quick-switcher__item--active" + : "quick-switcher__item", + "data-channelid": String(ch.id), + }); + + const icon = createElement("span", { class: "quick-switcher__icon" }, getChannelIcon(ch)); + const name = createElement("span", { class: "quick-switcher__name" }); + setText(name, ch.name); + + const parts: (Element | string)[] = [icon, name]; + + if (ch.category !== null) { + const category = createElement("span", { class: "quick-switcher__category" }); + setText(category, ch.category); + parts.push(category); + } + + appendChildren(item, ...parts); + + item.addEventListener("click", () => { + options.onSelectChannel(ch.id); + options.onClose(); + }, { signal }); + + resultsDiv.appendChild(item); + } + } + + function handleInput(): void { + const query = input.value.trim(); + filteredChannels = getFilteredChannels(query); + activeIndex = 0; + renderResults(); + } + + function handleKeydown(e: KeyboardEvent): void { + if (e.key === "Escape") { + e.preventDefault(); + options.onClose(); + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + if (filteredChannels.length > 0) { + activeIndex = (activeIndex + 1) % filteredChannels.length; + renderResults(); + } + return; + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + if (filteredChannels.length > 0) { + activeIndex = (activeIndex - 1 + filteredChannels.length) % filteredChannels.length; + renderResults(); + } + return; + } + + if (e.key === "Enter") { + e.preventDefault(); + const selected = filteredChannels[activeIndex]; + if (selected !== undefined) { + options.onSelectChannel(selected.id); + options.onClose(); + } + } + } + + function handleBackdropClick(e: MouseEvent): void { + if (e.target === root) { + options.onClose(); + } + } + + function handleGlobalKeydown(e: KeyboardEvent): void { + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + if (root !== null && root.parentNode !== null) { + options.onClose(); + } + } + } + + function refreshFromStore(): void { + const query = input?.value.trim() ?? ""; + filteredChannels = getFilteredChannels(query); + renderResults(); + } + + function mount(container: Element): void { + // Overlay backdrop + root = createElement("div", { + class: "quick-switcher-overlay", + style: "position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; justify-content: center; padding-top: 20vh;", + }); + + // Modal container + const modal = createElement("div", { class: "quick-switcher" }); + + // Search input + input = createElement("input", { + class: "quick-switcher__input", + type: "text", + placeholder: "Where do you want to go?", + }) as HTMLInputElement; + + // Results list + resultsDiv = createElement("div", { class: "quick-switcher__results" }); + + appendChildren(modal, input, resultsDiv); + root.appendChild(modal); + container.appendChild(root); + + // Initial render + filteredChannels = getFilteredChannels(""); + renderResults(); + + // Event listeners + input.addEventListener("input", handleInput, { signal }); + input.addEventListener("keydown", handleKeydown, { signal }); + root.addEventListener("click", handleBackdropClick, { signal }); + document.addEventListener("keydown", handleGlobalKeydown, { signal }); + + // Subscribe to store changes + unsubscribe = channelsStore.subscribe(refreshFromStore); + + // Auto-focus + requestAnimationFrame(() => input.focus()); + } + + function destroy(): void { + ac.abort(); + if (unsubscribe !== null) { + unsubscribe(); + unsubscribe = null; + } + root?.remove(); + root = null; + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/ServerBanner.ts b/Client/tauri-client/src/components/ServerBanner.ts new file mode 100644 index 00000000..c2652396 --- /dev/null +++ b/Client/tauri-client/src/components/ServerBanner.ts @@ -0,0 +1,62 @@ +/** + * ServerBanner component — top-of-app banner for server restart + * countdown and reconnecting state. + */ + +import { createElement, setText } from "@lib/dom"; + +export interface ServerBannerControl { + readonly element: HTMLDivElement; + showRestart(seconds: number): void; + showReconnecting(): void; + hide(): void; + destroy(): void; +} + +export function createServerBanner(): ServerBannerControl { + let intervalId: ReturnType | null = null; + + const root = createElement("div", { class: "reconnecting-banner" }); + + function clearCountdown(): void { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + } + + function showRestart(seconds: number): void { + clearCountdown(); + let remaining = seconds; + root.classList.add("visible"); + setText(root, `Server restarting in ${remaining} seconds...`); + + intervalId = setInterval(() => { + remaining -= 1; + if (remaining <= 0) { + clearCountdown(); + showReconnecting(); + return; + } + setText(root, `Server restarting in ${remaining} seconds...`); + }, 1000); + } + + function showReconnecting(): void { + clearCountdown(); + root.classList.add("visible"); + setText(root, "Reconnecting..."); + } + + function hide(): void { + clearCountdown(); + root.classList.remove("visible"); + } + + function destroy(): void { + clearCountdown(); + root.remove(); + } + + return { element: root, showRestart, showReconnecting, hide, destroy }; +} diff --git a/Client/tauri-client/src/components/ServerStrip.ts b/Client/tauri-client/src/components/ServerStrip.ts new file mode 100644 index 00000000..4118d478 --- /dev/null +++ b/Client/tauri-client/src/components/ServerStrip.ts @@ -0,0 +1,48 @@ +/** + * ServerStrip component — vertical strip on the far left showing server icons. + * Single-server for now: Home button, separator, add server button. + */ + +import { createElement, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export function createServerStrip(): MountableComponent { + const ac = new AbortController(); + let root: HTMLDivElement | null = null; + + function mount(container: Element): void { + root = createElement("div", { class: "server-strip", "data-testid": "server-strip" }); + + const homeIcon = createElement( + "div", + { class: "server-icon active", style: "background: var(--accent)" }, + "O", + ); + + const separator = createElement("div", { class: "server-separator" }); + + const addIcon = createElement("div", { class: "server-icon add" }, "+"); + + // Add server button click — placeholder for future multi-server support + addIcon.addEventListener( + "click", + () => { + // No-op for single-server mode + }, + { signal: ac.signal }, + ); + + appendChildren(root, homeIcon, separator, addIcon); + container.appendChild(root); + } + + function destroy(): void { + ac.abort(); + if (root !== null) { + root.remove(); + root = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/SettingsOverlay.ts b/Client/tauri-client/src/components/SettingsOverlay.ts new file mode 100644 index 00000000..af46ee1e --- /dev/null +++ b/Client/tauri-client/src/components/SettingsOverlay.ts @@ -0,0 +1,193 @@ +/** + * SettingsOverlay component — full-screen overlay with tabbed settings panels. + * Tabs: Account, Appearance, Notifications, Voice & Audio, Keybinds, Logs. + * Subscribes to uiStore for settingsOpen state. + */ + +import { createElement, appendChildren, clearChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import { uiStore } from "@stores/ui.store"; +import { loadPref, applyTheme } from "./settings/helpers"; +import type { ThemeName } from "./settings/helpers"; +import { buildAccountTab } from "./settings/AccountTab"; +import { buildAppearanceTab } from "./settings/AppearanceTab"; +import { buildNotificationsTab } from "./settings/NotificationsTab"; +import { buildVoiceAudioTab } from "./settings/VoiceAudioTab"; +import { buildKeybindsTab } from "./settings/KeybindsTab"; +import { createLogsTab } from "./settings/LogsTab"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface SettingsOverlayOptions { + onClose(): void; + onChangePassword(oldPassword: string, newPassword: string): Promise; + onUpdateProfile(username: string): Promise; + onLogout(): void; +} + +export type TabName = "Account" | "Appearance" | "Notifications" | "Voice & Audio" | "Keybinds" | "Logs"; + +const TAB_NAMES: readonly TabName[] = [ + "Account", + "Appearance", + "Notifications", + "Voice & Audio", + "Keybinds", + "Logs", +] as const; + +// --------------------------------------------------------------------------- +// Apply stored appearance (called at app startup) +// --------------------------------------------------------------------------- + +/** + * Apply stored appearance preferences (theme, font size, compact mode). + * Call at app startup so the UI doesn't flash default styles. + */ +export function applyStoredAppearance(): void { + applyTheme(loadPref("theme", "dark")); + document.documentElement.style.setProperty( + "--font-size", + `${loadPref("fontSize", 16)}px`, + ); + document.documentElement.classList.toggle( + "compact-mode", + loadPref("compactMode", false), + ); +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createSettingsOverlay( + options: SettingsOverlayOptions, +): MountableComponent & { open(): void; close(): void } { + const ac = new AbortController(); + let root: HTMLDivElement | null = null; + let contentArea: HTMLDivElement | null = null; + let activeTab: TabName = "Account"; + const tabButtons = new Map(); + let unsubUi: (() => void) | null = null; + + // Logs tab has stateful cleanup needs — create once via factory + const logsTab = createLogsTab(() => activeTab, ac.signal); + + // ---- Tab content builders ------------------------------------------------- + + const TAB_BUILDERS: Readonly HTMLDivElement>> = { + Account: () => buildAccountTab(options, ac.signal), + Appearance: () => buildAppearanceTab(ac.signal), + Notifications: () => buildNotificationsTab(ac.signal), + "Voice & Audio": () => buildVoiceAudioTab(ac.signal), + Keybinds: () => buildKeybindsTab(), + Logs: () => logsTab.build(), + }; + + // ---- Core methods --------------------------------------------------------- + + function renderActiveTab(): void { + if (contentArea === null) return; + clearChildren(contentArea); + const builder = TAB_BUILDERS[activeTab]; + contentArea.appendChild(builder()); + } + + function setActiveTab(tab: TabName): void { + if (tab === activeTab) return; + activeTab = tab; + for (const [name, btn] of tabButtons) { + btn.classList.toggle("active", name === tab); + } + renderActiveTab(); + } + + function show(): void { + root?.classList.add("open"); + } + + function hide(): void { + root?.classList.remove("open"); + } + + // ---- MountableComponent --------------------------------------------------- + + function mount(container: Element): void { + root = createElement("div", { class: "settings-overlay", "data-testid": "settings-overlay" }); + + // Sidebar + const sidebar = createElement("div", { class: "settings-sidebar" }); + const catLabel = createElement("div", { class: "settings-cat" }, "User Settings"); + sidebar.appendChild(catLabel); + for (const name of TAB_NAMES) { + const btn = createElement("button", { + class: `settings-nav-item${name === activeTab ? " active" : ""}`, + }, name); + btn.addEventListener("click", () => setActiveTab(name), { signal: ac.signal }); + tabButtons.set(name, btn); + sidebar.appendChild(btn); + } + + // Content + contentArea = createElement("div", { class: "settings-content" }); + + // Close button + const closeBtn = createElement("button", { class: "settings-close-btn" }, "\u00D7"); + closeBtn.addEventListener("click", () => { + options.onClose(); + }, { signal: ac.signal }); + + // Escape key + document.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Escape" && root?.classList.contains("open")) { + options.onClose(); + } + }, { signal: ac.signal }); + + appendChildren(root, sidebar, contentArea, closeBtn); + renderActiveTab(); + + // Subscribe to uiStore for open/close + unsubUi = uiStore.subscribe((state) => { + if (state.settingsOpen) { + show(); + } else { + hide(); + } + }); + + // Sync initial state + if (uiStore.getState().settingsOpen) { + show(); + } + + container.appendChild(root); + } + + function destroy(): void { + ac.abort(); + if (unsubUi !== null) { + unsubUi(); + unsubUi = null; + } + logsTab.cleanup(); + tabButtons.clear(); + if (root !== null) { + root.remove(); + root = null; + } + contentArea = null; + } + + function open(): void { + show(); + } + + function close(): void { + hide(); + } + + return { mount, destroy, open, close }; +} diff --git a/Client/tauri-client/src/components/Soundboard.ts b/Client/tauri-client/src/components/Soundboard.ts new file mode 100644 index 00000000..28f1fc8e --- /dev/null +++ b/Client/tauri-client/src/components/Soundboard.ts @@ -0,0 +1,117 @@ +/** + * Step 6.53 — Soundboard component. + * Grid of sound buttons with cooldown enforcement (1 play per 3s). + */ + +import { createElement, appendChildren, setText, clearChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface SoundItem { + readonly id: number; + readonly name: string; + readonly durationMs: number; +} + +export interface SoundboardOptions { + readonly sounds: readonly SoundItem[]; + readonly onPlaySound: (soundId: number) => void; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const COOLDOWN_MS = 3_000; + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createSoundboard(options: SoundboardOptions): MountableComponent { + let root: HTMLDivElement | null = null; + let cooldownTimer: ReturnType | null = null; + const ac = new AbortController(); + + function mount(container: Element): void { + root = createElement("div", { class: "soundboard" }); + + if (options.sounds.length === 0) { + const empty = createElement( + "div", + { class: "soundboard__empty" }, + "No sounds available", + ); + root.appendChild(empty); + container.appendChild(root); + return; + } + + const grid = createElement("div", { class: "soundboard__grid" }); + const buttons: HTMLButtonElement[] = []; + + for (const sound of options.sounds) { + const btn = createElement("button", { class: "sound-btn", type: "button" }); + const nameSpan = createElement("span", { class: "sound-btn__name" }, sound.name); + const durSpan = createElement( + "span", + { class: "sound-btn__duration" }, + formatDuration(sound.durationMs), + ); + + appendChildren(btn, nameSpan, durSpan); + + btn.addEventListener("click", () => { + if (btn.disabled) return; + options.onPlaySound(sound.id); + startCooldown(buttons); + }, { signal: ac.signal }); + + buttons.push(btn); + grid.appendChild(btn); + } + + root.appendChild(grid); + container.appendChild(root); + } + + function startCooldown(buttons: readonly HTMLButtonElement[]): void { + for (const btn of buttons) { + btn.disabled = true; + btn.classList.add("sound-btn--cooldown"); + } + + if (cooldownTimer !== null) { + clearTimeout(cooldownTimer); + } + + cooldownTimer = setTimeout(() => { + cooldownTimer = null; + for (const btn of buttons) { + btn.disabled = false; + btn.classList.remove("sound-btn--cooldown"); + } + }, COOLDOWN_MS); + } + + function destroy(): void { + ac.abort(); + if (cooldownTimer !== null) { + clearTimeout(cooldownTimer); + cooldownTimer = null; + } + if (root !== null) { + root.remove(); + root = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/Toast.ts b/Client/tauri-client/src/components/Toast.ts new file mode 100644 index 00000000..d5346524 --- /dev/null +++ b/Client/tauri-client/src/components/Toast.ts @@ -0,0 +1,115 @@ +/** + * Toast notification system — shows stacking notifications at bottom-right. + * Supports info, error, and success types with auto-dismiss. + */ + +import { createElement, setText } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export type ToastType = "info" | "error" | "success"; + +const MAX_TOASTS = 5; +const DEFAULT_DURATION_MS = 5000; + +interface ToastEntry { + readonly el: HTMLDivElement; + readonly timer: ReturnType; +} + +export interface ToastContainer extends MountableComponent { + show(message: string, type?: ToastType, durationMs?: number): void; + clear(): void; +} + +export function createToastContainer(): ToastContainer { + let root: HTMLDivElement | null = null; + const toasts: ToastEntry[] = []; + + function removeToast(entry: ToastEntry): void { + const idx = toasts.indexOf(entry); + if (idx === -1) return; + + clearTimeout(entry.timer); + toasts.splice(idx, 1); + + // Remove .show first and wait for the CSS opacity transition to finish + entry.el.classList.remove("show"); + entry.el.addEventListener( + "transitionend", + () => { + if (entry.el.parentNode !== null) { + entry.el.remove(); + } + }, + { once: true }, + ); + + // Fallback removal in case transitionend never fires + setTimeout(() => { + if (entry.el.parentNode !== null) { + entry.el.remove(); + } + }, 400); + } + + function show( + message: string, + type: ToastType = "info", + durationMs: number = DEFAULT_DURATION_MS, + ): void { + if (root === null) return; + + // Evict oldest toasts when at capacity + while (toasts.length >= MAX_TOASTS) { + const oldest = toasts[0]; + if (oldest !== undefined) { + removeToast(oldest); + } + } + + const el = createElement("div", { + class: `toast toast-${type}`, + "data-testid": "toast", + }); + setText(el, message); + + const timer = setTimeout(() => { + const entry = toasts.find((t) => t.el === el); + if (entry !== undefined) { + removeToast(entry); + } + }, durationMs); + + const entry: ToastEntry = { el, timer }; + toasts.push(entry); + root.appendChild(el); + + // Trigger .show on the next frame so the CSS opacity transition plays + requestAnimationFrame(() => { + requestAnimationFrame(() => { + el.classList.add("show"); + }); + }); + } + + function clear(): void { + for (const entry of [...toasts]) { + removeToast(entry); + } + } + + function mount(container: Element): void { + root = createElement("div", { class: "toast-container", "data-testid": "toast-container" }); + container.appendChild(root); + } + + function destroy(): void { + clear(); + if (root !== null) { + root.remove(); + root = null; + } + } + + return { mount, destroy, show, clear }; +} diff --git a/Client/tauri-client/src/components/TypingIndicator.ts b/Client/tauri-client/src/components/TypingIndicator.ts new file mode 100644 index 00000000..ae552916 --- /dev/null +++ b/Client/tauri-client/src/components/TypingIndicator.ts @@ -0,0 +1,83 @@ +/** + * Step 5.43 — TypingIndicator component. + * Subscribes to membersStore and displays who is typing in a channel. + * Uses mockup's .typing-bar and .typing-dots classes. + */ + +import { createElement, appendChildren, setText, clearChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import { membersStore, getTypingUsers } from "@stores/members.store"; +import type { Member } from "@stores/members.store"; + +export interface TypingIndicatorOptions { + readonly channelId: number; + readonly currentUserId: number; +} + +function formatTypingText(users: readonly Member[]): string { + if (users.length === 1) { + return `${users[0]?.username ?? "Someone"} is typing...`; + } + if (users.length === 2) { + return `${users[0]?.username ?? "Someone"} and ${users[1]?.username ?? "Someone"} are typing...`; + } + return "Several people are typing..."; +} + +export function createTypingIndicator( + options: TypingIndicatorOptions, +): MountableComponent { + let root: HTMLDivElement | null = null; + let unsubscribe: (() => void) | null = null; + + function updateFromState(): void { + if (root === null) return; + + const allTyping = getTypingUsers(options.channelId); + const filtered = allTyping.filter((u) => u.id !== options.currentUserId); + + clearChildren(root); + + if (filtered.length > 0) { + // Animated dots + const dots = createElement("span", { class: "typing-dots" }); + appendChildren( + dots, + createElement("span", {}), + createElement("span", {}), + createElement("span", {}), + ); + root.appendChild(dots); + + // Text + const textNode = document.createTextNode(` ${formatTypingText(filtered)}`); + root.appendChild(textNode); + } + // When empty, .typing-bar:empty CSS rule hides it (height: 0) + } + + function mount(container: Element): void { + root = createElement("div", { class: "typing-bar" }); + + updateFromState(); + + unsubscribe = membersStore.subscribe(() => { + updateFromState(); + }); + + container.appendChild(root); + } + + function destroy(): void { + if (unsubscribe !== null) { + unsubscribe(); + unsubscribe = null; + } + if (root !== null) { + root.remove(); + root = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/UpdateNotifier.ts b/Client/tauri-client/src/components/UpdateNotifier.ts new file mode 100644 index 00000000..37b4232f --- /dev/null +++ b/Client/tauri-client/src/components/UpdateNotifier.ts @@ -0,0 +1,101 @@ +// UpdateNotifier — shows a non-modal banner when a client update is available. +// Mounts at the top of the main page and allows the user to update or dismiss. + +import { createElement, appendChildren } from "@lib/dom"; +import { createLogger } from "@lib/logger"; +import { checkForUpdate, downloadAndInstallUpdate } from "@lib/updater"; +import type { MountableComponent } from "@lib/safe-render"; + +const log = createLogger("update-notifier"); + +export interface UpdateNotifierOptions { + readonly serverUrl: string; +} + +export function createUpdateNotifier(options: UpdateNotifierOptions): MountableComponent { + const { serverUrl } = options; + let container: Element | null = null; + let banner: HTMLDivElement | null = null; + let dismissed = false; + + async function performCheck(): Promise { + if (dismissed) return; + + const result = await checkForUpdate(serverUrl); + if (!result.available || result.version === null) return; + + showBanner(result.version, result.body ?? ""); + } + + function showBanner(version: string, notes: string): void { + if (container === null || banner !== null) return; + + banner = createElement("div", { class: "update-banner" }); + + const text = createElement("span", { class: "update-banner-text" }, + `Update v${version} available`); + + const updateBtn = createElement("button", { class: "update-banner-btn update-banner-install" }, + "Update Now"); + updateBtn.addEventListener("click", () => { + void installUpdate(); + }); + + const laterBtn = createElement("button", { class: "update-banner-btn update-banner-later" }, + "Later"); + laterBtn.addEventListener("click", () => { + dismissed = true; + removeBanner(); + }); + + appendChildren(banner, text, updateBtn, laterBtn); + container.prepend(banner); + } + + async function installUpdate(): Promise { + if (banner === null) return; + + // Replace banner content with progress indicator + while (banner.firstChild) banner.removeChild(banner.firstChild); + const progress = createElement("span", { class: "update-banner-text" }, + "Downloading update..."); + banner.appendChild(progress); + + try { + await downloadAndInstallUpdate(serverUrl); + // App will relaunch — this code won't execute after relaunch() + } catch (err) { + log.error("Update install failed", { error: String(err) }); + while (banner.firstChild) banner.removeChild(banner.firstChild); + const errorText = createElement("span", { class: "update-banner-text" }, + "Update failed. Please try again later."); + const dismissBtn = createElement("button", { class: "update-banner-btn update-banner-later" }, + "Dismiss"); + dismissBtn.addEventListener("click", () => { + dismissed = true; + removeBanner(); + }); + appendChildren(banner, errorText, dismissBtn); + } + } + + function removeBanner(): void { + if (banner !== null) { + banner.remove(); + banner = null; + } + } + + function mount(target: Element): void { + container = target; + // Delay the check slightly so the main UI renders first + setTimeout(() => { void performCheck(); }, 3000); + } + + function destroy(): void { + removeBanner(); + container = null; + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/UserBar.ts b/Client/tauri-client/src/components/UserBar.ts new file mode 100644 index 00000000..e0a210b1 --- /dev/null +++ b/Client/tauri-client/src/components/UserBar.ts @@ -0,0 +1,108 @@ +/** + * UserBar component — shows current user info at the bottom of the sidebar. + * Subscribes to authStore for user data. Settings button opens settings overlay. + */ + +import { createElement, appendChildren, setText } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import { authStore } from "@stores/auth.store"; +import { openSettings } from "@stores/ui.store"; + +export type UserBarOptions = Record; + +export function createUserBar(options?: UserBarOptions): MountableComponent { + const ac = new AbortController(); + let root: HTMLDivElement | null = null; + let unsubscribe: (() => void) | null = null; + + // Element references for targeted updates + let avatarEl: HTMLDivElement | null = null; + let avatarTextEl: HTMLSpanElement | null = null; + let nameEl: HTMLSpanElement | null = null; + let statusEl: HTMLSpanElement | null = null; + + function updateFromState(): void { + const state = authStore.getState(); + const user = state.user; + const username = user?.username ?? "Unknown"; + const initial = username.charAt(0).toUpperCase() || "?"; + + if (avatarTextEl !== null) { + setText(avatarTextEl, initial); + } + if (nameEl !== null) { + setText(nameEl, username); + } + if (statusEl !== null) { + setText(statusEl, state.isAuthenticated ? "Online" : "Offline"); + } + } + + function mount(container: Element): void { + root = createElement("div", { class: "user-bar", "data-testid": "user-bar" }); + + avatarEl = createElement( + "div", + { class: "ub-avatar", style: "background: var(--accent); position: relative;" }, + ); + avatarTextEl = createElement("span", {}); + avatarEl.appendChild(avatarTextEl); + const statusDot = createElement("div", { + class: "status-dot", + style: "background: var(--green); width: 10px; height: 10px; border-radius: 50%; position: absolute; bottom: 0; right: 0;", + }); + avatarEl.appendChild(statusDot); + + const info = createElement("div", { class: "ub-info" }); + nameEl = createElement("span", { class: "ub-name", "data-testid": "user-bar-name" }); + statusEl = createElement("span", { class: "ub-status" }); + appendChildren(info, nameEl, statusEl); + + const buttons = createElement("div", { class: "ub-controls" }); + + const settingsBtn = createElement( + "button", + { title: "Settings", "aria-label": "Settings" }, + "\u2699", + ); + + settingsBtn.addEventListener( + "click", + () => { + openSettings(); + }, + { signal: ac.signal }, + ); + + buttons.appendChild(settingsBtn); + appendChildren(root, avatarEl, info, buttons); + + // Initial render + updateFromState(); + + // Subscribe to auth changes + unsubscribe = authStore.subscribe(() => { + updateFromState(); + }); + + container.appendChild(root); + } + + function destroy(): void { + ac.abort(); + if (unsubscribe !== null) { + unsubscribe(); + unsubscribe = null; + } + if (root !== null) { + root.remove(); + root = null; + } + avatarEl = null; + avatarTextEl = null; + nameEl = null; + statusEl = null; + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/VoiceChannel.ts b/Client/tauri-client/src/components/VoiceChannel.ts new file mode 100644 index 00000000..3f82ed87 --- /dev/null +++ b/Client/tauri-client/src/components/VoiceChannel.ts @@ -0,0 +1,234 @@ +/** + * VoiceChannel component — renders a voice channel item with connected users. + * Returns an HTMLDivElement (not a MountableComponent). + * Step 6.51 + */ + +import { createElement, appendChildren, clearChildren, setText } from "@lib/dom"; +import { voiceStore } from "@stores/voice.store"; +import type { VoiceUser } from "@stores/voice.store"; +import { membersStore } from "@stores/members.store"; +import { setUserVolume, getUserVolume } from "@lib/voiceSession"; +import { authStore } from "@stores/auth.store"; + +export interface VoiceChannelOptions { + channelId: number; + channelName: string; + onJoin(): void; +} + +export interface VoiceChannelResult { + element: HTMLDivElement; + update(): void; + destroy(): void; +} + +const AVATAR_COLORS = ["#5865f2", "#57f287", "#fee75c", "#eb459e", "#ed4245"]; + +function pickAvatarColor(username: string): string { + let hash = 0; + for (let i = 0; i < username.length; i++) { + hash = (hash * 31 + username.charCodeAt(i)) | 0; + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] ?? "#5865f2"; +} + +export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelResult { + const ac = new AbortController(); + const unsubs: Array<() => void> = []; + + // Wrapper div to hold the channel-item and voice-users-list as siblings + const root = createElement("div"); + + // Channel item row (same structure as text channels) + const channelItem = createElement("div", { class: "channel-item voice" }); + const icon = createElement("span", { class: "ch-icon" }, "\uD83D\uDD0A"); + const nameEl = createElement("span", { class: "ch-name" }, options.channelName); + appendChildren(channelItem, icon, nameEl); + + // Users container + const usersContainer = createElement("div", { class: "voice-users-list" }); + + appendChildren(root, channelItem, usersContainer); + + // Click to join + channelItem.addEventListener("click", options.onJoin, { signal: ac.signal }); + + // Track active context menu for cleanup + let activeCtxMenu: HTMLDivElement | null = null; + let menuDismissAc: AbortController | null = null; + + function closeContextMenu(): void { + if (menuDismissAc !== null) { + menuDismissAc.abort(); + menuDismissAc = null; + } + if (activeCtxMenu !== null) { + activeCtxMenu.remove(); + activeCtxMenu = null; + } + } + + function showVolumeMenu(userId: number, username: string, x: number, y: number): void { + closeContextMenu(); + + const menu = createElement("div", { class: "context-menu" }); + + // Header + const header = createElement("div", { + class: "context-menu-item", + style: "font-weight:600;cursor:default;pointer-events:none", + }, username); + menu.appendChild(header); + + const sep = createElement("div", { class: "context-menu-sep" }); + menu.appendChild(sep); + + // Volume label + const currentVol = getUserVolume(userId); + const volLabel = createElement("div", { + class: "context-menu-item", + style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none", + }, `User Volume: ${currentVol}%`); + menu.appendChild(volLabel); + + // Volume slider (0-200%, like Discord) + const sliderRow = createElement("div", { + style: "padding:4px 10px;display:flex;align-items:center;gap:8px", + }); + const slider = createElement("input", { + type: "range", + class: "settings-slider", + min: "0", + max: "200", + value: String(currentVol), + style: "flex:1", + }); + const valLabel = createElement("span", { + class: "slider-val", + style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)", + }, `${currentVol}%`); + + slider.addEventListener("input", () => { + const val = Number(slider.value); + setText(valLabel, `${val}%`); + setText(volLabel, `User Volume: ${val}%`); + setUserVolume(userId, val); + }); + + appendChildren(sliderRow, slider, valLabel); + menu.appendChild(sliderRow); + + // Reset button + const resetBtn = createElement("div", { class: "context-menu-item" }, "Reset Volume"); + resetBtn.addEventListener("click", () => { + setUserVolume(userId, 100); + slider.value = "100"; + setText(valLabel, "100%"); + setText(volLabel, "User Volume: 100%"); + }); + menu.appendChild(resetBtn); + + // Position and show + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + document.body.appendChild(menu); + activeCtxMenu = menu; + + // Close on click outside — uses AbortController so cleanup on destroy works + menuDismissAc = new AbortController(); + const dismissSignal = menuDismissAc.signal; + setTimeout(() => { + document.addEventListener("mousedown", (e: MouseEvent) => { + if (!menu.contains(e.target as Node)) { + closeContextMenu(); + } + }, { signal: dismissSignal }); + }, 0); + } + + function createUserRow(user: VoiceUser, username: string): HTMLDivElement { + const classes = user.speaking + ? "voice-user-item speaking" + : "voice-user-item"; + const row = createElement("div", { class: classes }); + + const initial = username.length > 0 ? username.charAt(0).toUpperCase() : "?"; + const color = pickAvatarColor(username); + const avatar = createElement("div", { class: "vu-avatar" }, initial); + avatar.style.background = color; + row.appendChild(avatar); + + const name = createElement("span", { class: "vu-name" }, username); + row.appendChild(name); + + if (user.muted || user.deafened) { + const mutedIcon = user.deafened ? "\uD83D\uDD08" : "\uD83D\uDD07"; + const mutedEl = createElement("span", { class: "vu-muted" }, mutedIcon); + row.appendChild(mutedEl); + } + + // Right-click for per-user volume (skip for own user) + const currentUser = authStore.getState().user; + if (currentUser === null || currentUser.id !== user.userId) { + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + showVolumeMenu(user.userId, username, e.clientX, e.clientY); + }, { signal: ac.signal }); + } + + return row; + } + + // Track previous Map reference to skip unnecessary re-renders + let prevChannelUsers: ReadonlyMap | undefined; + let prevMembers: ReadonlyMap | undefined; + + function update(): void { + const channelUsers = voiceStore.getState().voiceUsers.get(options.channelId); + const members = membersStore.getState().members; + + // Skip re-render if neither the channel's user map nor members changed + if (channelUsers === prevChannelUsers && members === prevMembers) return; + prevChannelUsers = channelUsers; + prevMembers = members; + + clearChildren(usersContainer); + + if (channelUsers === undefined) { + channelItem.classList.remove("active"); + return; + } + + for (const user of channelUsers.values()) { + const member = members.get(user.userId); + const username = (member as { username?: string } | undefined)?.username ?? "Unknown"; + const row = createUserRow(user, username); + usersContainer.appendChild(row); + } + + // Mark channel-item active if there are users + if (channelUsers.size > 0) { + channelItem.classList.add("active"); + } else { + channelItem.classList.remove("active"); + } + } + + // Initial render and subscribe + update(); + unsubs.push(voiceStore.subscribe(() => update())); + unsubs.push(membersStore.subscribe(() => update())); + + function destroy(): void { + closeContextMenu(); + ac.abort(); + for (const unsub of unsubs) { + unsub(); + } + unsubs.length = 0; + } + + return { element: root, update, destroy }; +} diff --git a/Client/tauri-client/src/components/VoiceWidget.ts b/Client/tauri-client/src/components/VoiceWidget.ts new file mode 100644 index 00000000..70627455 --- /dev/null +++ b/Client/tauri-client/src/components/VoiceWidget.ts @@ -0,0 +1,108 @@ +/** + * VoiceWidget component — shows active voice channel info with controls. + * Hidden when not connected to a voice channel. + * Users are displayed under the voice channel in the sidebar, NOT here. + * Step 6.50 + */ + +import { createElement, appendChildren, setText } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import { voiceStore } from "@stores/voice.store"; +import { channelsStore } from "@stores/channels.store"; + +export interface VoiceWidgetOptions { + onDisconnect(): void; + onMuteToggle(): void; + onDeafenToggle(): void; + onCameraToggle(): void; + onScreenshareToggle(): void; +} + +export function createVoiceWidget(options: VoiceWidgetOptions): MountableComponent { + const ac = new AbortController(); + let root: HTMLDivElement | null = null; + let channelNameEl: HTMLSpanElement | null = null; + let muteBtn: HTMLButtonElement | null = null; + let deafenBtn: HTMLButtonElement | null = null; + + const unsubs: Array<() => void> = []; + + function render(): void { + if (root === null || channelNameEl === null) return; + + const voice = voiceStore.getState(); + const channelId = voice.currentChannelId; + + if (channelId === null) { + root.classList.remove("visible"); + return; + } + + root.classList.add("visible"); + + // Channel name + const channel = channelsStore.getState().channels.get(channelId); + setText(channelNameEl, channel?.name ?? "Voice Channel"); + + // Toggle button active states + muteBtn?.classList.toggle("active-ctrl", voice.localMuted); + deafenBtn?.classList.toggle("active-ctrl", voice.localDeafened); + } + + function createControlButton( + label: string, + icon: string, + handler: () => void, + extraClass?: string, + ): HTMLButtonElement { + const btn = createElement("button", { + class: extraClass ?? "", + "aria-label": label, + }, icon); + btn.addEventListener("click", handler, { signal: ac.signal }); + return btn; + } + + function mount(container: Element): void { + root = createElement("div", { class: "voice-widget", "data-testid": "voice-widget" }); + + const header = createElement("div", { class: "vw-header" }); + const connLabel = createElement("span", { class: "vw-connected" }, "Voice Connected"); + channelNameEl = createElement("span", { class: "vw-channel" }, "Voice Channel"); + appendChildren(header, connLabel, channelNameEl); + + const controls = createElement("div", { class: "vw-controls" }); + muteBtn = createControlButton("Mute", "\uD83C\uDFA4", options.onMuteToggle); + deafenBtn = createControlButton("Deafen", "\uD83C\uDFA7", options.onDeafenToggle); + const cameraBtn = createControlButton("Camera", "\uD83D\uDCF7", options.onCameraToggle); + const shareBtn = createControlButton("Screenshare", "\uD83D\uDDA5", options.onScreenshareToggle); + const disconnectBtn = createControlButton( + "Disconnect", "\u260E", options.onDisconnect, "disconnect", + ); + appendChildren(controls, muteBtn, deafenBtn, cameraBtn, shareBtn, disconnectBtn); + + appendChildren(root, header, controls); + + render(); + + unsubs.push(voiceStore.subscribe(() => render())); + unsubs.push(channelsStore.subscribe(() => render())); + + container.appendChild(root); + } + + function destroy(): void { + ac.abort(); + for (const unsub of unsubs) { + unsub(); + } + unsubs.length = 0; + root?.remove(); + root = null; + channelNameEl = null; + muteBtn = null; + deafenBtn = null; + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/message-list/renderers.ts b/Client/tauri-client/src/components/message-list/renderers.ts new file mode 100644 index 00000000..d78560be --- /dev/null +++ b/Client/tauri-client/src/components/message-list/renderers.ts @@ -0,0 +1,1066 @@ +/** + * Message rendering helpers — pure DOM builders for messages, day dividers, + * reactions, attachments, and content parsing. XSS-safe (no innerHTML). + */ + +import { + createElement, + setText, + appendChildren, +} from "@lib/dom"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeFile } from "@tauri-apps/plugin-fs"; +import type { Attachment } from "@lib/types"; +import type { Message } from "@stores/messages.store"; +import { membersStore } from "@stores/members.store"; +import type { MessageListOptions } from "../MessageList"; + +/** Module-level server host for resolving relative attachment URLs. */ +let _serverHost: string | null = null; + +/** Set the server host (called once from MainPage on connect). */ +export function setServerHost(host: string): void { + _serverHost = host; +} + +/** Resolve a potentially relative URL to a full URL using the server host. */ +function resolveServerUrl(url: string): string { + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + if (_serverHost !== null) { + return `https://${_serverHost}${url}`; + } + return url; +} + +// -- Constants ---------------------------------------------------------------- + +export const GROUP_THRESHOLD_MS = 5 * 60 * 1000; + +const MENTION_REGEX = /@(\w+)/g; +const CODE_BLOCK_REGEX = /```([\s\S]*?)```/g; +const INLINE_CODE_REGEX = /`([^`]+)`/g; +const URL_REGEX = /https?:\/\/[^\s<>"']+/g; + +// -- Formatting helpers ------------------------------------------------------- + +export function formatTime(iso: string): string { + const d = new Date(iso); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +export function formatFullDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export function isSameDay(a: string, b: string): boolean { + const da = new Date(a); + const db = new Date(b); + return ( + da.getFullYear() === db.getFullYear() && + da.getMonth() === db.getMonth() && + da.getDate() === db.getDate() + ); +} + +export function shouldGroup(prev: Message, curr: Message): boolean { + if (prev.user.id !== curr.user.id) return false; + if (prev.deleted || curr.deleted) return false; + const dt = new Date(curr.timestamp).getTime() - new Date(prev.timestamp).getTime(); + return dt < GROUP_THRESHOLD_MS; +} + +function getUserRole(userId: number): string { + return membersStore.getState().members.get(userId)?.role ?? "member"; +} + +function roleColorVar(role: string): string { + switch (role) { + case "owner": return "var(--role-owner)"; + case "admin": return "var(--role-admin)"; + case "moderator": return "var(--role-mod)"; + default: return "var(--role-member)"; + } +} + +// -- Content parsing (XSS-safe, no innerHTML) --------------------------------- + +function renderInlineContent(text: string): DocumentFragment { + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + for (const match of text.matchAll(INLINE_CODE_REGEX)) { + const idx = match.index; + if (idx === undefined) continue; + if (idx > lastIndex) { + fragment.appendChild(renderMentions(text.slice(lastIndex, idx))); + } + const code = createElement("code", {}); + setText(code, match[1]!); + fragment.appendChild(code); + lastIndex = idx + match[0].length; + } + if (lastIndex < text.length) { + fragment.appendChild(renderMentions(text.slice(lastIndex))); + } + return fragment; +} + +export function renderMentions(text: string): DocumentFragment { + // First pass: split by URLs, then handle mentions in non-URL segments + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + for (const match of text.matchAll(URL_REGEX)) { + const idx = match.index; + if (idx === undefined) continue; + if (idx > lastIndex) { + fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx))); + } + const url = match[0]; + if (isSafeUrl(url)) { + const link = createElement("a", { + class: "msg-link", + href: url, + target: "_blank", + rel: "noopener noreferrer", + }); + setText(link, url); + fragment.appendChild(link); + } else { + fragment.appendChild(document.createTextNode(url)); + } + lastIndex = idx + match[0].length; + } + if (lastIndex < text.length) { + fragment.appendChild(renderMentionSegment(text.slice(lastIndex))); + } + return fragment; +} + +/** Render @mentions within a text segment (no URLs). */ +function renderMentionSegment(text: string): DocumentFragment { + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + for (const match of text.matchAll(MENTION_REGEX)) { + const idx = match.index; + if (idx === undefined) continue; + if (idx > lastIndex) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex, idx))); + } + const span = createElement("span", { class: "mention" }); + setText(span, match[0]); + fragment.appendChild(span); + lastIndex = idx + match[0].length; + } + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex))); + } + return fragment; +} + +function renderMessageContent(content: string): DocumentFragment { + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + for (const match of content.matchAll(CODE_BLOCK_REGEX)) { + const idx = match.index; + if (idx === undefined) continue; + if (idx > lastIndex) { + const text = createElement("div", { class: "msg-text" }); + text.appendChild(renderInlineContent(content.slice(lastIndex, idx))); + fragment.appendChild(text); + } + const codeBlock = createElement("div", { class: "msg-codeblock" }); + setText(codeBlock, match[1]!.trim()); + fragment.appendChild(codeBlock); + lastIndex = idx + match[0].length; + } + if (lastIndex === 0) { + const text = createElement("div", { class: "msg-text" }); + text.appendChild(renderInlineContent(content)); + fragment.appendChild(text); + } else if (lastIndex < content.length) { + const remaining = content.slice(lastIndex).trim(); + if (remaining.length > 0) { + const text = createElement("div", { class: "msg-text" }); + text.appendChild(renderInlineContent(remaining)); + fragment.appendChild(text); + } + } + return fragment; +} + +// -- URL embed rendering ------------------------------------------------------ + +/** Extract YouTube video ID from various YouTube URL formats. */ +function extractYouTubeId(url: string): string | null { + try { + const parsed = new URL(url); + // youtube.com/watch?v=ID + if ( + (parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") && + parsed.pathname === "/watch" + ) { + return parsed.searchParams.get("v"); + } + // youtu.be/ID + if (parsed.hostname === "youtu.be") { + const id = parsed.pathname.slice(1); + return id.length > 0 ? id : null; + } + // youtube.com/embed/ID + if ( + (parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") && + parsed.pathname.startsWith("/embed/") + ) { + const id = parsed.pathname.slice(7); + return id.length > 0 ? id : null; + } + // youtube.com/shorts/ID + if ( + (parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") && + parsed.pathname.startsWith("/shorts/") + ) { + const id = parsed.pathname.slice(8); + return id.length > 0 ? id : null; + } + } catch { + // Invalid URL + } + return null; +} + +/** Cache for YouTube video titles to avoid re-fetching on every re-render. */ +const ytTitleCache = new Map(); + +/** Render a YouTube embed player with title header. */ +function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDivElement { + const wrap = createElement("div", { class: "msg-embed msg-embed-youtube" }); + + // Header: channel name + video title + const header = createElement("div", { class: "msg-embed-yt-header" }); + const channelLabel = createElement("div", { class: "msg-embed-host" }, "YouTube"); + const titleLink = createElement("a", { + class: "msg-embed-yt-title", + href: originalUrl, + target: "_blank", + rel: "noopener noreferrer", + }); + + const cached = ytTitleCache.get(videoId); + if (cached !== undefined) { + setText(titleLink, cached); + } else { + setText(titleLink, "Loading..."); + const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; + fetch(oembedUrl) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { title?: string } | null) => { + const title = data?.title ?? "YouTube Video"; + ytTitleCache.set(videoId, title); + setText(titleLink, title); + }) + .catch(() => { + ytTitleCache.set(videoId, "YouTube Video"); + setText(titleLink, "YouTube Video"); + }); + } + + appendChildren(header, channelLabel, titleLink); + wrap.appendChild(header); + + // Thumbnail container with play button overlay + const thumbWrap = createElement("div", { class: "msg-embed-yt-player" }); + const thumbUrl = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`; + const thumb = createElement("img", { + class: "msg-embed-thumb", + src: thumbUrl, + alt: "YouTube video", + loading: "lazy", + }); + + const playBtn = createElement("div", { class: "msg-embed-play" }, "\u25B6"); + + appendChildren(thumbWrap, thumb, playBtn); + wrap.appendChild(thumbWrap); + + // On click thumbnail, replace with iframe player + thumbWrap.addEventListener("click", () => { + const iframe = document.createElement("iframe"); + iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`; + iframe.setAttribute("allowfullscreen", ""); + iframe.setAttribute("allow", "autoplay; encrypted-media"); + iframe.className = "msg-embed-iframe"; + thumbWrap.replaceChildren(iframe); + }, { once: true }); + + return wrap; +} + +/** Extract all URLs from a message content string. */ +function extractUrls(content: string): string[] { + // Skip URLs inside code blocks + const withoutCodeBlocks = content.replace(CODE_BLOCK_REGEX, "").replace(INLINE_CODE_REGEX, ""); + const matches = withoutCodeBlocks.match(URL_REGEX); + return matches ?? []; +} + +/** Render URL embeds (YouTube players, generic link previews). */ +function renderUrlEmbeds(content: string): DocumentFragment { + const fragment = document.createDocumentFragment(); + const urls = extractUrls(content); + const seen = new Set(); + + for (const url of urls) { + if (seen.has(url)) continue; + seen.add(url); + + // YouTube embed + const ytId = extractYouTubeId(url); + if (ytId !== null) { + fragment.appendChild(renderYouTubeEmbed(ytId, url)); + continue; + } + + // Generic URL preview (compact link card) + if (isSafeUrl(url)) { + fragment.appendChild(renderGenericLinkPreview(url)); + } + } + + return fragment; +} + +/** Open Graph metadata extracted from a page. */ +interface OgMeta { + readonly title: string | null; + readonly description: string | null; + readonly image: string | null; + readonly siteName: string | null; +} + +/** Cache for OG metadata to avoid re-fetching on re-render. */ +const ogCache = new Map(); +/** URLs currently being fetched (prevents duplicate requests). */ +const ogInFlight = new Set(); + +/** Extract Open Graph meta tags from raw HTML using regex (no DOM parser needed). */ +function parseOgTags(html: string): OgMeta { + function getMetaContent(property: string): string | null { + // Match both property="og:X" and name="og:X" patterns + const regex = new RegExp( + `]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']` + + `|]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${property}["']`, + "i", + ); + const match = html.match(regex); + if (match !== null) { + return match[1] ?? match[2] ?? null; + } + return null; + } + + // Fallback: extract tag if no og:title + function getTitle(): string | null { + const og = getMetaContent("og:title"); + if (og !== null) return og; + const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i); + return titleMatch?.[1]?.trim() ?? null; + } + + // Fallback: extract meta description if no og:description + function getDescription(): string | null { + const og = getMetaContent("og:description"); + if (og !== null) return og; + return getMetaContent("description"); + } + + return { + title: getTitle(), + description: getDescription(), + image: getMetaContent("og:image"), + siteName: getMetaContent("og:site_name"), + }; +} + +/** Fetch OG metadata for a URL using the Tauri native HTTP client (no CORS). */ +async function fetchOgMeta(url: string): Promise<OgMeta> { + const cached = ogCache.get(url); + if (cached !== undefined) return cached; + + // Return empty while in-flight to avoid duplicate requests + if (ogInFlight.has(url)) { + return { title: null, description: null, image: null, siteName: null }; + } + + ogInFlight.add(url); + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + const res = await tauriFetch(url, { + signal: controller.signal, + headers: { "User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" }, + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + } as RequestInit); + clearTimeout(timer); + + if (!res.ok) { + const empty: OgMeta = { title: null, description: null, image: null, siteName: null }; + ogCache.set(url, empty); + return empty; + } + + // Only parse HTML responses (skip binary, JSON, etc.) + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("text/html")) { + const empty: OgMeta = { title: null, description: null, image: null, siteName: null }; + ogCache.set(url, empty); + return empty; + } + + const html = await res.text(); + // Only parse the first 50KB to avoid parsing huge pages + const meta = parseOgTags(html.slice(0, 50_000)); + ogCache.set(url, meta); + return meta; + } catch { + const empty: OgMeta = { title: null, description: null, image: null, siteName: null }; + ogCache.set(url, empty); + return empty; + } finally { + ogInFlight.delete(url); + } +} + +/** Render a link preview card with OG metadata (title, description, image). */ +function renderGenericLinkPreview(url: string): HTMLDivElement { + const wrap = createElement("div", { class: "msg-embed msg-embed-link" }); + + let displayHost = ""; + try { + displayHost = new URL(url).hostname; + } catch { + displayHost = url; + } + + const content = createElement("div", { class: "msg-embed-link-content" }); + + const hostEl = createElement("div", { class: "msg-embed-host" }, displayHost); + content.appendChild(hostEl); + + const titleEl = createElement("a", { + class: "msg-embed-link-title", + href: url, + target: "_blank", + rel: "noopener noreferrer", + }); + content.appendChild(titleEl); + + const descEl = createElement("div", { class: "msg-embed-link-desc" }); + content.appendChild(descEl); + + wrap.appendChild(content); + + // Image container (shown if og:image exists) + const imageWrap = createElement("div", { class: "msg-embed-link-image" }); + imageWrap.style.display = "none"; + wrap.appendChild(imageWrap); + + // Check cache first for instant render + const cached = ogCache.get(url); + if (cached !== undefined) { + applyOgMeta(cached, titleEl, descEl, hostEl, imageWrap, url, displayHost); + } else { + // Show URL as fallback title while loading + setText(titleEl, displayHost); + void fetchOgMeta(url).then((meta) => { + applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, url, displayHost); + }); + } + + return wrap; +} + +/** Apply fetched OG metadata to the preview card elements. */ +function applyOgMeta( + meta: OgMeta, + titleEl: HTMLElement, + descEl: HTMLElement, + hostEl: HTMLElement, + imageWrap: HTMLElement, + url: string, + displayHost: string, +): void { + setText(titleEl, meta.title ?? displayHost); + if (meta.siteName !== null) { + setText(hostEl, meta.siteName); + } + if (meta.description !== null) { + const desc = meta.description.length > 200 + ? meta.description.slice(0, 197) + "..." + : meta.description; + setText(descEl, desc); + descEl.style.display = ""; + } else { + descEl.style.display = "none"; + } + if (meta.image !== null && meta.image.length > 0) { + // Resolve relative image URLs + let imgSrc = meta.image; + if (imgSrc.startsWith("/")) { + try { + const base = new URL(url); + imgSrc = `${base.origin}${imgSrc}`; + } catch { /* keep as-is */ } + } + if (isSafeUrl(imgSrc)) { + const img = createElement("img", { + class: "msg-embed-link-img", + src: imgSrc, + alt: meta.title ?? "", + loading: "lazy", + }); + img.addEventListener("error", () => { + imageWrap.style.display = "none"; + }); + imageWrap.appendChild(img); + imageWrap.style.display = ""; + } + } +} + +// -- Image lightbox ----------------------------------------------------------- + +/** Open a full-screen lightbox overlay with zoom and pan. */ +function openImageLightbox(src: string, alt: string): void { + const overlay = createElement("div", { class: "image-lightbox" }); + + const imgWrap = createElement("div", { class: "image-lightbox-wrap" }); + const img = createElement("img", { src, alt }) as HTMLImageElement; + imgWrap.appendChild(img); + overlay.appendChild(imgWrap); + + const closeBtn = createElement("button", { class: "image-lightbox-close" }, "\u2715"); + overlay.appendChild(closeBtn); + + // Zoom & pan state + let scale = 1; + let panX = 0; + let panY = 0; + let isDragging = false; + let dragStartX = 0; + let dragStartY = 0; + let panStartX = 0; + let panStartY = 0; + + function applyTransform(): void { + img.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; + } + + function resetZoom(): void { + scale = 1; + panX = 0; + panY = 0; + applyTransform(); + } + + function close(): void { + overlay.remove(); + document.removeEventListener("keydown", onKey); + } + + // Mouse wheel zoom + imgWrap.addEventListener("wheel", (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.15 : 0.15; + const newScale = Math.max(0.5, Math.min(10, scale + delta * scale)); + // Zoom towards cursor position + const rect = img.getBoundingClientRect(); + const cx = e.clientX - rect.left - rect.width / 2; + const cy = e.clientY - rect.top - rect.height / 2; + const factor = newScale / scale; + panX = panX - cx * (factor - 1); + panY = panY - cy * (factor - 1); + scale = newScale; + applyTransform(); + }); + + // Single click to toggle zoom, with drag detection to avoid zoom on pan + let clickStartX = 0; + let clickStartY = 0; + + img.addEventListener("mousedown", (e) => { + e.preventDefault(); + clickStartX = e.clientX; + clickStartY = e.clientY; + + if (scale > 1.1) { + // Zoomed in — start panning + isDragging = true; + dragStartX = e.clientX; + dragStartY = e.clientY; + panStartX = panX; + panStartY = panY; + overlay.classList.add("dragging"); + } + }); + + img.addEventListener("click", (e) => { + e.stopPropagation(); + // Only toggle zoom if mouse didn't move (not a pan gesture) + const dx = Math.abs(e.clientX - clickStartX); + const dy = Math.abs(e.clientY - clickStartY); + if (dx > 5 || dy > 5) return; + + if (scale > 1.1) { + resetZoom(); + } else { + // Zoom to 3x towards click position + const rect = img.getBoundingClientRect(); + const cx = e.clientX - rect.left - rect.width / 2; + const cy = e.clientY - rect.top - rect.height / 2; + scale = 3; + panX = -cx * 2; + panY = -cy * 2; + applyTransform(); + } + }); + + document.addEventListener("mousemove", function onMove(e) { + if (!isDragging) return; + panX = panStartX + (e.clientX - dragStartX); + panY = panStartY + (e.clientY - dragStartY); + applyTransform(); + }); + + document.addEventListener("mouseup", function onUp() { + if (isDragging) { + isDragging = false; + overlay.classList.remove("dragging"); + } + }); + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + close(); + }); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) close(); + }); + + function onKey(e: KeyboardEvent): void { + if (e.key === "Escape") close(); + if (e.key === "+" || e.key === "=") { + scale = Math.min(10, scale * 1.3); + applyTransform(); + } + if (e.key === "-") { + scale = Math.max(0.5, scale / 1.3); + applyTransform(); + } + if (e.key === "0") resetZoom(); + } + document.addEventListener("keydown", onKey); + + document.body.appendChild(overlay); +} + +// -- Attachment rendering ----------------------------------------------------- + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function isImageMime(mime: string): boolean { + return mime.startsWith("image/"); +} + +function isSafeUrl(url: string): boolean { + try { + const parsed = new URL(url, window.location.origin); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Image cache: memory + IndexedDB for persistence across restarts +// --------------------------------------------------------------------------- + +/** In-memory cache for instant re-render. */ +const memoryCache = new Map<string, string>(); + +/** In-flight fetch promises to prevent duplicate concurrent requests. */ +const inFlight = new Map<string, Promise<string | null>>(); + +/** IndexedDB database name and store. */ +const IDB_NAME = "owncord-image-cache"; +const IDB_STORE = "images"; +const IDB_VERSION = 1; + +/** Open (or create) the IndexedDB database. */ +function openCacheDb(): Promise<IDBDatabase | null> { + return new Promise((resolve) => { + try { + const req = indexedDB.open(IDB_NAME, IDB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(null); + } catch { + resolve(null); + } + }); +} + +/** Read a cached data URL from IndexedDB. */ +async function idbGet(url: string): Promise<string | null> { + const db = await openCacheDb(); + if (db === null) return null; + return new Promise((resolve) => { + try { + const tx = db.transaction(IDB_STORE, "readonly"); + const store = tx.objectStore(IDB_STORE); + const req = store.get(url); + req.onsuccess = () => resolve(typeof req.result === "string" ? req.result : null); + req.onerror = () => resolve(null); + } catch { + resolve(null); + } + }); +} + +/** Write a data URL to IndexedDB. */ +async function idbPut(url: string, dataUrl: string): Promise<void> { + const db = await openCacheDb(); + if (db === null) return; + try { + const tx = db.transaction(IDB_STORE, "readwrite"); + tx.objectStore(IDB_STORE).put(dataUrl, url); + } catch { + // IndexedDB full or unavailable — ignore + } +} + +/** Convert a Uint8Array to a base64 string. */ +function uint8ToBase64(bytes: Uint8Array): string { + // Process in chunks to avoid call stack overflow on large files + const CHUNK = 8192; + let binary = ""; + for (let i = 0; i < bytes.length; i += CHUNK) { + const slice = bytes.subarray(i, Math.min(i + CHUNK, bytes.length)); + binary += String.fromCharCode(...slice); + } + return btoa(binary); +} + +/** Fetch an image and return a data: URI. Uses memory → IndexedDB → network. */ +function fetchImageAsDataUrl(url: string): Promise<string | null> { + // 1. Memory cache (instant) + const cached = memoryCache.get(url); + if (cached !== undefined) return Promise.resolve(cached); + + // 2. Deduplicate concurrent requests for the same URL + const existing = inFlight.get(url); + if (existing !== undefined) return existing; + + const promise = (async (): Promise<string | null> => { + // 3. IndexedDB cache (persists across restarts) + const idbCached = await idbGet(url); + if (idbCached !== null) { + memoryCache.set(url, idbCached); + return idbCached; + } + + // 4. Network fetch via Tauri HTTP plugin + try { + const res = await tauriFetch(url, { + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + } as RequestInit); + if (!res.ok) return null; + + const contentType = res.headers.get("content-type") ?? "image/png"; + const buffer = await res.arrayBuffer(); + const base64 = uint8ToBase64(new Uint8Array(buffer)); + const dataUrl = `data:${contentType};base64,${base64}`; + + // Store in both caches + memoryCache.set(url, dataUrl); + void idbPut(url, dataUrl); + + return dataUrl; + } catch (err) { + console.error("Failed to fetch attachment image:", url, err); + return null; + } + })(); + + inFlight.set(url, promise); + void promise.finally(() => inFlight.delete(url)); + + return promise; +} + +function renderAttachment(att: Attachment): HTMLDivElement { + const resolvedUrl = resolveServerUrl(att.url); + if (isImageMime(att.mime) && isSafeUrl(resolvedUrl)) { + const wrap = createElement("div", { class: "msg-image" }); + + function attachLightbox(img: HTMLImageElement): void { + img.addEventListener("click", () => { + openImageLightbox(img.src, att.filename); + }); + } + + // Check cache first for instant render + const cached = memoryCache.get(resolvedUrl); + if (cached !== undefined) { + const img = createElement("img", { + src: cached, + alt: att.filename, + }) as HTMLImageElement; + attachLightbox(img); + wrap.appendChild(img); + } else { + // Show loading placeholder, then replace with image + const placeholder = createElement("div", { class: "placeholder-img loading" }, att.filename); + wrap.appendChild(placeholder); + + void fetchImageAsDataUrl(resolvedUrl).then((dataUrl) => { + if (dataUrl !== null) { + const img = createElement("img", { + src: dataUrl, + alt: att.filename, + }) as HTMLImageElement; + attachLightbox(img); + placeholder.replaceWith(img); + } + }); + } + + return wrap; + } + const wrap = createElement("div", { class: "msg-file" }); + const inner = createElement("div", { class: "msg-file-inner" }); + const icon = createElement("div", { class: "msg-file-icon" }, "\uD83D\uDCC4"); + const nameEl = createElement("div", { class: "msg-file-name" }, att.filename); + nameEl.addEventListener("click", () => { + void downloadFile(resolvedUrl, att.filename); + }); + const sizeEl = createElement("div", { class: "msg-file-size" }, formatFileSize(att.size)); + const info = createElement("div", {}); + appendChildren(info, nameEl, sizeEl); + const downloadBtn = createElement("button", { + class: "msg-file-download", + title: "Download", + }, "\u2B07"); + downloadBtn.addEventListener("click", () => { + void downloadFile(resolvedUrl, att.filename); + }); + appendChildren(inner, icon, info, downloadBtn); + wrap.appendChild(inner); + return wrap; +} + +/** Download a file via Tauri HTTP plugin and save to disk with native dialog. */ +async function downloadFile(url: string, filename: string): Promise<void> { + try { + // Show native save dialog with suggested filename + const filePath = await save({ defaultPath: filename }); + if (filePath === null) return; // User cancelled + + // Fetch file data + const res = await tauriFetch(url, { + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + } as RequestInit); + if (!res.ok) return; + + const buffer = await res.arrayBuffer(); + await writeFile(filePath, new Uint8Array(buffer)); + } catch (err) { + console.error("Download failed:", err); + } +} + +// -- Reaction rendering ------------------------------------------------------- + +function renderReactions( + msg: Message, + opts: MessageListOptions, + signal: AbortSignal, +): HTMLDivElement { + const container = createElement("div", { class: "msg-reactions" }); + for (const reaction of msg.reactions) { + const chip = createElement("span", { + class: reaction.me ? "reaction-chip me" : "reaction-chip", + }); + const emoji = document.createTextNode(reaction.emoji); + const count = createElement("span", { class: "rc-count" }, String(reaction.count)); + chip.appendChild(emoji); + chip.appendChild(count); + chip.addEventListener("click", () => opts.onReactionClick(msg.id, reaction.emoji), { signal }); + container.appendChild(chip); + } + const addBtn = createElement("span", { class: "reaction-chip add-reaction" }, "+"); + addBtn.addEventListener("click", () => opts.onReactionClick(msg.id, ""), { signal }); + container.appendChild(addBtn); + return container; +} + +// -- DOM rendering (matches ui-mockup.html structure) ------------------------- + +export function renderDayDivider(iso: string): HTMLDivElement { + const divider = createElement("div", { class: "msg-day-divider" }); + appendChildren( + divider, + createElement("span", { class: "line" }), + createElement("span", { class: "date" }, formatFullDate(iso)), + createElement("span", { class: "line" }), + ); + return divider; +} + +function renderReplyRef( + replyToId: number, + allMessages: readonly Message[], +): HTMLDivElement { + const ref = allMessages.find((m) => m.id === replyToId); + const bar = createElement("div", { class: "msg-reply-ref" }); + if (ref) { + const preview = ref.deleted ? "[message deleted]" : ref.content.slice(0, 100); + appendChildren( + bar, + createElement("span", { class: "rr-author" }, ref.user.username), + createElement("span", { class: "rr-text" }, preview), + ); + } else { + setText(bar, "Reply to unknown message"); + } + return bar; +} + +function renderSystemMessage(msg: Message): HTMLDivElement { + const el = createElement("div", { class: "system-msg" }); + const icon = createElement("span", { class: "sm-icon" }, "\u2192"); + const text = createElement("span", { class: "sm-text" }); + text.appendChild(renderMentions(msg.content)); + const time = createElement("span", { class: "sm-time" }, formatTime(msg.timestamp)); + appendChildren(el, icon, text, time); + return el; +} + +export function renderMessage( + msg: Message, + isGrouped: boolean, + allMessages: readonly Message[], + opts: MessageListOptions, + signal: AbortSignal, +): HTMLDivElement { + if (msg.user.username === "System") { + return renderSystemMessage(msg); + } + + const el = createElement("div", { + class: isGrouped ? "message grouped" : "message", + "data-testid": `message-${msg.id}`, + }); + + const role = getUserRole(msg.user.id); + const initial = msg.user.username.charAt(0).toUpperCase(); + const avatar = createElement("div", { + class: "msg-avatar", + style: `background: ${roleColorVar(role)}`, + }, initial); + el.appendChild(avatar); + + if (isGrouped) { + const hoverTime = createElement("div", { + class: "msg-hover-time", + }, formatTime(msg.timestamp)); + el.appendChild(hoverTime); + } + + if (msg.replyTo !== null) { + el.appendChild(renderReplyRef(msg.replyTo, allMessages)); + } + + const header = createElement("div", { class: "msg-header" }); + const author = createElement("span", { + class: "msg-author", + style: `color: ${roleColorVar(role)}`, + }, msg.user.username); + const time = createElement("span", { class: "msg-time" }, formatTime(msg.timestamp)); + appendChildren(header, author, time); + el.appendChild(header); + + if (msg.deleted) { + const text = createElement("div", { class: "msg-text" }); + text.style.fontStyle = "italic"; + text.style.color = "var(--text-muted)"; + setText(text, "[message deleted]"); + el.appendChild(text); + } else { + el.appendChild(renderMessageContent(msg.content)); + if (msg.editedAt !== null) { + el.appendChild(createElement("span", { class: "msg-edited" }, "(edited)")); + } + + for (const att of msg.attachments) { + el.appendChild(renderAttachment(att)); + } + + // URL embeds (YouTube players, link previews) + const embeds = renderUrlEmbeds(msg.content); + if (embeds.childNodes.length > 0) { + el.appendChild(embeds); + } + + if (msg.reactions.length > 0) { + el.appendChild(renderReactions(msg, opts, signal)); + } + } + + if (!msg.deleted) { + const actionsBar = createElement("div", { class: "msg-actions-bar" }); + + const reactBtn = createElement("button", { "data-testid": `msg-react-${msg.id}` }, "\uD83D\uDE04"); + reactBtn.title = "React"; + reactBtn.addEventListener("click", () => opts.onReactionClick(msg.id, ""), { signal }); + actionsBar.appendChild(reactBtn); + + const replyBtn = createElement("button", { "data-testid": `msg-reply-${msg.id}` }, "\u21A9"); + replyBtn.title = "Reply"; + replyBtn.addEventListener("click", () => opts.onReplyClick(msg.id), { signal }); + actionsBar.appendChild(replyBtn); + + if (msg.user.id === opts.currentUserId) { + const editBtn = createElement("button", { "data-testid": `msg-edit-${msg.id}` }, "\u270E"); + editBtn.title = "Edit"; + editBtn.addEventListener("click", () => opts.onEditClick(msg.id), { signal }); + actionsBar.appendChild(editBtn); + } + + if (msg.user.id === opts.currentUserId) { + const deleteBtn = createElement("button", { "data-testid": `msg-delete-${msg.id}` }, "\uD83D\uDDD1"); + deleteBtn.title = "Delete"; + deleteBtn.addEventListener("click", () => opts.onDeleteClick(msg.id), { signal }); + actionsBar.appendChild(deleteBtn); + } + + el.appendChild(actionsBar); + } + + return el; +} diff --git a/Client/tauri-client/src/components/settings/AccountTab.ts b/Client/tauri-client/src/components/settings/AccountTab.ts new file mode 100644 index 00000000..3d247c49 --- /dev/null +++ b/Client/tauri-client/src/components/settings/AccountTab.ts @@ -0,0 +1,99 @@ +/** + * Account settings tab — profile editing, password change, logout. + */ + +import { createElement, appendChildren, setText } from "@lib/dom"; +import { authStore } from "@stores/auth.store"; +import type { SettingsOverlayOptions } from "../SettingsOverlay"; + +export function buildAccountTab( + options: SettingsOverlayOptions, + signal: AbortSignal, +): HTMLDivElement { + const section = createElement("div", { class: "settings-pane active" }); + const user = authStore.getState().user; + + // Account card + const accountCard = createElement("div", { class: "account-card" }); + const acAvatar = createElement("div", { + class: "ac-avatar", + style: "background: var(--accent)", + }, (user?.username ?? "U").charAt(0).toUpperCase()); + const acInfo = createElement("div", {}); + const acName = createElement("div", { class: "ac-name" }, user?.username ?? "Unknown"); + const acId = createElement("div", { class: "ac-id" }, `ID: ${user?.id ?? "?"}`); + appendChildren(acInfo, acName, acId); + const editBtn = createElement("button", { class: "ac-btn" }, "Edit Profile"); + appendChildren(accountCard, acAvatar, acInfo, editBtn); + section.appendChild(accountCard); + + const editForm = createElement("div", { class: "setting-row", style: "display:none" }); + const editInput = createElement("input", { class: "form-input", type: "text", placeholder: "New username" }); + const saveBtn = createElement("button", { class: "ac-btn" }, "Save"); + const cancelBtn = createElement("button", { class: "ac-btn", style: "background:var(--bg-active)" }, "Cancel"); + const usernameValue = acName; + appendChildren(editForm, editInput, saveBtn, cancelBtn); + + editBtn.addEventListener("click", () => { + editForm.style.display = "flex"; + editInput.value = user?.username ?? ""; + editInput.focus(); + }, { signal }); + + cancelBtn.addEventListener("click", () => { + editForm.style.display = "none"; + }, { signal }); + + saveBtn.addEventListener("click", () => { + const newName = editInput.value.trim(); + if (newName.length > 0) { + void options.onUpdateProfile(newName).then(() => { + setText(usernameValue, newName); + editForm.style.display = "none"; + }); + } + }, { signal }); + + section.appendChild(editForm); + + // Change password + const pwHeader = createElement("h3", {}, "Change Password"); + const oldPw = createElement("input", { class: "form-input", type: "password", placeholder: "Old password", style: "margin-bottom:8px" }); + const newPw = createElement("input", { class: "form-input", type: "password", placeholder: "New password", style: "margin-bottom:8px" }); + const confirmPw = createElement("input", { class: "form-input", type: "password", placeholder: "Confirm new password", style: "margin-bottom:8px" }); + const pwError = createElement("div", { style: "color:var(--red);font-size:13px;margin-bottom:8px" }); + const pwBtn = createElement("button", { class: "ac-btn" }, "Change Password"); + + pwBtn.addEventListener("click", () => { + const oldVal = oldPw.value; + const newVal = newPw.value; + const confirmVal = confirmPw.value; + + if (newVal.length < 8) { + setText(pwError, "New password must be at least 8 characters."); + return; + } + if (newVal !== confirmVal) { + setText(pwError, "Passwords do not match."); + return; + } + setText(pwError, ""); + void options.onChangePassword(oldVal, newVal).then(() => { + oldPw.value = ""; + newPw.value = ""; + confirmPw.value = ""; + }); + }, { signal }); + + appendChildren(section, pwHeader, oldPw, newPw, confirmPw, pwError, pwBtn); + + // Logout + const logoutBtn = createElement("button", { + class: "settings-nav-item danger", + style: "margin-top:16px;width:auto;padding:8px 16px", + }, "Log Out"); + logoutBtn.addEventListener("click", () => options.onLogout(), { signal }); + section.appendChild(logoutBtn); + + return section; +} diff --git a/Client/tauri-client/src/components/settings/AppearanceTab.ts b/Client/tauri-client/src/components/settings/AppearanceTab.ts new file mode 100644 index 00000000..1237740d --- /dev/null +++ b/Client/tauri-client/src/components/settings/AppearanceTab.ts @@ -0,0 +1,78 @@ +/** + * Appearance settings tab — theme, font size, compact mode. + */ + +import { createElement, appendChildren, setText } from "@lib/dom"; +import { loadPref, savePref, applyTheme, THEMES } from "./helpers"; +import type { ThemeName } from "./helpers"; +import { setTheme } from "@stores/ui.store"; + +export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement { + const section = createElement("div", { class: "settings-pane active" }); + const currentTheme = loadPref<ThemeName>("theme", "dark"); + const currentFontSize = loadPref<number>("fontSize", 16); + const currentCompact = loadPref<boolean>("compactMode", false); + + // Theme selector + const themeHeader = createElement("h3", {}, "Theme"); + const themeRow = createElement("div", { class: "theme-options" }); + for (const name of Object.keys(THEMES) as ThemeName[]) { + const btn = createElement("div", { + class: `theme-opt ${name}${name === currentTheme ? " active" : ""}`, + }, name.charAt(0).toUpperCase() + name.slice(1)); + + btn.addEventListener("click", () => { + applyTheme(name); + savePref("theme", name); + setTheme(name); + const prev = themeRow.querySelector(".theme-opt.active"); + if (prev) prev.classList.remove("active"); + btn.classList.add("active"); + }, { signal }); + + themeRow.appendChild(btn); + } + appendChildren(section, themeHeader, themeRow); + + // Font size slider + const fontHeader = createElement("h3", {}, "Font Size"); + const fontRow = createElement("div", { class: "slider-row" }); + const fontSlider = createElement("input", { + class: "settings-slider", + type: "range", + min: "12", + max: "20", + value: String(currentFontSize), + }); + const fontLabel = createElement("span", { class: "slider-val" }, `${currentFontSize}px`); + fontSlider.addEventListener("input", () => { + const size = Number(fontSlider.value); + setText(fontLabel, `${size}px`); + document.documentElement.style.setProperty("--font-size", `${size}px`); + savePref("fontSize", size); + }, { signal }); + appendChildren(fontRow, fontSlider, fontLabel); + appendChildren(section, fontHeader, fontRow); + + // Compact mode toggle + const compactRow = createElement("div", { class: "setting-row" }); + const compactLabel = createElement("span", { class: "setting-label" }, "Compact Mode"); + const compactToggle = createElement("div", { + class: currentCompact ? "toggle on" : "toggle", + }); + compactToggle.addEventListener("click", () => { + const isNowCompact = !compactToggle.classList.contains("on"); + compactToggle.classList.toggle("on", isNowCompact); + savePref("compactMode", isNowCompact); + document.documentElement.classList.toggle("compact-mode", isNowCompact); + }, { signal }); + appendChildren(compactRow, compactLabel, compactToggle); + section.appendChild(compactRow); + + // Apply stored preferences on render + applyTheme(currentTheme); + document.documentElement.style.setProperty("--font-size", `${currentFontSize}px`); + document.documentElement.classList.toggle("compact-mode", currentCompact); + + return section; +} diff --git a/Client/tauri-client/src/components/settings/KeybindsTab.ts b/Client/tauri-client/src/components/settings/KeybindsTab.ts new file mode 100644 index 00000000..16cca4b0 --- /dev/null +++ b/Client/tauri-client/src/components/settings/KeybindsTab.ts @@ -0,0 +1,26 @@ +/** + * Keybinds settings tab — push-to-talk and quick switcher bindings. + */ + +import { createElement, appendChildren } from "@lib/dom"; +import { loadPref } from "./helpers"; + +export function buildKeybindsTab(): HTMLDivElement { + const section = createElement("div", { class: "settings-pane active" }); + const header = createElement("h1", {}, "Keybinds"); + section.appendChild(header); + + const pttRow = createElement("div", { class: "keybind-row" }); + const pttLabel = createElement("span", { class: "setting-label" }, "Push to Talk"); + const pttValue = createElement("span", { class: "kbd" }, loadPref<string>("pttKey", "Not set")); + appendChildren(pttRow, pttLabel, pttValue); + section.appendChild(pttRow); + + const searchRow = createElement("div", { class: "keybind-row" }); + const searchLabel = createElement("span", { class: "setting-label" }, "Quick Switcher"); + const searchValue = createElement("span", { class: "kbd" }, "Ctrl + K"); + appendChildren(searchRow, searchLabel, searchValue); + section.appendChild(searchRow); + + return section; +} diff --git a/Client/tauri-client/src/components/settings/LogsTab.ts b/Client/tauri-client/src/components/settings/LogsTab.ts new file mode 100644 index 00000000..7ce5bdb1 --- /dev/null +++ b/Client/tauri-client/src/components/settings/LogsTab.ts @@ -0,0 +1,359 @@ +/** + * Logs settings tab — log viewer with filtering, level control, live updates. + */ + +import { createElement, appendChildren, clearChildren } from "@lib/dom"; +import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/logger"; +import type { LogEntry, LogLevel } from "@lib/logger"; +import type { TabName } from "../SettingsOverlay"; +import { getSessionDebugInfo, measureStreamLevel, getRemoteStreams, getLocalProcessedStream } from "@lib/voiceSession"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const LOG_LEVEL_COLORS: Record<LogLevel, string> = { + debug: "#888", + info: "#3ba55d", + warn: "#faa61a", + error: "#ed4245", +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatLogEntry(entry: LogEntry): HTMLDivElement { + const row = createElement("div", { + class: "log-entry", + style: `border-left: 3px solid ${LOG_LEVEL_COLORS[entry.level]}; padding: 4px 8px; margin: 2px 0; font-family: monospace; font-size: 12px; line-height: 1.4;`, + }); + const time = entry.timestamp.slice(11, 23); // HH:MM:SS.mmm + const level = entry.level.toUpperCase().padEnd(5); + const text = `${time} ${level} [${entry.component}] ${entry.message}`; + const textEl = createElement("span", { + style: `color: ${LOG_LEVEL_COLORS[entry.level]}`, + }, text); + row.appendChild(textEl); + + if (entry.data !== undefined) { + const dataStr = typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data, null, 2); + const dataEl = createElement("pre", { + style: "margin: 2px 0 0 0; color: #999; font-size: 11px; white-space: pre-wrap; word-break: break-all;", + }, dataStr); + row.appendChild(dataEl); + } + + return row; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export interface LogsTabHandle { + build(): HTMLDivElement; + cleanup(): void; +} + +export function createLogsTab( + getActiveTab: () => TabName, + signal: AbortSignal, +): LogsTabHandle { + let logListEl: HTMLDivElement | null = null; + let logFilterLevel: LogLevel | "all" = "all"; + let unsubLogListener: (() => void) | null = null; + + function renderLogEntries(): void { + if (logListEl === null) return; + clearChildren(logListEl); + + const entries = getLogBuffer(); + for (const entry of entries) { + if (logFilterLevel !== "all" && entry.level !== logFilterLevel) continue; + logListEl.appendChild(formatLogEntry(entry)); + } + + // Auto-scroll to bottom + logListEl.scrollTop = logListEl.scrollHeight; + } + + function build(): HTMLDivElement { + const section = createElement("div", { class: "settings-pane active" }); + const header = createElement("h1", {}, "Logs"); + section.appendChild(header); + + // Controls row + const controls = createElement("div", { + style: "display: flex; gap: 8px; margin-bottom: 8px; align-items: center;", + }); + + // Filter dropdown + const filterLabel = createElement("span", { class: "setting-label", style: "margin: 0;" }, "Filter:"); + const filterSelect = createElement("select", { + style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;", + }); + const levels: Array<LogLevel | "all"> = ["all", "debug", "info", "warn", "error"]; + for (const lvl of levels) { + const opt = createElement("option", { value: lvl }, lvl.toUpperCase()); + if (lvl === logFilterLevel) opt.setAttribute("selected", ""); + filterSelect.appendChild(opt); + } + filterSelect.addEventListener("change", () => { + logFilterLevel = filterSelect.value as LogLevel | "all"; + renderLogEntries(); + }, { signal }); + + // Log level selector + const levelLabel = createElement("span", { class: "setting-label", style: "margin: 0 0 0 16px;" }, "Min Level:"); + const levelSelect = createElement("select", { + style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;", + }); + const minLevels: LogLevel[] = ["debug", "info", "warn", "error"]; + for (const lvl of minLevels) { + const opt = createElement("option", { value: lvl }, lvl.toUpperCase()); + levelSelect.appendChild(opt); + } + levelSelect.addEventListener("change", () => { + setLogLevel(levelSelect.value as LogLevel); + }, { signal }); + + // Copy All button + const copyBtn = createElement("button", { + class: "ac-btn", + style: "margin-left: auto;", + }, "Copy All"); + copyBtn.addEventListener("click", () => { + const entries = getLogBuffer(); + const filtered = logFilterLevel === "all" + ? entries + : entries.filter((e) => e.level === logFilterLevel); + const text = filtered.map((e) => { + const time = e.timestamp.slice(11, 23); + const level = e.level.toUpperCase().padEnd(5); + const base = `${time} ${level} [${e.component}] ${e.message}`; + if (e.data === undefined) return base; + const dataStr = typeof e.data === "string" ? e.data : JSON.stringify(e.data, null, 2); + return `${base}\n${dataStr}`; + }).join("\n"); + void navigator.clipboard.writeText(text).then(() => { + copyBtn.textContent = "Copied!"; + setTimeout(() => { copyBtn.textContent = "Copy All"; }, 1500); + }); + }, { signal }); + + // Clear button + const clearBtn = createElement("button", { class: "ac-btn" }, "Clear Logs"); + clearBtn.addEventListener("click", () => { + clearLogBuffer(); + renderLogEntries(); + }, { signal }); + + // Refresh button + const refreshBtn = createElement("button", { class: "ac-btn" }, "Refresh"); + refreshBtn.addEventListener("click", () => renderLogEntries(), { signal }); + + appendChildren(controls, filterLabel, filterSelect, levelLabel, levelSelect, copyBtn, clearBtn, refreshBtn); + section.appendChild(controls); + + // Voice diagnostics panel + const diagHeader = createElement("h3", { style: "margin: 12px 0 6px 0;" }, "Voice Diagnostics"); + section.appendChild(diagHeader); + + const diagPanel = createElement("div", { + style: "background: var(--bg-tertiary); border-radius: 8px; padding: 10px; margin-bottom: 12px; font-family: monospace; font-size: 12px; line-height: 1.6; color: var(--text-muted);", + }); + + function refreshDiag(): void { + const info = getSessionDebugInfo(); + const ctx = info.sharedAudioCtx as { state: string; sampleRate: number } | null; + const localTracks = info.localTracks as Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>; + const remoteEls = info.remoteAudioElements as Array<{ + streamId: string; userId: number; audioPaused: boolean; audioMuted: boolean; + audioVolume: number; audioReadyState: number; hasSrcObject: boolean; + gainValue: number | string; + tracks: Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>; + }>; + const webrtcStreams = info.webrtcRemoteStreams as Array<{ + streamId: string; trackCount: number; + audioTracks: Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>; + }>; + + const lines: string[] = [ + `=== Session ===`, + `WebRTC: ${info.hasWebrtc} VAD: ${info.hasVad} Suppressor: ${info.hasNoiseSuppressor}`, + `Join in progress: ${info.joinInProgress} Silence suppression: ${info.silenceSuppressionEnabled}`, + `SharedAudioCtx: ${ctx ? `${ctx.state} @ ${ctx.sampleRate}Hz` : "none"}`, + ``, + `=== Local Audio ===`, + `Stream: ${info.hasLocalStream} Processed: ${info.hasProcessedStream}`, + ]; + for (const t of localTracks) { + lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`); + } + + lines.push(``, `=== WebRTC Remote Streams ===`); + if (webrtcStreams.length === 0) lines.push(` (none)`); + for (const s of webrtcStreams) { + lines.push(` Stream ${s.streamId}: ${s.trackCount} tracks`); + for (const t of s.audioTracks) { + lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`); + } + } + + lines.push(``, `=== Remote Audio Elements ===`); + if (remoteEls.length === 0) lines.push(` (none)`); + for (const el of remoteEls) { + lines.push(` [user ${el.userId}] stream=${el.streamId}`); + lines.push(` <audio> paused=${el.audioPaused} muted=${el.audioMuted} volume=${el.audioVolume} readyState=${el.audioReadyState} srcObject=${el.hasSrcObject}`); + lines.push(` GainNode: ${typeof el.gainValue === "number" ? el.gainValue.toFixed(2) : el.gainValue}`); + for (const t of el.tracks) { + lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`); + } + } + + diagPanel.textContent = lines.join("\n"); + } + + refreshDiag(); + const diagRefresh = createElement("button", { class: "ac-btn", style: "margin-top: 6px;" }, "Refresh Diagnostics"); + diagRefresh.addEventListener("click", refreshDiag, { signal }); + + const diagCopy = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Copy Diagnostics"); + diagCopy.addEventListener("click", () => { + void navigator.clipboard.writeText(diagPanel.textContent ?? "").then(() => { + diagCopy.textContent = "Copied!"; + setTimeout(() => { diagCopy.textContent = "Copy Diagnostics"; }, 1500); + }); + }, { signal }); + + // Live audio level probe — measures actual signal flowing through streams + const levelBtn = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Probe Audio Levels"); + const levelResult = createElement("pre", { + style: "margin: 6px 0 0 0; color: #ccc; font-family: monospace; font-size: 12px; white-space: pre-wrap;", + }); + levelBtn.addEventListener("click", () => { + levelBtn.textContent = "Probing..."; + levelResult.textContent = ""; + + const info = getSessionDebugInfo(); + const promises: Array<Promise<string>> = []; + + // 1. Probe local mic (what we're sending) + const localStream = getLocalProcessedStream(); + if (localStream) { + promises.push( + measureStreamLevel(localStream).then((lvl) => `Local mic (outgoing): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`), + ); + } else { + promises.push(Promise.resolve("Local mic: no stream")); + } + + // 2. Probe raw WebRTC remote streams (before GainNode) + const rawRemoteStreams = getRemoteStreams(); + rawRemoteStreams.forEach((s, i) => { + promises.push( + measureStreamLevel(s).then((lvl) => `Remote [${i}] (raw WebRTC ${s.id}): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`), + ); + }); + + // 3. Probe GainNode output (what <audio> element plays) + const audioContainer = document.getElementById("voice-audio-container"); + const audioEls = audioContainer?.querySelectorAll("audio") ?? []; + audioEls.forEach((el, i) => { + const a = el as HTMLAudioElement; + const src = a.srcObject as MediaStream | null; + if (src) { + promises.push( + measureStreamLevel(src).then((lvl) => `Remote [${i}] (GainNode output): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`), + ); + } + }); + + if (promises.length === 0) { + levelResult.textContent = "No audio streams to probe"; + levelBtn.textContent = "Probe Audio Levels"; + return; + } + + void Promise.all(promises).then((results) => { + levelResult.textContent = results.join("\n"); + levelBtn.textContent = "Probe Audio Levels"; + }); + }, { signal }); + + section.appendChild(diagPanel); + const diagBtns = createElement("div", { style: "display: flex; flex-wrap: wrap;" }); + appendChildren(diagBtns, diagRefresh, diagCopy, levelBtn); + section.appendChild(diagBtns); + section.appendChild(levelResult); + + // Direct playback test — bypasses GainNode pipeline entirely + const directBtn = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Test Direct Playback"); + const directResult = createElement("pre", { + style: "margin: 6px 0 0 0; color: #ccc; font-family: monospace; font-size: 12px; white-space: pre-wrap;", + }); + directBtn.addEventListener("click", () => { + const rawStreams = getRemoteStreams(); + if (rawStreams.length === 0) { + directResult.textContent = "No remote streams to test"; + return; + } + const lines: string[] = []; + for (const s of rawStreams) { + const testAudio = document.createElement("audio"); + testAudio.srcObject = s; + testAudio.autoplay = true; + testAudio.volume = 1.0; + document.body.appendChild(testAudio); + testAudio.play().then(() => { + lines.push(`Stream ${s.id}: play() succeeded, paused=${testAudio.paused}, readyState=${testAudio.readyState}`); + lines.push(` tracks: ${s.getAudioTracks().map((t) => `${t.id.slice(0,8)} enabled=${t.enabled} muted=${t.muted} readyState=${t.readyState}`).join(", ")}`); + directResult.textContent = lines.join("\n") + "\n\nDirect <audio> element added — can you hear audio now? (playing raw WebRTC stream, no GainNode)"; + // Clean up after 10 seconds + setTimeout(() => { testAudio.srcObject = null; testAudio.remove(); }, 10000); + }).catch((err) => { + lines.push(`Stream ${s.id}: play() FAILED — ${err instanceof Error ? err.message : String(err)}`); + directResult.textContent = lines.join("\n"); + testAudio.remove(); + }); + } + }, { signal }); + diagBtns.appendChild(directBtn); + section.appendChild(directResult); + + // Log count + const countEl = createElement("div", { + style: "font-size: 12px; color: #888; margin: 12px 0 4px 0;", + }, `${getLogBuffer().length} entries`); + section.appendChild(countEl); + + // Log list (scrollable) + logListEl = createElement("div", { + class: "log-viewer", + style: "max-height: 60vh; overflow-y: auto; background: var(--bg-tertiary); border-radius: 8px; padding: 8px;", + }); + section.appendChild(logListEl); + + renderLogEntries(); + + // Live update: subscribe to new log entries + unsubLogListener?.(); + unsubLogListener = addLogListener(() => { + if (getActiveTab() === "Logs") { + renderLogEntries(); + countEl.textContent = `${getLogBuffer().length} entries`; + } + }); + + return section; + } + + function cleanup(): void { + unsubLogListener?.(); + unsubLogListener = null; + logListEl = null; + } + + return { build, cleanup }; +} diff --git a/Client/tauri-client/src/components/settings/NotificationsTab.ts b/Client/tauri-client/src/components/settings/NotificationsTab.ts new file mode 100644 index 00000000..1bd2b7f7 --- /dev/null +++ b/Client/tauri-client/src/components/settings/NotificationsTab.ts @@ -0,0 +1,40 @@ +/** + * Notifications settings tab — desktop notifications, taskbar flash, sounds. + */ + +import { createElement, appendChildren } from "@lib/dom"; +import { loadPref, savePref } from "./helpers"; + +export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement { + const section = createElement("div", { class: "settings-pane active" }); + const header = createElement("h1", {}, "Notifications"); + section.appendChild(header); + + const toggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [ + { key: "desktopNotifications", label: "Desktop Notifications", desc: "Show desktop notifications for messages", fallback: true }, + { key: "flashTaskbar", label: "Flash Taskbar", desc: "Flash taskbar on new messages", fallback: true }, + { key: "suppressEveryone", label: "Suppress @everyone", desc: "Mute @everyone and @here mentions", fallback: false }, + { key: "notificationSounds", label: "Notification Sounds", desc: "Play sounds for notifications", fallback: true }, + ]; + + for (const item of toggles) { + const row = createElement("div", { class: "setting-row" }); + const info = createElement("div", {}); + const label = createElement("div", { class: "setting-label" }, item.label); + const desc = createElement("div", { class: "setting-desc" }, item.desc); + appendChildren(info, label, desc); + + const isOn = loadPref<boolean>(item.key, item.fallback); + const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" }); + toggle.addEventListener("click", () => { + const nowOn = !toggle.classList.contains("on"); + toggle.classList.toggle("on", nowOn); + savePref(item.key, nowOn); + }, { signal }); + + appendChildren(row, info, toggle); + section.appendChild(row); + } + + return section; +} diff --git a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts new file mode 100644 index 00000000..1fbfa90f --- /dev/null +++ b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts @@ -0,0 +1,223 @@ +/** + * Voice & Audio settings tab — input/output device, sensitivity, audio processing. + */ + +import { createElement, appendChildren, setText } from "@lib/dom"; +import { loadPref, savePref } from "./helpers"; +import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, updateSilenceSuppressionPref } from "@lib/voiceSession"; +import { sensitivityToThreshold } from "@lib/vad"; + +export function buildVoiceAudioTab(signal: AbortSignal): HTMLDivElement { + const section = createElement("div", { class: "settings-pane active" }); + const header = createElement("h1", {}, "Voice & Audio"); + section.appendChild(header); + + // Input device selector + const inputHeader = createElement("h3", {}, "Input Device"); + const inputSelect = createElement("select", { + class: "form-input", + style: "width:100%;margin-bottom:12px", + }); + const defaultInputOpt = createElement("option", { value: "" }, "Default"); + inputSelect.appendChild(defaultInputOpt); + section.appendChild(inputHeader); + section.appendChild(inputSelect); + + // Output device selector + const outputHeader = createElement("h3", {}, "Output Device"); + const outputSelect = createElement("select", { + class: "form-input", + style: "width:100%;margin-bottom:12px", + }); + const defaultOutputOpt = createElement("option", { value: "" }, "Default"); + outputSelect.appendChild(defaultOutputOpt); + section.appendChild(outputHeader); + section.appendChild(outputSelect); + + // Populate devices asynchronously + void (async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const savedInput = loadPref<string>("audioInputDevice", ""); + const savedOutput = loadPref<string>("audioOutputDevice", ""); + + for (const d of devices) { + if (d.kind === "audioinput") { + const opt = createElement("option", { value: d.deviceId }, + d.label || `Microphone (${d.deviceId.slice(0, 8)})`); + if (d.deviceId === savedInput) opt.setAttribute("selected", ""); + inputSelect.appendChild(opt); + } else if (d.kind === "audiooutput") { + const opt = createElement("option", { value: d.deviceId }, + d.label || `Speaker (${d.deviceId.slice(0, 8)})`); + if (d.deviceId === savedOutput) opt.setAttribute("selected", ""); + outputSelect.appendChild(opt); + } + } + + // Restore saved selections + if (savedInput) inputSelect.value = savedInput; + if (savedOutput) outputSelect.value = savedOutput; + } catch { + const errOpt = createElement("option", { value: "", disabled: "" }, + "Could not enumerate devices"); + inputSelect.appendChild(errOpt); + } + })(); + + inputSelect.addEventListener("change", () => { + savePref("audioInputDevice", inputSelect.value); + void switchInputDevice(inputSelect.value); + }, { signal }); + + outputSelect.addEventListener("change", () => { + savePref("audioOutputDevice", outputSelect.value); + void switchOutputDevice(outputSelect.value); + }, { signal }); + + // ── Mic level meter + sensitivity slider ────────────────────────── + const sensitivityHeader = createElement("h3", {}, "Input Sensitivity"); + section.appendChild(sensitivityHeader); + + // Real-time mic level bar + const meterWrap = createElement("div", { class: "mic-meter-wrap" }); + const meterBar = createElement("div", { class: "mic-meter-bar" }); + const meterLevel = createElement("div", { class: "mic-meter-level" }); + const meterThreshold = createElement("div", { class: "mic-meter-threshold" }); + meterBar.appendChild(meterLevel); + meterBar.appendChild(meterThreshold); + meterWrap.appendChild(meterBar); + section.appendChild(meterWrap); + + // Sensitivity slider + const sensitivityRow = createElement("div", { class: "slider-row" }); + const savedSensitivity = loadPref<number>("voiceSensitivity", 50); + const sensitivitySlider = createElement("input", { + class: "settings-slider", + type: "range", + min: "0", + max: "100", + value: String(savedSensitivity), + }); + const sensitivityLabel = createElement("span", { class: "slider-val" }, `${savedSensitivity}%`); + + // Position threshold indicator + function updateThresholdIndicator(sensitivity: number): void { + const threshold = sensitivityToThreshold(sensitivity); + // Map threshold (0-0.15) to percentage position (0-100%) + const pct = Math.min((threshold / 0.15) * 100, 100); + meterThreshold.style.left = `${pct}%`; + } + updateThresholdIndicator(savedSensitivity); + + sensitivitySlider.addEventListener("input", () => { + const val = Number(sensitivitySlider.value); + setText(sensitivityLabel, `${val}%`); + savePref("voiceSensitivity", val); + setVoiceSensitivity(val); + updateThresholdIndicator(val); + }, { signal }); + appendChildren(sensitivityRow, sensitivitySlider, sensitivityLabel); + section.appendChild(sensitivityRow); + + // Start mic level monitoring for visual feedback + let micStream: MediaStream | null = null; + let micAudioCtx: AudioContext | null = null; + let micAnalyser: AnalyserNode | null = null; + let micAnimFrame: number | null = null; + + void (async () => { + try { + const savedDevice = loadPref<string>("audioInputDevice", ""); + const constraints: MediaStreamConstraints = { + audio: savedDevice ? { deviceId: { exact: savedDevice } } : true, + video: false, + }; + micStream = await navigator.mediaDevices.getUserMedia(constraints); + micAudioCtx = new AudioContext(); + micAnalyser = micAudioCtx.createAnalyser(); + micAnalyser.fftSize = 256; + micAnalyser.smoothingTimeConstant = 0.5; + const source = micAudioCtx.createMediaStreamSource(micStream); + source.connect(micAnalyser); + + const dataArray = new Uint8Array(micAnalyser.frequencyBinCount); + + function updateMeter(): void { + if (micAnalyser === null || signal.aborted) return; + micAnalyser.getByteFrequencyData(dataArray); + // Compute RMS normalized to 0-1 + let sum = 0; + for (let i = 0; i < dataArray.length; i++) { + const v = (dataArray[i] ?? 0) / 255; + sum += v * v; + } + const rms = Math.sqrt(sum / dataArray.length); + // Scale for visual: use sqrt for more visible quiet sounds + const visual = Math.min(Math.sqrt(rms) * 2, 1); + meterLevel.style.width = `${visual * 100}%`; + + // Color: green if above threshold, yellow/red if below + const threshold = sensitivityToThreshold(Number(sensitivitySlider.value)); + const normalizedRms = rms; + if (normalizedRms >= threshold) { + meterLevel.style.background = "#43b581"; // green — voice detected + } else { + meterLevel.style.background = "#faa61a"; // yellow — below threshold + } + + micAnimFrame = requestAnimationFrame(updateMeter); + } + micAnimFrame = requestAnimationFrame(updateMeter); + } catch { + // Mic access denied or unavailable — meter stays empty + } + })(); + + // Cleanup mic monitoring when settings tab is closed + signal.addEventListener("abort", () => { + if (micAnimFrame !== null) cancelAnimationFrame(micAnimFrame); + if (micStream !== null) { + for (const track of micStream.getTracks()) track.stop(); + } + if (micAudioCtx !== null) void micAudioCtx.close(); + }); + + // ── Audio processing toggles ────────────────────────────────────── + const audioToggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [ + { key: "echoCancellation", label: "Echo Cancellation", desc: "Reduce echo from speakers feeding back into microphone", fallback: true }, + { key: "noiseSuppression", label: "Noise Suppression", desc: "Filter out background noise from your microphone", fallback: true }, + { key: "autoGainControl", label: "Automatic Gain Control", desc: "Automatically adjust microphone volume", fallback: true }, + { key: "enhancedNoiseSuppression", label: "Enhanced Noise Suppression", desc: "ML-powered noise removal (RNNoise) — filters keyboard, pets, and other non-voice sounds", fallback: false }, + { key: "silenceSuppression", label: "Silence Suppression", desc: "Stop sending audio during silence to save bandwidth", fallback: true }, + ]; + + for (const item of audioToggles) { + const row = createElement("div", { class: "setting-row" }); + const info = createElement("div", {}); + const label = createElement("div", { class: "setting-label" }, item.label); + const desc = createElement("div", { class: "setting-desc" }, item.desc); + appendChildren(info, label, desc); + + const isOn = loadPref<boolean>(item.key, item.fallback); + const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" }); + toggle.addEventListener("click", () => { + const nowOn = !toggle.classList.contains("on"); + toggle.classList.toggle("on", nowOn); + savePref(item.key, nowOn); + if (item.key === "silenceSuppression") { + // Silence suppression takes effect on next VAD tick — no device switch needed + updateSilenceSuppressionPref(); + } else { + // Re-acquire mic with new constraints if in an active voice session + const currentDevice = loadPref<string>("audioInputDevice", ""); + void switchInputDevice(currentDevice); + } + }, { signal }); + + appendChildren(row, info, toggle); + section.appendChild(row); + } + + return section; +} diff --git a/Client/tauri-client/src/components/settings/helpers.ts b/Client/tauri-client/src/components/settings/helpers.ts new file mode 100644 index 00000000..2e4db573 --- /dev/null +++ b/Client/tauri-client/src/components/settings/helpers.ts @@ -0,0 +1,46 @@ +/** + * Shared helpers and constants for settings tabs. + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const STORAGE_PREFIX = "owncord:settings:"; + +export const THEMES = { + dark: { "--bg-primary": "#313338", "--bg-secondary": "#2b2d31", "--bg-tertiary": "#1e1f22", "--text-normal": "#dbdee1" }, + midnight: { "--bg-primary": "#1a1a2e", "--bg-secondary": "#16213e", "--bg-tertiary": "#0f3460", "--text-normal": "#e0e0e0" }, + light: { "--bg-primary": "#ffffff", "--bg-secondary": "#f2f3f5", "--bg-tertiary": "#e3e5e8", "--text-normal": "#313338" }, +} as const; + +export type ThemeName = keyof typeof THEMES; + +// --------------------------------------------------------------------------- +// Preference helpers +// --------------------------------------------------------------------------- + +export function loadPref<T>(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + key); + return raw !== null ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + +export function savePref(key: string, value: unknown): void { + localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); +} + +// --------------------------------------------------------------------------- +// Theme application +// --------------------------------------------------------------------------- + +export function applyTheme(name: ThemeName): void { + const vars = THEMES[name]; + const root = document.documentElement; + for (const [prop, val] of Object.entries(vars)) { + root.style.setProperty(prop, val); + } +} diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts new file mode 100644 index 00000000..39d7968e --- /dev/null +++ b/Client/tauri-client/src/lib/api.ts @@ -0,0 +1,524 @@ +// Step 2.13 — REST API Client +// Uses Tauri's HTTP plugin fetch to bypass self-signed cert rejection in webview. + +import { fetch } from "@tauri-apps/plugin-http"; +import { createLogger } from "./logger"; +import type { + AuthResponse, + RegisterResponse, + HealthResponse, + MessagesResponse, + SearchResponse, + ApiError, + ChannelType, + ChannelResponse, + EmojiResponse, + SoundResponse, + InviteResponse, + SessionResponse, + UploadResponse, + VoiceCredentialsResponse, + MemberResponse, +} from "./types"; + +/** Configuration for the API client. */ +export interface ApiClientConfig { + readonly host: string; + readonly token?: string; +} + +/** API client error with parsed error body. */ +export class ApiClientError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = "ApiClientError"; + this.status = status; + this.code = code; + } +} + +export type OnUnauthorized = () => void; + +const log = createLogger("api"); + +/** Create the REST API client. */ +export function createApiClient( + initialConfig: ApiClientConfig, + onUnauthorized?: OnUnauthorized, +) { + let config = { ...initialConfig }; + + function baseUrl(): string { + return `https://${config.host}/api/v1`; + } + + function adminBaseUrl(): string { + return `https://${config.host}/admin/api`; + } + + function headers(): Record<string, string> { + const h: Record<string, string> = { + "Content-Type": "application/json", + }; + if (config.token) { + h["Authorization"] = `Bearer ${config.token}`; + } + return h; + } + + async function request<T>( + method: string, + path: string, + body?: unknown, + signal?: AbortSignal, + ): Promise<T> { + const url = `${baseUrl()}${path}`; + const init: RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } } = { + method, + headers: headers(), + signal, + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + }; + if (body !== undefined) { + init.body = JSON.stringify(body); + } + + log.debug("API →", { method, path }); + + let res: Response; + try { + res = await fetch(url, init as RequestInit); + } catch (fetchErr) { + // Tauri plugin errors may not be standard Error instances + log.error("API fetch failed", { method, path, error: String(fetchErr) }); + if (fetchErr instanceof Error) { + throw fetchErr; + } + throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr)); + } + + log.debug("API ←", { method, path, status: res.status }); + + if (res.status === 401) { + onUnauthorized?.(); + const err = await parseError(res); + throw new ApiClientError(401, err.error, err.message); + } + + if (!res.ok) { + const err = await parseError(res); + log.warn("API error", { method, path, status: res.status, code: err.error, message: err.message }); + throw new ApiClientError(res.status, err.error, err.message); + } + + // 204 No Content + if (res.status === 204) { + return undefined as T; + } + + return res.json() as Promise<T>; + } + + async function adminRequest<T>( + method: string, + path: string, + body?: unknown, + signal?: AbortSignal, + ): Promise<T> { + const url = `${adminBaseUrl()}${path}`; + const init: RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } } = { + method, + headers: headers(), + signal, + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + }; + if (body !== undefined) { + init.body = JSON.stringify(body); + } + + log.debug("Admin API →", { method, path }); + + let res: Response; + try { + res = await fetch(url, init as RequestInit); + } catch (fetchErr) { + log.error("Admin API fetch failed", { method, path, error: String(fetchErr) }); + if (fetchErr instanceof Error) { + throw fetchErr; + } + throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr)); + } + + log.debug("Admin API ←", { method, path, status: res.status }); + + if (res.status === 401) { + onUnauthorized?.(); + const err = await parseError(res); + throw new ApiClientError(401, err.error, err.message); + } + + if (!res.ok) { + const err = await parseError(res); + log.warn("Admin API error", { method, path, status: res.status, code: err.error, message: err.message }); + throw new ApiClientError(res.status, err.error, err.message); + } + + if (res.status === 204) { + return undefined as T; + } + + return res.json() as Promise<T>; + } + + async function parseError(res: Response): Promise<ApiError> { + try { + const body = await res.json(); + return { + error: body.error ?? "UNKNOWN", + message: body.message ?? res.statusText, + }; + } catch { + return { + error: "UNKNOWN", + message: res.statusText, + }; + } + } + + return { + /** Update the client config (e.g., after login). */ + setConfig(newConfig: Partial<ApiClientConfig>): void { + config = { ...config, ...newConfig }; + }, + + /** Get current config (for debugging). Token is redacted. */ + getConfig(): Readonly<ApiClientConfig> { + return { ...config, token: config.token ? "[redacted]" : undefined }; + }, + + // ── Auth ────────────────────────────────────────────── + + login( + username: string, + password: string, + signal?: AbortSignal, + ): Promise<AuthResponse> { + return request<AuthResponse>( + "POST", + "/auth/login", + { username, password }, + signal, + ); + }, + + register( + username: string, + password: string, + inviteCode: string, + signal?: AbortSignal, + ): Promise<RegisterResponse> { + return request<RegisterResponse>( + "POST", + "/auth/register", + { username, password, invite_code: inviteCode }, + signal, + ); + }, + + logout(signal?: AbortSignal): Promise<void> { + return request<void>("POST", "/auth/logout", undefined, signal); + }, + + verifyTotp( + code: string, + partialToken: string, + signal?: AbortSignal, + ): Promise<AuthResponse> { + // Temporarily set token for this request; restore in .finally() + const prevToken = config.token; + config = { ...config, token: partialToken }; + return request<AuthResponse>( + "POST", + "/auth/verify-totp", + { code }, + signal, + ).finally(() => { + config = { ...config, token: prevToken }; + }); + }, + + // ── Users ───────────────────────────────────────────── + + getMe(signal?: AbortSignal): Promise<MemberResponse> { + return request<MemberResponse>("GET", "/users/me", undefined, signal); + }, + + updateProfile( + data: { username?: string; avatar?: string }, + signal?: AbortSignal, + ): Promise<MemberResponse> { + return request<MemberResponse>("PATCH", "/users/me", data, signal); + }, + + changePassword( + currentPassword: string, + newPassword: string, + signal?: AbortSignal, + ): Promise<void> { + return request<void>( + "PUT", + "/users/me/password", + { current_password: currentPassword, new_password: newPassword }, + signal, + ); + }, + + enableTotp(signal?: AbortSignal): Promise<{ qr_uri: string; backup_codes: string[] }> { + return request("POST", "/users/me/totp/enable", undefined, signal); + }, + + confirmTotp(code: string, signal?: AbortSignal): Promise<void> { + return request<void>("POST", "/users/me/totp/confirm", { code }, signal); + }, + + disableTotp(signal?: AbortSignal): Promise<void> { + return request<void>("DELETE", "/users/me/totp", undefined, signal); + }, + + getSessions(signal?: AbortSignal): Promise<SessionResponse[]> { + return request<SessionResponse[]>( + "GET", + "/users/me/sessions", + undefined, + signal, + ); + }, + + revokeSession(sessionId: number, signal?: AbortSignal): Promise<void> { + return request<void>( + "DELETE", + `/users/me/sessions/${sessionId}`, + undefined, + signal, + ); + }, + + // ── Channels ────────────────────────────────────────── + + getMessages( + channelId: number, + options?: { before?: number; limit?: number }, + signal?: AbortSignal, + ): Promise<MessagesResponse> { + const params = new URLSearchParams(); + if (options?.before !== undefined) params.set("before", String(options.before)); + if (options?.limit !== undefined) params.set("limit", String(options.limit)); + const qs = params.toString(); + return request<MessagesResponse>( + "GET", + `/channels/${channelId}/messages${qs ? `?${qs}` : ""}`, + undefined, + signal, + ); + }, + + getPins(channelId: number, signal?: AbortSignal): Promise<MessagesResponse> { + return request<MessagesResponse>( + "GET", + `/channels/${channelId}/pins`, + undefined, + signal, + ); + }, + + pinMessage( + channelId: number, + messageId: number, + signal?: AbortSignal, + ): Promise<void> { + return request<void>( + "POST", + `/channels/${channelId}/pins/${messageId}`, + undefined, + signal, + ); + }, + + unpinMessage( + channelId: number, + messageId: number, + signal?: AbortSignal, + ): Promise<void> { + return request<void>( + "DELETE", + `/channels/${channelId}/pins/${messageId}`, + undefined, + signal, + ); + }, + + // ── Search ──────────────────────────────────────────── + + search( + query: string, + options?: { channelId?: number; limit?: number }, + signal?: AbortSignal, + ): Promise<SearchResponse> { + const params = new URLSearchParams({ q: query }); + if (options?.channelId !== undefined) params.set("channel_id", String(options.channelId)); + if (options?.limit !== undefined) params.set("limit", String(options.limit)); + return request<SearchResponse>( + "GET", + `/search?${params.toString()}`, + undefined, + signal, + ); + }, + + // ── File Uploads ────────────────────────────────────── + + async uploadFile( + file: File, + signal?: AbortSignal, + ): Promise<UploadResponse> { + const formData = new FormData(); + formData.append("file", file); + + const url = `${baseUrl()}/uploads`; + const h: Record<string, string> = {}; + if (config.token) { + h["Authorization"] = `Bearer ${config.token}`; + } + // Don't set Content-Type — browser sets multipart boundary + + const res = await fetch(url, { + method: "POST", + headers: h, + body: formData, + signal, + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + } as RequestInit); + + if (!res.ok) { + const err = await parseError(res); + throw new ApiClientError(res.status, err.error, err.message); + } + + return res.json() as Promise<UploadResponse>; + }, + + // ── Invites ─────────────────────────────────────────── + + getInvites(signal?: AbortSignal): Promise<InviteResponse[]> { + return request<InviteResponse[]>("GET", "/invites", undefined, signal); + }, + + createInvite( + data: { max_uses?: number; expires_in_hours?: number }, + signal?: AbortSignal, + ): Promise<InviteResponse> { + return request<InviteResponse>("POST", "/invites", data, signal); + }, + + revokeInvite(inviteId: number, signal?: AbortSignal): Promise<void> { + return request<void>("DELETE", `/invites/${inviteId}`, undefined, signal); + }, + + // ── Emoji ───────────────────────────────────────────── + + getEmoji(signal?: AbortSignal): Promise<EmojiResponse[]> { + return request<EmojiResponse[]>("GET", "/emoji", undefined, signal); + }, + + deleteEmoji(emojiId: number, signal?: AbortSignal): Promise<void> { + return request<void>("DELETE", `/emoji/${emojiId}`, undefined, signal); + }, + + // ── Sounds ──────────────────────────────────────────── + + getSounds(signal?: AbortSignal): Promise<SoundResponse[]> { + return request<SoundResponse[]>("GET", "/sounds", undefined, signal); + }, + + deleteSound(soundId: number, signal?: AbortSignal): Promise<void> { + return request<void>("DELETE", `/sounds/${soundId}`, undefined, signal); + }, + + // ── Voice ───────────────────────────────────────────── + + getVoiceCredentials( + signal?: AbortSignal, + ): Promise<VoiceCredentialsResponse> { + return request<VoiceCredentialsResponse>( + "GET", + "/voice/credentials", + undefined, + signal, + ); + }, + + // ── Health ──────────────────────────────────────────── + + async getHealth( + host?: string, + timeoutMs = 3000, + ): Promise<HealthResponse> { + const targetHost = host ?? config.host; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`https://${targetHost}/api/v1/health`, { + signal: controller.signal, + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + } as RequestInit); + if (!res.ok) { + throw new ApiClientError(res.status, "HEALTH_CHECK_FAILED", "Health check failed"); + } + return res.json() as Promise<HealthResponse>; + } finally { + clearTimeout(timer); + } + }, + + // ── Admin: Channels ────────────────────────────────────── + + adminCreateChannel( + data: { + name: string; + type: ChannelType; + category: string; + topic?: string; + position?: number; + }, + signal?: AbortSignal, + ): Promise<ChannelResponse> { + return adminRequest<ChannelResponse>("POST", "/channels", data, signal); + }, + + adminUpdateChannel( + id: number, + data: { + name?: string; + topic?: string; + slow_mode?: number; + position?: number; + archived?: boolean; + }, + signal?: AbortSignal, + ): Promise<ChannelResponse> { + return adminRequest<ChannelResponse>("PATCH", `/channels/${id}`, data, signal); + }, + + adminDeleteChannel( + id: number, + signal?: AbortSignal, + ): Promise<void> { + return adminRequest<void>("DELETE", `/channels/${id}`, undefined, signal); + }, + }; +} + +export type ApiClient = ReturnType<typeof createApiClient>; diff --git a/Client/tauri-client/src/lib/audio.ts b/Client/tauri-client/src/lib/audio.ts new file mode 100644 index 00000000..d898c2bb --- /dev/null +++ b/Client/tauri-client/src/lib/audio.ts @@ -0,0 +1,155 @@ +// ============================================================================= +// Audio Device Manager — enumerate devices, acquire streams, set output +// ============================================================================= + +import { loadPref } from "@components/settings/helpers"; +import { createLogger } from "@lib/logger"; + +const log = createLogger("audio"); + +export interface AudioDevice { + readonly deviceId: string; + readonly label: string; + readonly kind: "audioinput" | "audiooutput"; +} + +export interface AudioManager { + enumerateDevices(): Promise<readonly AudioDevice[]>; + getUserMedia(deviceId?: string): Promise<MediaStream>; + setOutputDevice(element: HTMLAudioElement, deviceId: string): Promise<void>; + getInputDeviceId(): string | null; + getOutputDeviceId(): string | null; + onDeviceChange(callback: (devices: readonly AudioDevice[]) => void): () => void; + destroy(): void; +} + +type DeviceChangeCallback = (devices: readonly AudioDevice[]) => void; + +function toAudioDevice(info: MediaDeviceInfo): AudioDevice | null { + if (info.kind !== "audioinput" && info.kind !== "audiooutput") return null; + return { + deviceId: info.deviceId, + label: info.label || `${info.kind === "audioinput" ? "Microphone" : "Speaker"} (${info.deviceId.slice(0, 8)})`, + kind: info.kind, + }; +} + +export function createAudioManager(): AudioManager { + let currentInputDeviceId: string | null = null; + let currentOutputDeviceId: string | null = null; + let destroyed = false; + + const activeStreams = new Set<MediaStream>(); + const deviceChangeCallbacks = new Set<DeviceChangeCallback>(); + + async function listAudioDevices(): Promise<readonly AudioDevice[]> { + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioDevices: AudioDevice[] = []; + for (const d of devices) { + const mapped = toAudioDevice(d); + if (mapped !== null) { + audioDevices.push(mapped); + } + } + return audioDevices; + } + + function handleDeviceChange(): void { + if (destroyed) return; + void listAudioDevices().then((devices) => { + log.info("Audio device change detected", { + inputs: devices.filter((d) => d.kind === "audioinput").length, + outputs: devices.filter((d) => d.kind === "audiooutput").length, + }); + for (const cb of deviceChangeCallbacks) { + cb(devices); + } + }); + } + + navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange); + + return { + async enumerateDevices(): Promise<readonly AudioDevice[]> { + if (destroyed) throw new Error("AudioManager has been destroyed"); + return listAudioDevices(); + }, + + async getUserMedia(deviceId?: string): Promise<MediaStream> { + if (destroyed) throw new Error("AudioManager has been destroyed"); + + const constraints: MediaStreamConstraints = { + audio: { + deviceId: deviceId !== undefined ? { exact: deviceId } : undefined, + echoCancellation: loadPref<boolean>("echoCancellation", true), + noiseSuppression: loadPref<boolean>("noiseSuppression", true), + autoGainControl: loadPref<boolean>("autoGainControl", true), + }, + video: false, + }; + + const stream = await navigator.mediaDevices.getUserMedia(constraints); + activeStreams.add(stream); + + // Determine actual device ID from the track settings + const audioTrack = stream.getAudioTracks()[0]; + if (audioTrack !== undefined) { + const settings = audioTrack.getSettings(); + currentInputDeviceId = settings.deviceId ?? deviceId ?? null; + log.info("Microphone acquired", { + deviceId: currentInputDeviceId, + sampleRate: settings.sampleRate, + channelCount: settings.channelCount, + echoCancellation: settings.echoCancellation, + noiseSuppression: settings.noiseSuppression, + autoGainControl: settings.autoGainControl, + }); + } + + return stream; + }, + + async setOutputDevice(element: HTMLAudioElement, deviceId: string): Promise<void> { + if (destroyed) throw new Error("AudioManager has been destroyed"); + + // setSinkId is not available in all browsers; check before calling + if (typeof element.setSinkId !== "function") { + throw new Error("Audio output device selection is not supported in this browser"); + } + await element.setSinkId(deviceId); + currentOutputDeviceId = deviceId; + }, + + getInputDeviceId(): string | null { + return currentInputDeviceId; + }, + + getOutputDeviceId(): string | null { + return currentOutputDeviceId; + }, + + onDeviceChange(callback: DeviceChangeCallback): () => void { + deviceChangeCallbacks.add(callback); + return () => { deviceChangeCallbacks.delete(callback); }; + }, + + destroy(): void { + if (destroyed) return; + destroyed = true; + + navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange); + + // Stop all tracks on all active streams + log.debug("AudioManager destroying", { activeStreams: activeStreams.size }); + for (const stream of activeStreams) { + for (const track of stream.getTracks()) { + track.stop(); + } + } + activeStreams.clear(); + deviceChangeCallbacks.clear(); + currentInputDeviceId = null; + currentOutputDeviceId = null; + }, + }; +} diff --git a/Client/tauri-client/src/lib/credentials.ts b/Client/tauri-client/src/lib/credentials.ts new file mode 100644 index 00000000..c1646570 --- /dev/null +++ b/Client/tauri-client/src/lib/credentials.ts @@ -0,0 +1,98 @@ +/** + * Credential storage — wraps Tauri IPC commands for Windows Credential Manager. + * Falls back to no-op in non-Tauri environments (tests, browser). + */ + +import { createLogger } from "./logger"; + +const log = createLogger("credentials"); + +export interface SavedCredential { + readonly username: string; + readonly token: string; + readonly password?: string; +} + +/** Dynamically import Tauri invoke to avoid errors in test/browser. */ +async function getInvoke(): Promise< + ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null +> { + try { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke; + } catch { + return null; + } +} + +/** + * Save a credential to Windows Credential Manager. + * Target: OwnCord/{host} + */ +export async function saveCredential( + host: string, + username: string, + token: string, + password?: string, +): Promise<boolean> { + const invoke = await getInvoke(); + if (!invoke) { + log.warn("Tauri not available — credential not saved"); + return false; + } + try { + await invoke("save_credential", { host, username, token, password: password ?? null }); + return true; + } catch (err) { + log.error("Failed to save credential", { host, error: String(err) }); + return false; + } +} + +/** + * Load a credential from Windows Credential Manager. + * Returns null if not found or Tauri unavailable. + */ +export async function loadCredential( + host: string, +): Promise<SavedCredential | null> { + const invoke = await getInvoke(); + if (!invoke) { + return null; + } + try { + const result = await invoke("load_credential", { host }); + if (result && typeof result === "object") { + const cred = result as Record<string, unknown>; + if (typeof cred.username === "string" && typeof cred.token === "string") { + const saved: SavedCredential = { + username: cred.username, + token: cred.token, + ...(typeof cred.password === "string" ? { password: cred.password } : {}), + }; + return saved; + } + } + return null; + } catch (err) { + log.error("Failed to load credential", { host, error: String(err) }); + return null; + } +} + +/** + * Delete a credential from Windows Credential Manager. + */ +export async function deleteCredential(host: string): Promise<boolean> { + const invoke = await getInvoke(); + if (!invoke) { + return false; + } + try { + await invoke("delete_credential", { host }); + return true; + } catch (err) { + log.error("Failed to delete credential", { host, error: String(err) }); + return false; + } +} diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts new file mode 100644 index 00000000..293c318b --- /dev/null +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -0,0 +1,311 @@ +// Step 2.26 — WebSocket Dispatcher +// Wires WS client events to store updates. +// Each server message type maps to one or more store actions. + +import type { WsClient } from "./ws"; +import { authStore, setAuth, clearAuth } from "@stores/auth.store"; +import { setTransientError } from "@stores/ui.store"; +import { + setChannels, + setActiveChannel, + addChannel, + updateChannel, + removeChannel, + incrementUnread, +} from "@stores/channels.store"; +import { channelsStore } from "@stores/channels.store"; +import { + addMessage, + editMessage, + deleteMessage, + updateReaction, + confirmSend, +} from "@stores/messages.store"; +import { + setMembers, + addMember, + removeMember, + updateMemberRole, + updatePresence, + setTyping, +} from "@stores/members.store"; +import { + setVoiceStates, + updateVoiceState, + removeVoiceUser, + setVoiceConfig, + setSpeakers, + joinVoiceChannel, + leaveVoiceChannel, +} from "@stores/voice.store"; +import { + handleServerOffer, + handleServerAnswer, + handleServerIce, +} from "@lib/voiceSession"; +import { createLogger } from "./logger"; + +const log = createLogger("dispatcher"); + +/** Unsubscribe all listeners. */ +export type DispatcherCleanup = () => void; + +/** + * Wire a WsClient to all domain stores. + * Returns a cleanup function that removes all listeners. + */ +export function wireDispatcher(ws: WsClient): DispatcherCleanup { + const unsubs: Array<() => void> = []; + + // ── Auth ────────────────────────────────────────────── + + unsubs.push( + ws.on("auth_ok", (payload) => { + setAuth( + authStore.getState().token ?? "", + payload.user, + payload.server_name, + payload.motd, + ); + }), + ); + + unsubs.push( + ws.on("auth_error", (payload) => { + log.error("Auth failed", { message: payload.message }); + setTransientError(payload.message); + clearAuth(); + }), + ); + + // ── Ready (initial state dump) ──────────────────────── + + unsubs.push( + ws.on("ready", (payload) => { + setChannels(payload.channels); + setMembers(payload.members); + setVoiceStates(payload.voice_states); + + // Auto-select the first text channel if none is active + const currentActive = channelsStore.select((s) => s.activeChannelId); + if (currentActive === null && payload.channels.length > 0) { + const firstText = payload.channels.find((ch) => ch.type === "text"); + if (firstText !== undefined) { + setActiveChannel(firstText.id); + } + } + + log.info("Ready payload applied", { + channels: payload.channels.length, + members: payload.members.length, + voiceStates: payload.voice_states.length, + }); + }), + ); + + // ── Chat Messages ───────────────────────────────────── + + unsubs.push( + ws.on("chat_message", (payload) => { + log.debug("chat_message received", { + id: payload.id, + channelId: payload.channel_id, + user: payload.user.username, + }); + addMessage(payload); + // Increment unread for non-active channels + const activeId = channelsStore.select( + (s) => s.activeChannelId, + ); + if (payload.channel_id !== activeId) { + incrementUnread(payload.channel_id); + } + }), + ); + + unsubs.push( + ws.on("chat_edited", (payload) => { + editMessage(payload); + }), + ); + + unsubs.push( + ws.on("chat_deleted", (payload) => { + deleteMessage(payload); + }), + ); + + unsubs.push( + ws.on("chat_send_ok", (payload, id) => { + if (id) { + confirmSend(id, payload.message_id, payload.timestamp); + } + }), + ); + + // ── Reactions ─────────────────────────────────────────── + + unsubs.push( + ws.on("reaction_update", (payload) => { + const userId = authStore.getState().user?.id ?? 0; + updateReaction(payload, userId); + }), + ); + + // ── Typing ──────────────────────────────────────────── + + unsubs.push( + ws.on("typing", (payload) => { + setTyping(payload.channel_id, payload.user_id); + }), + ); + + // ── Presence ────────────────────────────────────────── + + unsubs.push( + ws.on("presence", (payload) => { + updatePresence(payload.user_id, payload.status); + }), + ); + + // ── Channels ────────────────────────────────────────── + + unsubs.push( + ws.on("channel_create", (payload) => { + addChannel(payload); + }), + ); + + unsubs.push( + ws.on("channel_update", (payload) => { + updateChannel(payload); + }), + ); + + unsubs.push( + ws.on("channel_delete", (payload) => { + // If the deleted channel is the active one, redirect to the first text channel. + const activeId = channelsStore.select((s) => s.activeChannelId); + removeChannel(payload.id); + if (payload.id === activeId) { + const remaining = channelsStore.select((s) => s.channels); + const sorted = [...remaining.values()] + .filter((ch) => ch.type === "text") + .sort((a, b) => a.position - b.position); + const firstTextId = sorted.length > 0 ? sorted[0]!.id : null; + setActiveChannel(firstTextId); + log.info("Active channel deleted, redirected", { deletedId: payload.id }); + } + }), + ); + + // ── Members ─────────────────────────────────────────── + + unsubs.push( + ws.on("member_join", (payload) => { + log.info("Member joined", { userId: payload.user.id, username: payload.user.username }); + addMember(payload); + }), + ); + + unsubs.push( + ws.on("member_leave", (payload) => { + log.info("Member left", { userId: payload.user_id }); + removeMember(payload.user_id); + }), + ); + + unsubs.push( + ws.on("member_ban", (payload) => { + log.info("Member banned", { userId: payload.user_id }); + removeMember(payload.user_id); + }), + ); + + unsubs.push( + ws.on("member_update", (payload) => { + log.info("Member role updated", { userId: payload.user_id, role: payload.role }); + updateMemberRole(payload.user_id, payload.role); + }), + ); + + // ── Voice ───────────────────────────────────────────── + + unsubs.push( + ws.on("voice_state", (payload) => { + updateVoiceState(payload); + // Auto-join voice channel if the event is for the current user + const currentUserId = authStore.getState().user?.id ?? 0; + if (payload.user_id === currentUserId) { + joinVoiceChannel(payload.channel_id); + } + }), + ); + + unsubs.push( + ws.on("voice_leave", (payload) => { + removeVoiceUser(payload); + // Clear local voice state if the current user was removed (kick/disconnect) + const currentUserId = authStore.getState().user?.id ?? 0; + if (payload.user_id === currentUserId) { + leaveVoiceChannel(); + } + }), + ); + + unsubs.push( + ws.on("voice_config", (payload) => { + setVoiceConfig(payload); + }), + ); + + unsubs.push( + ws.on("voice_speakers", (payload) => { + setSpeakers(payload); + }), + ); + + unsubs.push( + ws.on("voice_offer", (payload) => { + handleServerOffer(payload.sdp, payload.channel_id); + }), + ); + + unsubs.push( + ws.on("voice_answer", (payload) => { + handleServerAnswer(payload.sdp); + }), + ); + + unsubs.push( + ws.on("voice_ice", (payload) => { + handleServerIce(payload.candidate); + }), + ); + + // ── Server Events ───────────────────────────────────── + + unsubs.push( + ws.on("server_restart", (payload) => { + log.warn("Server restarting", { + reason: payload.reason, + delaySeconds: payload.delay_seconds, + }); + }), + ); + + unsubs.push( + ws.on("error", (payload) => { + log.error("Server error", { + code: payload.code, + message: payload.message, + }); + }), + ); + + return () => { + for (const unsub of unsubs) { + unsub(); + } + }; +} diff --git a/Client/tauri-client/src/lib/dom.ts b/Client/tauri-client/src/lib/dom.ts new file mode 100644 index 00000000..40dbea64 --- /dev/null +++ b/Client/tauri-client/src/lib/dom.ts @@ -0,0 +1,98 @@ +// Step 1.11 — Safe DOM utilities +// NEVER use innerHTML with user-provided content. +// All user content must go through these helpers. + +/** + * Escape HTML special characters to prevent XSS. + * Use this when building HTML strings that include user data. + */ +export function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Create an element with optional attributes and text content. + * Text is set via textContent (safe from XSS). + */ +export function createElement<K extends keyof HTMLElementTagNameMap>( + tag: K, + attrs?: Record<string, string>, + textContent?: string, +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + if (attrs) { + for (const [key, value] of Object.entries(attrs)) { + if (key === "class") { + el.className = value; + } else if (key.startsWith("data-")) { + el.dataset[key.slice(5)] = value; + } else if (key.startsWith("aria-")) { + el.setAttribute(key, value); + } else { + el.setAttribute(key, value); + } + } + } + if (textContent !== undefined) { + el.textContent = textContent; + } + return el; +} + +/** + * Set text content safely on an element. + * Always prefer this over innerHTML for user content. + */ +export function setText(el: Element, text: string): void { + el.textContent = text; +} + +/** + * Append multiple children to a parent element. + */ +export function appendChildren( + parent: Element, + ...children: (Element | string)[] +): void { + for (const child of children) { + if (typeof child === "string") { + parent.appendChild(document.createTextNode(child)); + } else { + parent.appendChild(child); + } + } +} + +/** + * Remove all children from an element safely. + */ +export function clearChildren(el: Element): void { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} + +/** + * Query a single element with type safety. + * Returns null if not found. + */ +export function qs<K extends keyof HTMLElementTagNameMap>( + selector: K, + parent?: Element, +): HTMLElementTagNameMap[K] | null; +export function qs(selector: string, parent?: Element): Element | null; +export function qs(selector: string, parent?: Element): Element | null { + return (parent ?? document).querySelector(selector); +} + +/** + * Query all matching elements as an array. + */ +export function qsa(selector: string, parent?: Element): Element[] { + return Array.from((parent ?? document).querySelectorAll(selector)); +} diff --git a/Client/tauri-client/src/lib/logger.ts b/Client/tauri-client/src/lib/logger.ts new file mode 100644 index 00000000..ef41c108 --- /dev/null +++ b/Client/tauri-client/src/lib/logger.ts @@ -0,0 +1,143 @@ +// Step 1.12 — Structured client-side logger + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export interface LogEntry { + readonly timestamp: string; + readonly level: LogLevel; + readonly component: string; + readonly message: string; + readonly data?: unknown; +} + +const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +const MAX_LOG_BUFFER = 500; +const logBuffer: LogEntry[] = []; + +let currentLevel: LogLevel = "debug"; +const listeners: Array<(entry: LogEntry) => void> = []; + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[currentLevel]; +} + +/** Convert Error objects (and nested ones) into serializable form. + * Error.message and Error.stack don't appear in JSON.stringify by default. */ +function serializeData(data: unknown): unknown { + if (data instanceof Error) { + return { error: data.message, stack: data.stack }; + } + if (typeof data === "object" && data !== null) { + const result: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(data as Record<string, unknown>)) { + result[key] = value instanceof Error + ? { error: value.message, stack: value.stack } + : value; + } + return result; + } + return data; +} + +function createEntry( + level: LogLevel, + component: string, + message: string, + data?: unknown, +): LogEntry { + return { + timestamp: new Date().toISOString(), + level, + component, + message, + data: data !== undefined ? serializeData(data) : undefined, + }; +} + +function emit(entry: LogEntry): void { + // Store in circular buffer + logBuffer.push(entry); + if (logBuffer.length > MAX_LOG_BUFFER) { + logBuffer.shift(); + } + + // Console output + const prefix = `[${entry.timestamp}] [${entry.level.toUpperCase()}] [${entry.component}]`; + switch (entry.level) { + case "debug": + console.debug(prefix, entry.message, entry.data ?? ""); + break; + case "info": + console.info(prefix, entry.message, entry.data ?? ""); + break; + case "warn": + console.warn(prefix, entry.message, entry.data ?? ""); + break; + case "error": + console.error(prefix, entry.message, entry.data ?? ""); + break; + } + + // Notify listeners + for (const listener of listeners) { + listener(entry); + } +} + +/** + * Create a scoped logger for a specific component. + */ +export function createLogger(component: string) { + return { + debug(message: string, data?: unknown): void { + if (shouldLog("debug")) emit(createEntry("debug", component, message, data)); + }, + info(message: string, data?: unknown): void { + if (shouldLog("info")) emit(createEntry("info", component, message, data)); + }, + warn(message: string, data?: unknown): void { + if (shouldLog("warn")) emit(createEntry("warn", component, message, data)); + }, + error(message: string, data?: unknown): void { + if (shouldLog("error")) emit(createEntry("error", component, message, data)); + }, + }; +} + +/** + * Set the minimum log level. Messages below this level are silenced. + */ +export function setLogLevel(level: LogLevel): void { + currentLevel = level; +} + +/** + * Add a listener for log entries (e.g., to write to file via Tauri). + */ +export function addLogListener(listener: (entry: LogEntry) => void): () => void { + listeners.push(listener); + return () => { + const idx = listeners.indexOf(listener); + if (idx >= 0) listeners.splice(idx, 1); + }; +} + +/** + * Get a snapshot of the in-memory log buffer (most recent MAX_LOG_BUFFER entries). + */ +export function getLogBuffer(): readonly LogEntry[] { + return logBuffer; +} + +/** + * Clear the log buffer. + */ +export function clearLogBuffer(): void { + logBuffer.length = 0; +} diff --git a/Client/tauri-client/src/lib/noise-suppression.ts b/Client/tauri-client/src/lib/noise-suppression.ts new file mode 100644 index 00000000..458f2b06 --- /dev/null +++ b/Client/tauri-client/src/lib/noise-suppression.ts @@ -0,0 +1,320 @@ +// ============================================================================= +// Noise Suppression — RNNoise ML-based noise removal via Web Audio API +// +// Inserts between getUserMedia stream and the PeerConnection to clean audio. +// RNNoise processes 480-sample frames at 48kHz (10ms). +// +// Uses AudioWorklet (modern, runs on audio thread) with ScriptProcessorNode +// fallback (deprecated but widely supported). +// ============================================================================= + +import { createRNNWasmModule } from "@jitsi/rnnoise-wasm"; +import { createLogger } from "@lib/logger"; + +const log = createLogger("noise-suppression"); + +const RNNOISE_FRAME_SIZE = 480; +const SCRIPT_PROCESSOR_BUFFER = 4096; + +export interface NoiseSuppressor { + process(input: MediaStream): Promise<MediaStream>; + destroy(): void; +} + +// --------------------------------------------------------------------------- +// Shared WASM module cache (used by ScriptProcessorNode fallback) +// --------------------------------------------------------------------------- + +interface RNNoiseModule { + _rnnoise_create: () => number; + _rnnoise_destroy: (state: number) => void; + _rnnoise_process_frame: (state: number, out: number, inp: number) => number; + _malloc: (bytes: number) => number; + _free: (ptr: number) => void; + HEAPF32: Float32Array; + ready: Promise<unknown>; +} + +let cachedModule: RNNoiseModule | null = null; + +async function loadRNNoise(): Promise<RNNoiseModule> { + if (cachedModule !== null) return cachedModule; + const startMs = performance.now(); + const mod = (createRNNWasmModule as (opts: Record<string, unknown>) => unknown)({ + locateFile: (file: string) => { + if (file.endsWith(".wasm")) return "/rnnoise.wasm"; + return file; + }, + }) as RNNoiseModule; + await mod.ready; + cachedModule = mod; + log.info("RNNoise WASM loaded", { durationMs: Math.round(performance.now() - startMs) }); + return mod; +} + +// --------------------------------------------------------------------------- +// AudioWorklet-based suppressor (preferred, runs on audio thread) +// --------------------------------------------------------------------------- + +function createWorkletSuppressor(): NoiseSuppressor { + let audioContext: AudioContext | null = null; + let sourceNode: MediaStreamAudioSourceNode | null = null; + let destNode: MediaStreamAudioDestinationNode | null = null; + let workletNode: AudioWorkletNode | null = null; + let destroyed = false; + + return { + async process(input: MediaStream): Promise<MediaStream> { + if (destroyed) throw new Error("NoiseSuppressor destroyed"); + + audioContext = new AudioContext({ sampleRate: 48000 }); + + // Load the worklet processor module + await audioContext.audioWorklet.addModule("/rnnoise-worklet.js"); + + // Fetch WASM bytes to send to the worklet thread + const wasmResponse = await fetch("/rnnoise.wasm"); + const wasmBytes = await wasmResponse.arrayBuffer(); + + sourceNode = audioContext.createMediaStreamSource(input); + destNode = audioContext.createMediaStreamDestination(); + + workletNode = new AudioWorkletNode(audioContext, "rnnoise-processor", { + numberOfInputs: 1, + numberOfOutputs: 1, + outputChannelCount: [1], + }); + + // Wait for WASM init in the worklet + const initPromise = new Promise<void>((resolve, reject) => { + if (workletNode === null) { reject(new Error("No worklet")); return; } + workletNode.port.onmessage = (event: MessageEvent) => { + if (event.data.type === "ready") { + resolve(); + } else if (event.data.type === "error") { + reject(new Error(event.data.message)); + } + }; + }); + + // Send WASM bytes to the worklet for initialization + workletNode.port.postMessage({ type: "init", wasmBytes }, [wasmBytes]); + await initPromise; + + sourceNode.connect(workletNode); + workletNode.connect(destNode); + + log.info("RNNoise AudioWorklet processing active"); + return destNode.stream; + }, + + destroy(): void { + if (destroyed) return; + destroyed = true; + + if (workletNode !== null) { + workletNode.port.postMessage({ type: "destroy" }); + workletNode.disconnect(); + workletNode = null; + } + if (sourceNode !== null) { + sourceNode.disconnect(); + sourceNode = null; + } + if (destNode !== null) { + destNode.disconnect(); + destNode = null; + } + if (audioContext !== null) { + void audioContext.close(); + audioContext = null; + } + log.info("RNNoise AudioWorklet destroyed"); + }, + }; +} + +// --------------------------------------------------------------------------- +// ScriptProcessorNode fallback (deprecated but universal) +// --------------------------------------------------------------------------- + +function createScriptProcessorSuppressor(): NoiseSuppressor { + let audioContext: AudioContext | null = null; + let sourceNode: MediaStreamAudioSourceNode | null = null; + let destNode: MediaStreamAudioDestinationNode | null = null; + let processorNode: ScriptProcessorNode | null = null; + let rnnoiseState: number = 0; + let inputPtr: number = 0; + let outputPtr: number = 0; + let wasmModule: RNNoiseModule | null = null; + let destroyed = false; + + const inputRing = new Float32Array(RNNOISE_FRAME_SIZE); + let inputRingOffset = 0; + + const OUT_RING_CAPACITY = 50; + const outRing: Float32Array[] = new Array(OUT_RING_CAPACITY); + let outWriteIdx = 0; + let outReadIdx = 0; + let outCount = 0; + let outSampleOffset = 0; + + function processFrame(): void { + if (wasmModule === null) return; + const inOff = inputPtr / 4; + for (let i = 0; i < RNNOISE_FRAME_SIZE; i++) { + wasmModule.HEAPF32[inOff + i] = (inputRing[i] ?? 0) * 32768; + } + wasmModule._rnnoise_process_frame(rnnoiseState, outputPtr, inputPtr); + const outOff = outputPtr / 4; + const result = new Float32Array(RNNOISE_FRAME_SIZE); + for (let i = 0; i < RNNOISE_FRAME_SIZE; i++) { + result[i] = (wasmModule.HEAPF32[outOff + i] ?? 0) / 32768; + } + if (outCount >= OUT_RING_CAPACITY) { + outReadIdx = (outReadIdx + 1) % OUT_RING_CAPACITY; + outCount--; + outSampleOffset = 0; + } + outRing[outWriteIdx] = result; + outWriteIdx = (outWriteIdx + 1) % OUT_RING_CAPACITY; + outCount++; + } + + return { + async process(input: MediaStream): Promise<MediaStream> { + if (destroyed) throw new Error("NoiseSuppressor destroyed"); + + wasmModule = await loadRNNoise(); + rnnoiseState = wasmModule._rnnoise_create(); + inputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4); + outputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4); + + audioContext = new AudioContext({ sampleRate: 48000 }); + sourceNode = audioContext.createMediaStreamSource(input); + destNode = audioContext.createMediaStreamDestination(); + processorNode = audioContext.createScriptProcessor(SCRIPT_PROCESSOR_BUFFER, 1, 1); + + processorNode.onaudioprocess = (event: AudioProcessingEvent) => { + const inData = event.inputBuffer.getChannelData(0); + const outData = event.outputBuffer.getChannelData(0); + + let inIdx = 0; + while (inIdx < inData.length) { + const needed = RNNOISE_FRAME_SIZE - inputRingOffset; + const toCopy = Math.min(needed, inData.length - inIdx); + inputRing.set(inData.subarray(inIdx, inIdx + toCopy), inputRingOffset); + inputRingOffset += toCopy; + inIdx += toCopy; + if (inputRingOffset >= RNNOISE_FRAME_SIZE) { + processFrame(); + inputRingOffset = 0; + } + } + + let outIdx = 0; + while (outIdx < outData.length && outCount > 0) { + const chunk = outRing[outReadIdx]!; + const available = chunk.length - outSampleOffset; + const toWrite = Math.min(available, outData.length - outIdx); + outData.set(chunk.subarray(outSampleOffset, outSampleOffset + toWrite), outIdx); + outIdx += toWrite; + outSampleOffset += toWrite; + if (outSampleOffset >= chunk.length) { + outReadIdx = (outReadIdx + 1) % OUT_RING_CAPACITY; + outCount--; + outSampleOffset = 0; + } + } + if (outIdx < outData.length) { + outData.fill(0, outIdx); + } + }; + + sourceNode.connect(processorNode); + processorNode.connect(destNode); + + log.info("RNNoise ScriptProcessor processing active (fallback)"); + return destNode.stream; + }, + + destroy(): void { + if (destroyed) return; + destroyed = true; + + if (processorNode !== null) { + processorNode.onaudioprocess = null; + processorNode.disconnect(); + processorNode = null; + } + if (sourceNode !== null) { + sourceNode.disconnect(); + sourceNode = null; + } + if (destNode !== null) { + destNode.disconnect(); + destNode = null; + } + if (audioContext !== null) { + void audioContext.close(); + audioContext = null; + } + if (wasmModule !== null && rnnoiseState !== 0) { + wasmModule._rnnoise_destroy(rnnoiseState); + wasmModule._free(inputPtr); + wasmModule._free(outputPtr); + rnnoiseState = 0; + } + outWriteIdx = 0; + outReadIdx = 0; + outCount = 0; + outSampleOffset = 0; + log.info("RNNoise ScriptProcessor destroyed"); + }, + }; +} + +// --------------------------------------------------------------------------- +// Factory — tries AudioWorklet first, falls back to ScriptProcessorNode +// --------------------------------------------------------------------------- + +/** Check if AudioWorklet is available in this browser context. */ +function supportsAudioWorklet(): boolean { + try { + return typeof AudioWorkletNode !== "undefined" + && typeof AudioContext !== "undefined" + && "audioWorklet" in AudioContext.prototype; + } catch { + return false; + } +} + +export function createNoiseSuppressor(): NoiseSuppressor { + log.debug("Creating noise suppressor", { audioWorkletSupported: supportsAudioWorklet() }); + if (supportsAudioWorklet()) { + // Wrap in a facade that falls back to ScriptProcessor on failure + const worklet = createWorkletSuppressor(); + let fallback: NoiseSuppressor | null = null; + let activeSuppressor: NoiseSuppressor = worklet; + + return { + async process(input: MediaStream): Promise<MediaStream> { + try { + return await worklet.process(input); + } catch (err) { + log.warn("AudioWorklet failed, falling back to ScriptProcessorNode", err); + worklet.destroy(); + fallback = createScriptProcessorSuppressor(); + activeSuppressor = fallback; + return fallback.process(input); + } + }, + destroy(): void { + activeSuppressor.destroy(); + }, + }; + } + + log.info("AudioWorklet not supported, using ScriptProcessorNode"); + return createScriptProcessorSuppressor(); +} diff --git a/Client/tauri-client/src/lib/permissions.ts b/Client/tauri-client/src/lib/permissions.ts new file mode 100644 index 00000000..cb01da59 --- /dev/null +++ b/Client/tauri-client/src/lib/permissions.ts @@ -0,0 +1,57 @@ +import { Permission } from './types'; + +/** Bitmask with every permission bit set. */ +const ALL_PERMISSIONS = 0x7FFFFFFF; + +/** + * Returns true if `userPerms` includes the given permission bit. + * Users with the ADMINISTRATOR bit always pass. + */ +export function hasPermission(userPerms: number, perm: Permission): boolean { + if ((userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR) { + return true; + } + return (userPerms & perm) === perm; +} + +/** + * Returns true if `userPerms` includes **any** of the listed permissions. + * ADMINISTRATOR bit causes an automatic pass. + */ +export function hasAnyPermission(userPerms: number, ...perms: Permission[]): boolean { + if ((userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR) { + return true; + } + return perms.some((p) => (userPerms & p) === p); +} + +/** + * Returns true if `userPerms` includes **all** of the listed permissions. + * ADMINISTRATOR bit causes an automatic pass. + */ +export function hasAllPermissions(userPerms: number, ...perms: Permission[]): boolean { + if ((userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR) { + return true; + } + return perms.every((p) => (userPerms & p) === p); +} + +/** + * Compute effective permissions after applying channel-level overrides. + * + * - If the base permissions contain ADMINISTRATOR the result is all bits set + * (deny/allow are ignored). + * - Otherwise: start with `basePerms`, add `allow` bits, then remove `deny` bits. + * Deny takes precedence over allow. + */ +export function computeEffective(basePerms: number, allow: number, deny: number): number { + if ((basePerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR) { + return ALL_PERMISSIONS; + } + return (basePerms | allow) & ~deny; +} + +/** Shorthand check for the ADMINISTRATOR bit. */ +export function isAdministrator(userPerms: number): boolean { + return (userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR; +} diff --git a/Client/tauri-client/src/lib/profiles.ts b/Client/tauri-client/src/lib/profiles.ts new file mode 100644 index 00000000..6244093a --- /dev/null +++ b/Client/tauri-client/src/lib/profiles.ts @@ -0,0 +1,423 @@ +/** + * Server profiles management module. + * + * Manages saved server connection profiles for the OwnCord login page. + * Uses the createStore reactive pattern for state and Tauri invoke + * commands for persistence (mockable via dependency injection). + */ + +import { createStore, type Store } from "./store"; +import { fetch } from "@tauri-apps/plugin-http"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const STORAGE_KEY = "owncord:profiles"; +const CURRENT_SCHEMA_VERSION = 1; +const HEALTH_TIMEOUT_MS = 3000; +const SLOW_THRESHOLD_MS = 1500; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ServerProfile { + readonly id: string; + readonly name: string; + readonly host: string; + readonly username: string; + readonly autoConnect: boolean; + readonly rememberPassword: boolean; + readonly color: string; + readonly lastConnected: string | null; +} + +export interface HealthStatus { + readonly status: "online" | "slow" | "offline" | "checking"; + readonly latencyMs: number | null; + readonly version: string | null; +} + +export interface ProfilesState { + readonly profiles: readonly ServerProfile[]; + readonly healthStatuses: ReadonlyMap<string, HealthStatus>; +} + +export type CreateProfileData = Omit<ServerProfile, "id" | "lastConnected">; + +export type UpdateProfileData = Partial<Omit<ServerProfile, "id">>; + +/** Schema-versioned persistence envelope. */ +interface StoredData { + readonly schemaVersion: number; + readonly profiles: readonly ServerProfile[]; +} + +/** + * Persistence backend abstraction. + * In production, wraps Tauri `invoke("save_settings", ...)` / `invoke("get_settings")`. + * In tests, can be replaced with a synchronous Map-backed implementation. + */ +export interface PersistenceBackend { + load(): Promise<StoredData | null>; + save(data: StoredData): Promise<void>; +} + +/** + * Fetch function type matching the Tauri HTTP plugin signature. + * Allows injection of a mock in tests. + */ +export type FetchFn = typeof globalThis.fetch; + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +function isValidProfileShape(item: unknown): item is ServerProfile { + if (typeof item !== "object" || item === null) return false; + const obj = item as Record<string, unknown>; + return ( + typeof obj.id === "string" && + typeof obj.name === "string" && + obj.name.length > 0 && + typeof obj.host === "string" && + obj.host.length > 0 && + typeof obj.username === "string" && + typeof obj.color === "string" && + typeof obj.autoConnect === "boolean" && + (obj.rememberPassword === undefined || typeof obj.rememberPassword === "boolean") && + (obj.lastConnected === null || typeof obj.lastConnected === "string") + ); +} + +function isValidStoredData(data: unknown): data is StoredData { + if (typeof data !== "object" || data === null) return false; + const obj = data as Record<string, unknown>; + return ( + typeof obj.schemaVersion === "number" && + Array.isArray(obj.profiles) && + obj.profiles.every(isValidProfileShape) + ); +} + +// --------------------------------------------------------------------------- +// Default Tauri persistence backend +// --------------------------------------------------------------------------- + +export function createTauriBackend(): PersistenceBackend { + return { + async load(): Promise<StoredData | null> { + const { invoke } = await import("@tauri-apps/api/core"); + const settings = (await invoke("get_settings")) as Record< + string, + unknown + >; + const raw = settings[STORAGE_KEY]; + if (raw === undefined || raw === null) return null; + if (isValidStoredData(raw)) return raw; + return null; + }, + async save(data: StoredData): Promise<void> { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("save_settings", { key: STORAGE_KEY, value: data }); + }, + }; +} + +// --------------------------------------------------------------------------- +// Profile Manager +// --------------------------------------------------------------------------- + +export interface ProfileManager { + /** Reactive store — subscribe for state changes. */ + readonly store: Store<ProfilesState>; + + /** Load profiles from persistence backend into store. */ + loadProfiles(): Promise<void>; + + /** Save current profiles to persistence backend. */ + saveProfiles(): Promise<void>; + + /** Get all profiles (snapshot). */ + getAll(): readonly ServerProfile[]; + + /** Get a profile by id. */ + getById(id: string): ServerProfile | null; + + /** Add a new profile. Returns the created profile. */ + addProfile(data: CreateProfileData): ServerProfile; + + /** Update an existing profile. Returns updated profile or null if not found. */ + updateProfile(id: string, data: UpdateProfileData): ServerProfile | null; + + /** Remove a profile by id. Returns true if removed. */ + removeProfile(id: string): boolean; + + /** Returns the first profile with autoConnect=true, or null. */ + getAutoConnectProfile(): ServerProfile | null; + + /** Set lastConnected to current ISO timestamp. */ + setLastConnected(id: string): void; + + /** Check health of a single profile by id. Updates healthStatuses. */ + checkHealth(profileId: string): Promise<HealthStatus>; + + /** Check health of all profiles in parallel. Updates healthStatuses. */ + checkAllHealth(): Promise<ReadonlyMap<string, HealthStatus>>; + + /** Export all profiles as a JSON string. */ + exportProfiles(): string; + + /** Import profiles from JSON string. Merges by host (skips duplicates). */ + importProfiles(json: string): { imported: number; skipped: number }; +} + +export function createProfileManager( + backend: PersistenceBackend, + fetchFn?: FetchFn, +): ProfileManager { + const initialState: ProfilesState = { + profiles: [], + healthStatuses: new Map(), + }; + + const store = createStore<ProfilesState>(initialState); + + // Resolve which fetch to use: injected mock, Tauri plugin, or global + const doFetch: FetchFn = fetchFn ?? (fetch as unknown as FetchFn); + + // ── Helpers ──────────────────────────────────────────────── + + function currentProfiles(): readonly ServerProfile[] { + return store.getState().profiles; + } + + function setProfiles(profiles: readonly ServerProfile[]): void { + store.setState((prev) => ({ + ...prev, + profiles, + })); + } + + function setHealthStatus(profileId: string, status: HealthStatus): void { + store.setState((prev) => { + const next = new Map(prev.healthStatuses); + next.set(profileId, status); + return { ...prev, healthStatuses: next }; + }); + } + + function toStoredData(): StoredData { + return { + schemaVersion: CURRENT_SCHEMA_VERSION, + profiles: [...currentProfiles()], + }; + } + + // ── Health check implementation ──────────────────────────── + + async function pingHost(host: string): Promise<HealthStatus> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); + const start = performance.now(); + + try { + const res = await doFetch(`https://${host}/api/v1/health`, { + signal: controller.signal, + }); + const elapsed = Math.round(performance.now() - start); + + if (!res.ok) { + return { status: "offline", latencyMs: elapsed, version: null }; + } + + const body = (await res.json()) as { version?: string }; + const version = typeof body.version === "string" ? body.version : null; + const status = elapsed > SLOW_THRESHOLD_MS ? "slow" : "online"; + + return { status, latencyMs: elapsed, version }; + } catch { + return { status: "offline", latencyMs: null, version: null }; + } finally { + clearTimeout(timer); + } + } + + // ── Public API ───────────────────────────────────────────── + + const manager: ProfileManager = { + store, + + async loadProfiles(): Promise<void> { + const data = await backend.load(); + if (data !== null) { + setProfiles(data.profiles); + } + }, + + async saveProfiles(): Promise<void> { + await backend.save(toStoredData()); + }, + + getAll(): readonly ServerProfile[] { + return [...currentProfiles()]; + }, + + getById(id: string): ServerProfile | null { + return currentProfiles().find((p) => p.id === id) ?? null; + }, + + addProfile(data: CreateProfileData): ServerProfile { + const profile: ServerProfile = { + ...data, + id: crypto.randomUUID(), + lastConnected: null, + }; + setProfiles([...currentProfiles(), profile]); + return profile; + }, + + updateProfile(id: string, data: UpdateProfileData): ServerProfile | null { + const profiles = currentProfiles(); + const index = profiles.findIndex((p) => p.id === id); + if (index === -1) return null; + + const existing = profiles[index]!; + const updated: ServerProfile = { ...existing, ...data }; + setProfiles(profiles.map((p) => (p.id === id ? updated : p))); + return updated; + }, + + removeProfile(id: string): boolean { + const profiles = currentProfiles(); + const filtered = profiles.filter((p) => p.id !== id); + if (filtered.length === profiles.length) return false; + setProfiles(filtered); + return true; + }, + + getAutoConnectProfile(): ServerProfile | null { + return currentProfiles().find((p) => p.autoConnect) ?? null; + }, + + setLastConnected(id: string): void { + const profiles = currentProfiles(); + const index = profiles.findIndex((p) => p.id === id); + if (index === -1) return; + + const existing = profiles[index]!; + const updated: ServerProfile = { + ...existing, + lastConnected: new Date().toISOString(), + }; + setProfiles(profiles.map((p) => (p.id === id ? updated : p))); + }, + + async checkHealth(profileId: string): Promise<HealthStatus> { + const profile = currentProfiles().find((p) => p.id === profileId); + if (!profile) { + const offline: HealthStatus = { + status: "offline", + latencyMs: null, + version: null, + }; + return offline; + } + + setHealthStatus(profileId, { + status: "checking", + latencyMs: null, + version: null, + }); + + const result = await pingHost(profile.host); + setHealthStatus(profileId, result); + return result; + }, + + async checkAllHealth(): Promise<ReadonlyMap<string, HealthStatus>> { + const profiles = currentProfiles(); + + // Set all to "checking" first + for (const profile of profiles) { + setHealthStatus(profile.id, { + status: "checking", + latencyMs: null, + version: null, + }); + } + + // Ping all in parallel + const results = await Promise.all( + profiles.map(async (profile) => { + const result = await pingHost(profile.host); + setHealthStatus(profile.id, result); + return [profile.id, result] as const; + }), + ); + + return new Map(results); + }, + + exportProfiles(): string { + return JSON.stringify(toStoredData()); + }, + + importProfiles(json: string): { imported: number; skipped: number } { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return { imported: 0, skipped: 0 }; + } + + // Accept either StoredData envelope or a bare array + let incoming: unknown[]; + if (isValidStoredData(parsed)) { + incoming = [...parsed.profiles]; + } else if (Array.isArray(parsed)) { + incoming = parsed; + } else { + return { imported: 0, skipped: 0 }; + } + + const existingHosts = new Set(currentProfiles().map((p) => p.host)); + let imported = 0; + let skipped = 0; + const newProfiles: ServerProfile[] = []; + + for (const raw of incoming) { + if (!isValidProfileShape(raw)) { + skipped++; + continue; + } + if (existingHosts.has(raw.host)) { + skipped++; + } else { + const profile: ServerProfile = { + id: crypto.randomUUID(), + name: raw.name, + host: raw.host, + username: raw.username, + color: raw.color, + autoConnect: raw.autoConnect, + rememberPassword: raw.rememberPassword ?? false, + lastConnected: null, + }; + newProfiles.push(profile); + existingHosts.add(profile.host); + imported++; + } + } + + if (imported > 0) { + setProfiles([...currentProfiles(), ...newProfiles]); + } + + return { imported, skipped }; + }, + }; + + return manager; +} diff --git a/Client/tauri-client/src/lib/rate-limiter.ts b/Client/tauri-client/src/lib/rate-limiter.ts new file mode 100644 index 00000000..4d05ec63 --- /dev/null +++ b/Client/tauri-client/src/lib/rate-limiter.ts @@ -0,0 +1,201 @@ +/** + * Window-based rate limiter with per-key tracking. + * + * Uses a sliding window algorithm: each key stores an array of timestamps. + * Expired entries are pruned on every public call. No external dependencies. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface RateLimiterConfig { + /** Maximum number of actions allowed per window. */ + readonly maxTokens: number; + /** Window duration in milliseconds. */ + readonly windowMs: number; +} + +interface KeyState { + readonly timestamps: readonly number[]; +} + +// --------------------------------------------------------------------------- +// Default key used when callers omit the key argument +// --------------------------------------------------------------------------- + +const DEFAULT_KEY = "__default__" as const; + +// --------------------------------------------------------------------------- +// RateLimiter +// --------------------------------------------------------------------------- + +export class RateLimiter { + private readonly config: Readonly<RateLimiterConfig>; + private state: ReadonlyMap<string, KeyState>; + + constructor(config: RateLimiterConfig) { + if (config.maxTokens < 1) { + throw new Error("maxTokens must be >= 1"); + } + if (config.windowMs < 1) { + throw new Error("windowMs must be >= 1"); + } + this.config = Object.freeze({ ...config }); + this.state = new Map(); + } + + /** + * Attempt to consume one token for the given key. + * Returns `true` if the action is allowed, `false` if rate-limited. + */ + tryConsume(key?: string): boolean { + const k = key ?? DEFAULT_KEY; + const now = Date.now(); + const cleaned = this.pruneAll(now); + const entry = cleaned.get(k); + const timestamps = entry?.timestamps ?? []; + + if (timestamps.length >= this.config.maxTokens) { + this.state = cleaned; + return false; + } + + const newEntry: KeyState = { timestamps: [...timestamps, now] }; + const next = new Map(cleaned); + next.set(k, Object.freeze(newEntry)); + this.state = next; + return true; + } + + /** Reset state for a single key (or the default key when omitted). */ + reset(key?: string): void { + const k = key ?? DEFAULT_KEY; + const next = new Map(this.state); + next.delete(k); + this.state = next; + } + + /** Clear all tracked state across every key. */ + resetAll(): void { + this.state = new Map(); + } + + /** + * Returns milliseconds until the next request would be allowed for the key. + * Returns 0 if a request is allowed right now. + */ + getRemainingMs(key?: string): number { + const k = key ?? DEFAULT_KEY; + const now = Date.now(); + const cleaned = this.pruneAll(now); + this.state = cleaned; + + const entry = cleaned.get(k); + const timestamps = entry?.timestamps ?? []; + + if (timestamps.length < this.config.maxTokens) { + return 0; + } + + const oldest = timestamps[0]; + if (oldest === undefined) { + return 0; + } + return Math.max(0, oldest + this.config.windowMs - now); + } + + /** Return a new map with expired timestamps removed from every key. */ + private pruneAll(now: number): ReadonlyMap<string, KeyState> { + const cutoff = now - this.config.windowMs; + const next = new Map<string, KeyState>(); + + for (const [key, entry] of this.state) { + const filtered = entry.timestamps.filter((t) => t > cutoff); + if (filtered.length > 0) { + next.set(key, Object.freeze({ timestamps: filtered })); + } + } + + return next; + } +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create a `RateLimiter` from explicit config values. + * + * @param maxTokens Maximum actions per window. + * @param windowMs Window length in milliseconds. + */ +export function createRateLimiter(maxTokens: number, windowMs: number): RateLimiter { + return new RateLimiter({ maxTokens, windowMs }); +} + +// --------------------------------------------------------------------------- +// Pre-configured limiters (PROTOCOL.md - Rate Limits) +// --------------------------------------------------------------------------- + +/** Chat messages: 10 per second. */ +export function createChatLimiter(): RateLimiter { + return createRateLimiter(10, 1_000); +} + +/** Typing events: 1 per 3 seconds (use channel id as key). */ +export function createTypingLimiter(): RateLimiter { + return createRateLimiter(1, 3_000); +} + +/** Presence updates: 1 per 10 seconds. */ +export function createPresenceLimiter(): RateLimiter { + return createRateLimiter(1, 10_000); +} + +/** Reactions: 5 per second. */ +export function createReactionLimiter(): RateLimiter { + return createRateLimiter(5, 1_000); +} + +/** Voice signaling: 20 per second. */ +export function createVoiceLimiter(): RateLimiter { + return createRateLimiter(20, 1_000); +} + +/** Voice camera / screenshare toggle: 2 per second. */ +export function createVideoCameraLimiter(): RateLimiter { + return createRateLimiter(2, 1_000); +} + +/** Soundboard: 1 per 3 seconds. */ +export function createSoundboardLimiter(): RateLimiter { + return createRateLimiter(1, 3_000); +} + +// --------------------------------------------------------------------------- +// Bundled set of all protocol limiters +// --------------------------------------------------------------------------- + +export interface RateLimiterSet { + readonly chat: RateLimiter; + readonly typing: RateLimiter; + readonly presence: RateLimiter; + readonly reactions: RateLimiter; + readonly voice: RateLimiter; + readonly voiceVideo: RateLimiter; + readonly soundboard: RateLimiter; +} + +export function createRateLimiterSet(): RateLimiterSet { + return Object.freeze({ + chat: createChatLimiter(), + typing: createTypingLimiter(), + presence: createPresenceLimiter(), + reactions: createReactionLimiter(), + voice: createVoiceLimiter(), + voiceVideo: createVideoCameraLimiter(), + soundboard: createSoundboardLimiter(), + }); +} diff --git a/Client/tauri-client/src/lib/router.ts b/Client/tauri-client/src/lib/router.ts new file mode 100644 index 00000000..28c4d644 --- /dev/null +++ b/Client/tauri-client/src/lib/router.ts @@ -0,0 +1,52 @@ +// Router — simple page router for desktop SPA (no URL routing). +// Tracks current page and notifies listeners. Does NOT manipulate the DOM. + +export type PageId = "connect" | "main"; + +export type NavigateListener = (page: PageId) => void; + +export interface Router { + /** Navigate to a page. Notifies all listeners if the page changed. */ + navigate(page: PageId): void; + + /** Returns the currently active page. */ + getCurrentPage(): PageId; + + /** + * Register a listener that fires when the page changes. + * Returns an unsubscribe function. + */ + onNavigate(listener: NavigateListener): () => void; +} + +/** + * Create a new router instance. + * @param initialPage - The page to start on (defaults to "connect"). + */ +export function createRouter(initialPage: PageId = "connect"): Router { + let currentPage: PageId = initialPage; + const listeners: Set<NavigateListener> = new Set(); + + function navigate(page: PageId): void { + if (page === currentPage) { + return; + } + currentPage = page; + for (const listener of listeners) { + listener(page); + } + } + + function getCurrentPage(): PageId { + return currentPage; + } + + function onNavigate(listener: NavigateListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } + + return { navigate, getCurrentPage, onNavigate }; +} diff --git a/Client/tauri-client/src/lib/safe-render.ts b/Client/tauri-client/src/lib/safe-render.ts new file mode 100644 index 00000000..5193edf9 --- /dev/null +++ b/Client/tauri-client/src/lib/safe-render.ts @@ -0,0 +1,85 @@ +// Step 1.13 — Error boundary / safe render utility + +import { createLogger } from "./logger"; + +const log = createLogger("safe-render"); + +/** + * Minimal component interface for safe mounting. + */ +export interface MountableComponent { + mount(container: Element): void; + destroy?(): void; +} + +/** + * Safely mount a component, catching any errors during rendering. + * On failure, displays a fallback UI instead of crashing the app. + */ +export function safeMount( + component: MountableComponent, + container: Element, +): void { + try { + component.mount(container); + } catch (err) { + log.error("Component mount failed", err); + renderFallback(container, err); + } +} + +/** + * Render a minimal fallback UI when a component fails. + */ +function renderFallback(container: Element, error: unknown): void { + container.textContent = ""; + const fallback = document.createElement("div"); + fallback.style.cssText = + "padding:16px;color:#f23f43;background:#2b2d31;border-radius:8px;font-size:13px;margin:8px;"; + fallback.textContent = "Something went wrong rendering this section."; + container.appendChild(fallback); + + // Log the actual error for debugging + if (error instanceof Error) { + const detail = document.createElement("pre"); + detail.style.cssText = + "color:#949ba4;font-size:11px;margin-top:8px;white-space:pre-wrap;word-break:break-all;"; + detail.textContent = error.message; + fallback.appendChild(detail); + } +} + +/** + * Install global error handlers. + * Call once at app startup. + */ +export function installGlobalErrorHandlers(): void { + window.addEventListener("error", (event) => { + log.error("Uncaught error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error instanceof Error ? event.error.stack : String(event.error), + }); + }); + + window.addEventListener("unhandledrejection", (event) => { + const reason = + event.reason instanceof Error + ? event.reason.stack ?? event.reason.message + : String(event.reason); + + // Tauri plugin-http GC cleanup: when a consumed Response body is finalized, + // Tauri tries to drop the Rust resource which may already be freed. + // This is cosmetic — downgrade to debug instead of polluting error logs. + if (typeof reason === "string" && /resource id .+ is invalid/.test(reason)) { + log.debug("Tauri resource already freed (benign)", { reason }); + return; + } + + log.error("Unhandled promise rejection", { reason }); + }); + + log.info("Global error handlers installed"); +} diff --git a/Client/tauri-client/src/lib/store.ts b/Client/tauri-client/src/lib/store.ts new file mode 100644 index 00000000..b53009ae --- /dev/null +++ b/Client/tauri-client/src/lib/store.ts @@ -0,0 +1,74 @@ +/** + * Generic reactive store foundation for OwnCord Tauri client. + * Immutable state updates only — setState receives an updater + * that must return a NEW state object. + */ + +export interface Store<T> { + /** Returns the current state (immutable reference). */ + getState(): T; + + /** + * Update state via an updater function. Subscriber notifications are + * batched via queueMicrotask — multiple rapid setState calls result + * in a single notification with the final state. + */ + setState(updater: (prev: T) => T): void; + + /** + * Subscribe to state changes. The listener receives the new state + * after every setState batch. Returns an unsubscribe function. + */ + subscribe(listener: (state: T) => void): () => void; + + /** Derive a value from the current state using a selector function. */ + select<S>(selector: (state: T) => S): S; + + /** Flush pending notifications synchronously (useful in tests). */ + flush(): void; +} + +export function createStore<T>(initialState: T): Store<T> { + let state: T = initialState; + const listeners: Set<(state: T) => void> = new Set(); + let notifyScheduled = false; + + function getState(): T { + return state; + } + + function setState(updater: (prev: T) => T): void { + state = updater(state); + if (!notifyScheduled) { + notifyScheduled = true; + queueMicrotask(() => { + notifyScheduled = false; + for (const listener of listeners) { + listener(state); + } + }); + } + } + + function subscribe(listener: (state: T) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } + + function select<S>(selector: (state: T) => S): S { + return selector(state); + } + + function flush(): void { + if (notifyScheduled) { + notifyScheduled = false; + for (const listener of listeners) { + listener(state); + } + } + } + + return { getState, setState, subscribe, select, flush }; +} diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts new file mode 100644 index 00000000..029fe263 --- /dev/null +++ b/Client/tauri-client/src/lib/types.ts @@ -0,0 +1,596 @@ +// ============================================================================= +// OwnCord Protocol Types +// All WebSocket message types, REST response types, and permission definitions. +// Source of truth: PROTOCOL.md, API.md, SCHEMA.md +// ============================================================================= + +// ----------------------------------------------------------------------------- +// Common / Shared Types +// ----------------------------------------------------------------------------- + +/** Status values allowed by the protocol. */ +export type UserStatus = "online" | "idle" | "dnd" | "offline"; + +/** Channel types supported by the server. */ +export type ChannelType = "text" | "voice" | "announcement"; + +/** Voice quality presets. */ +export type VoiceQuality = "low" | "medium" | "high"; + +/** Voice threshold mode. CRITICAL: always "threshold_mode", never "mode". */ +export type ThresholdMode = "forwarding" | "selective"; + +/** Reaction action direction. */ +export type ReactionAction = "add" | "remove"; + +/** WebSocket error codes returned by the server. */ +export type WsErrorCode = + | "FORBIDDEN" + | "NOT_FOUND" + | "RATE_LIMITED" + | "INVALID_INPUT" + | "SERVER_ERROR" + | "CHANNEL_FULL" + | "INVALID_SDP" + | "VOICE_ERROR" + | "VIDEO_LIMIT"; + +/** REST API error codes. */ +export type ApiErrorCode = + | "UNAUTHORIZED" + | "FORBIDDEN" + | "NOT_FOUND" + | "RATE_LIMITED" + | "INVALID_INPUT" + | "CONFLICT" + | "TOO_LARGE" + | "SERVER_ERROR" + | "UNKNOWN"; + +// ----------------------------------------------------------------------------- +// Embedded Objects (used inside payloads) +// ----------------------------------------------------------------------------- + +/** Minimal user object embedded in messages and member payloads. */ +export interface MessageUser { + readonly id: number; + readonly username: string; + readonly avatar: string | null; +} + +/** User object with role, used in auth_ok and member_join. */ +export interface UserWithRole extends MessageUser { + readonly role: string; +} + +/** Attachment on a chat message. */ +export interface Attachment { + readonly id: string; + readonly filename: string; + readonly size: number; + readonly mime: string; + readonly url: string; +} + +/** Reaction summary on a REST message response. */ +export interface ReactionSummary { + readonly emoji: string; + readonly count: number; + readonly me: boolean; +} + +// ----------------------------------------------------------------------------- +// Ready Payload Nested Objects +// ----------------------------------------------------------------------------- + +/** Channel object in the ready payload. */ +export interface ReadyChannel { + readonly id: number; + readonly name: string; + readonly type: ChannelType; + readonly category: string | null; + readonly position: number; + readonly unread_count?: number; + readonly last_message_id?: number; +} + +/** Member object in the ready payload. */ +export interface ReadyMember { + readonly id: number; + readonly username: string; + readonly avatar: string | null; + readonly role: string; + readonly status: UserStatus; +} + +/** Voice state object in the ready payload. */ +export interface ReadyVoiceState { + readonly channel_id: number; + readonly user_id: number; + readonly muted: boolean; + readonly deafened: boolean; +} + +/** Role object in the ready payload. */ +export interface ReadyRole { + readonly id: number; + readonly name: string; + readonly color: string | null; + readonly permissions: number; +} + +// ----------------------------------------------------------------------------- +// Permission Bitfield (from SCHEMA.md) +// ----------------------------------------------------------------------------- + +export enum Permission { + SEND_MESSAGES = 0x1, + READ_MESSAGES = 0x2, + ATTACH_FILES = 0x20, + ADD_REACTIONS = 0x40, + USE_SOUNDBOARD = 0x100, + CONNECT_VOICE = 0x200, + SPEAK_VOICE = 0x400, + USE_VIDEO = 0x800, + SHARE_SCREEN = 0x1000, + MANAGE_MESSAGES = 0x10000, + MANAGE_CHANNELS = 0x20000, + KICK_MEMBERS = 0x40000, + BAN_MEMBERS = 0x80000, + MUTE_MEMBERS = 0x100000, + MANAGE_ROLES = 0x1000000, + MANAGE_SERVER = 0x2000000, + MANAGE_INVITES = 0x4000000, + VIEW_AUDIT_LOG = 0x8000000, + ADMINISTRATOR = 0x40000000, +} + +// ----------------------------------------------------------------------------- +// WebSocket Envelope +// ----------------------------------------------------------------------------- + +/** Generic WebSocket message envelope. */ +export interface WsEnvelope<T> { + readonly type: string; + readonly id?: string; + readonly payload: T; +} + +// ----------------------------------------------------------------------------- +// Server → Client Payloads +// ----------------------------------------------------------------------------- + +export interface AuthOkPayload { + readonly user: UserWithRole; + readonly server_name: string; + readonly motd: string; +} + +export interface AuthErrorPayload { + readonly message: string; +} + +export interface ReadyPayload { + readonly channels: readonly ReadyChannel[]; + readonly members: readonly ReadyMember[]; + readonly voice_states: readonly ReadyVoiceState[]; + readonly roles: readonly ReadyRole[]; +} + +export interface ChatMessagePayload { + readonly id: number; + readonly channel_id: number; + readonly user: MessageUser; + readonly content: string; + readonly reply_to: number | null; + readonly attachments: readonly Attachment[]; + readonly timestamp: string; +} + +export interface ChatSendOkPayload { + readonly message_id: number; + readonly timestamp: string; +} + +export interface ChatEditedPayload { + readonly message_id: number; + readonly channel_id: number; + readonly content: string; + readonly edited_at: string; +} + +export interface ChatDeletedPayload { + readonly message_id: number; + readonly channel_id: number; +} + +export interface ReactionUpdatePayload { + readonly message_id: number; + readonly channel_id: number; + readonly emoji: string; + readonly user_id: number; + readonly action: ReactionAction; +} + +export interface TypingPayload { + readonly channel_id: number; + readonly user_id: number; + readonly username: string; +} + +export interface PresencePayload { + readonly user_id: number; + readonly status: UserStatus; +} + +export interface ChannelCreatePayload { + readonly id: number; + readonly name: string; + readonly type: ChannelType; + readonly category: string | null; + readonly position: number; +} + +export interface ChannelUpdatePayload { + readonly id: number; + readonly name?: string; + readonly position?: number; +} + +export interface ChannelDeletePayload { + readonly id: number; +} + +export interface VoiceStatePayload { + readonly channel_id: number; + readonly user_id: number; + readonly username: string; + readonly muted: boolean; + readonly deafened: boolean; + readonly speaking: boolean; + readonly camera: boolean; + readonly screenshare: boolean; +} + +export interface VoiceLeavePayload { + readonly channel_id: number; + readonly user_id: number; +} + +/** CRITICAL: uses threshold_mode, NOT mode. */ +export interface VoiceConfigPayload { + readonly channel_id: number; + readonly quality: VoiceQuality; + readonly bitrate: number; + readonly threshold_mode: ThresholdMode; + readonly mixing_threshold: number; + readonly top_speakers: number; + readonly max_users: number; +} + +/** CRITICAL: uses threshold_mode, NOT mode. */ +export interface VoiceSpeakersPayload { + readonly channel_id: number; + readonly speakers: readonly number[]; + readonly threshold_mode: ThresholdMode; +} + +export interface VoiceOfferPayload { + readonly channel_id: number; + readonly sdp: string; +} + +export interface VoiceAnswerPayload { + readonly channel_id: number; + readonly sdp: string; +} + +export interface VoiceIcePayload { + readonly channel_id: number; + readonly candidate: RTCIceCandidateInit; +} + +export interface MemberJoinPayload { + readonly user: UserWithRole; +} + +export interface MemberLeavePayload { + readonly user_id: number; +} + +export interface MemberUpdatePayload { + readonly user_id: number; + readonly role: string; +} + +export interface MemberBanPayload { + readonly user_id: number; +} + +export interface ServerRestartPayload { + readonly reason: string; + readonly delay_seconds: number; +} + +export interface ErrorPayload { + readonly code: WsErrorCode; + readonly message: string; +} + +// ----------------------------------------------------------------------------- +// Client → Server Payloads +// ----------------------------------------------------------------------------- + +export interface AuthPayload { + readonly token: string; +} + +export interface ChatSendPayload { + readonly channel_id: number; + readonly content: string; + readonly reply_to: number | null; + readonly attachments: readonly string[]; +} + +export interface ChatEditPayload { + readonly message_id: number; + readonly content: string; +} + +export interface ChatDeletePayload { + readonly message_id: number; +} + +export interface ReactionAddPayload { + readonly message_id: number; + readonly emoji: string; +} + +export interface ReactionRemovePayload { + readonly message_id: number; + readonly emoji: string; +} + +export interface TypingStartPayload { + readonly channel_id: number; +} + +export interface ChannelFocusPayload { + readonly channel_id: number; +} + +export interface PresenceUpdatePayload { + readonly status: UserStatus; +} + +export interface VoiceJoinPayload { + readonly channel_id: number; +} + +/** Client → Server: leave current voice channel (no payload needed). */ +export type VoiceLeaveClientPayload = Record<string, never>; + +export interface VoiceMutePayload { + readonly muted: boolean; +} + +export interface VoiceDeafenPayload { + readonly deafened: boolean; +} + +export interface VoiceCameraPayload { + readonly enabled: boolean; +} + +export interface VoiceScreensharePayload { + readonly enabled: boolean; +} + +export interface SoundboardPlayPayload { + readonly sound_id: string; +} + +// Note: VoiceOfferPayload, VoiceAnswerPayload, VoiceIcePayload are +// bidirectional — the same interface is used for both client→server +// and server→client directions. See definitions above. + +// ----------------------------------------------------------------------------- +// Discriminated Union: Server → Client Messages +// ----------------------------------------------------------------------------- + +export type ServerMessage = + | (WsEnvelope<AuthOkPayload> & { readonly type: "auth_ok" }) + | (WsEnvelope<AuthErrorPayload> & { readonly type: "auth_error" }) + | (WsEnvelope<ReadyPayload> & { readonly type: "ready" }) + | (WsEnvelope<ChatMessagePayload> & { readonly type: "chat_message" }) + | (WsEnvelope<ChatSendOkPayload> & { readonly type: "chat_send_ok" }) + | (WsEnvelope<ChatEditedPayload> & { readonly type: "chat_edited" }) + | (WsEnvelope<ChatDeletedPayload> & { readonly type: "chat_deleted" }) + | (WsEnvelope<ReactionUpdatePayload> & { readonly type: "reaction_update" }) + | (WsEnvelope<TypingPayload> & { readonly type: "typing" }) + | (WsEnvelope<PresencePayload> & { readonly type: "presence" }) + | (WsEnvelope<ChannelCreatePayload> & { readonly type: "channel_create" }) + | (WsEnvelope<ChannelUpdatePayload> & { readonly type: "channel_update" }) + | (WsEnvelope<ChannelDeletePayload> & { readonly type: "channel_delete" }) + | (WsEnvelope<VoiceStatePayload> & { readonly type: "voice_state" }) + | (WsEnvelope<VoiceLeavePayload> & { readonly type: "voice_leave" }) + | (WsEnvelope<VoiceConfigPayload> & { readonly type: "voice_config" }) + | (WsEnvelope<VoiceSpeakersPayload> & { readonly type: "voice_speakers" }) + | (WsEnvelope<VoiceOfferPayload> & { readonly type: "voice_offer" }) + | (WsEnvelope<VoiceAnswerPayload> & { readonly type: "voice_answer" }) + | (WsEnvelope<VoiceIcePayload> & { readonly type: "voice_ice" }) + | (WsEnvelope<MemberJoinPayload> & { readonly type: "member_join" }) + | (WsEnvelope<MemberLeavePayload> & { readonly type: "member_leave" }) + | (WsEnvelope<MemberUpdatePayload> & { readonly type: "member_update" }) + | (WsEnvelope<MemberBanPayload> & { readonly type: "member_ban" }) + | (WsEnvelope<ServerRestartPayload> & { readonly type: "server_restart" }) + | (WsEnvelope<ErrorPayload> & { readonly type: "error" }); + +// ----------------------------------------------------------------------------- +// Discriminated Union: Client → Server Messages +// ----------------------------------------------------------------------------- + +export type ClientMessage = + | (WsEnvelope<AuthPayload> & { readonly type: "auth" }) + | (WsEnvelope<ChatSendPayload> & { readonly type: "chat_send" }) + | (WsEnvelope<ChatEditPayload> & { readonly type: "chat_edit" }) + | (WsEnvelope<ChatDeletePayload> & { readonly type: "chat_delete" }) + | (WsEnvelope<ReactionAddPayload> & { readonly type: "reaction_add" }) + | (WsEnvelope<ReactionRemovePayload> & { readonly type: "reaction_remove" }) + | (WsEnvelope<TypingStartPayload> & { readonly type: "typing_start" }) + | (WsEnvelope<ChannelFocusPayload> & { readonly type: "channel_focus" }) + | (WsEnvelope<PresenceUpdatePayload> & { readonly type: "presence_update" }) + | (WsEnvelope<VoiceJoinPayload> & { readonly type: "voice_join" }) + | (WsEnvelope<VoiceLeaveClientPayload> & { readonly type: "voice_leave" }) + | (WsEnvelope<VoiceMutePayload> & { readonly type: "voice_mute" }) + | (WsEnvelope<VoiceDeafenPayload> & { readonly type: "voice_deafen" }) + | (WsEnvelope<VoiceCameraPayload> & { readonly type: "voice_camera" }) + | (WsEnvelope<VoiceScreensharePayload> & { readonly type: "voice_screenshare" }) + | (WsEnvelope<SoundboardPlayPayload> & { readonly type: "soundboard_play" }) + | (WsEnvelope<VoiceOfferPayload> & { readonly type: "voice_offer" }) + | (WsEnvelope<VoiceAnswerPayload> & { readonly type: "voice_answer" }) + | (WsEnvelope<VoiceIcePayload> & { readonly type: "voice_ice" }); + +// ----------------------------------------------------------------------------- +// REST API Response Types +// ----------------------------------------------------------------------------- + +/** POST /api/auth/login response. */ +export interface AuthResponse { + readonly token?: string; + readonly partial_token?: string; + readonly requires_2fa: boolean; +} + +/** POST /api/auth/register response. */ +export interface RegisterResponse { + readonly user: { readonly id: number; readonly username: string }; + readonly token: string; +} + +/** GET /api/health response. */ +export interface HealthResponse { + readonly status: string; + readonly version: string; + readonly uptime: number; +} + +/** Single channel object from REST API. */ +export interface ChannelResponse { + readonly id: number; + readonly name: string; + readonly type: ChannelType; + readonly category: string | null; + readonly position: number; +} + +/** Single message object from GET /api/channels/{id}/messages. */ +export interface MessageResponse { + readonly id: number; + readonly channel_id: number; + readonly user: MessageUser; + readonly content: string; + readonly reply_to: number | null; + readonly attachments: readonly Attachment[]; + readonly reactions: readonly ReactionSummary[]; + readonly pinned: boolean; + readonly edited_at: string | null; + readonly deleted: boolean; + readonly timestamp: string; +} + +/** Paginated messages response. */ +export interface MessagesResponse { + readonly messages: readonly MessageResponse[]; + readonly has_more: boolean; +} + +/** Member object from REST API. */ +export interface MemberResponse { + readonly id: number; + readonly username: string; + readonly avatar: string | null; + readonly role: string; + readonly status: UserStatus; +} + +/** Search result item. */ +export interface SearchResultItem { + readonly message_id: number; + readonly channel_id: number; + readonly channel_name: string; + readonly user: MessageUser; + readonly content: string; + readonly timestamp: string; +} + +/** GET /api/search response. */ +export interface SearchResponse { + readonly results: readonly SearchResultItem[]; +} + +/** REST API error response body. */ +export interface ApiError { + readonly error: ApiErrorCode; + readonly message: string; +} + +/** Single emoji object from GET /api/emoji. */ +export interface EmojiResponse { + readonly id: number; + readonly shortcode: string; + readonly filename: string; + readonly uploaded_by: number; + readonly created_at: string; +} + +/** Single sound object from GET /api/sounds. */ +export interface SoundResponse { + readonly id: number; + readonly name: string; + readonly filename: string; + readonly duration_ms: number; + readonly uploaded_by: number; + readonly created_at: string; +} + +/** Single invite object from GET/POST /api/invites. */ +export interface InviteResponse { + readonly id: number; + readonly code: string; + readonly url: string; + readonly max_uses: number | null; + readonly use_count?: number; + readonly expires_at: string | null; +} + +/** Single session object from GET /api/users/me/sessions. */ +export interface SessionResponse { + readonly id: number; + readonly device: string | null; + readonly ip_address: string | null; + readonly created_at: string; + readonly last_used: string; + readonly expires_at: string; +} + +/** Upload response from POST /api/uploads. */ +export interface UploadResponse { + readonly id: string; + readonly filename: string; + readonly size: number; + readonly mime: string; + readonly url: string; +} + +/** TURN/STUN credentials from GET /api/voice/credentials. */ +export interface IceServer { + readonly urls: string; + readonly username?: string; + readonly credential?: string; +} + +export interface VoiceCredentialsResponse { + readonly ice_servers: readonly IceServer[]; + readonly expires_in: number; +} diff --git a/Client/tauri-client/src/lib/updater.ts b/Client/tauri-client/src/lib/updater.ts new file mode 100644 index 00000000..17c46f64 --- /dev/null +++ b/Client/tauri-client/src/lib/updater.ts @@ -0,0 +1,41 @@ +// updater.ts — Client auto-update service. +// Uses custom Tauri commands that build the updater with a dynamic server URL +// at runtime (required because OwnCord is self-hosted). + +import { invoke } from "@tauri-apps/api/core"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { createLogger } from "@lib/logger"; + +const log = createLogger("updater"); + +export interface UpdateCheckResult { + readonly available: boolean; + readonly version: string | null; + readonly body: string | null; +} + +/** Check if a newer client version is available on the connected server. */ +export async function checkForUpdate(serverUrl: string): Promise<UpdateCheckResult> { + try { + const result = await invoke<UpdateCheckResult>("check_client_update", { + serverUrl, + }); + if (result.available) { + log.info("Update available", { version: result.version }); + } else { + log.debug("No update available"); + } + return result; + } catch (err) { + log.error("Update check failed", { error: String(err) }); + return { available: false, version: null, body: null }; + } +} + +/** Download and install a pending update, then relaunch the app. */ +export async function downloadAndInstallUpdate(serverUrl: string): Promise<void> { + log.info("Downloading and installing update..."); + await invoke("download_and_install_update", { serverUrl }); + log.info("Update installed, relaunching..."); + await relaunch(); +} diff --git a/Client/tauri-client/src/lib/vad.ts b/Client/tauri-client/src/lib/vad.ts new file mode 100644 index 00000000..bbb1721f --- /dev/null +++ b/Client/tauri-client/src/lib/vad.ts @@ -0,0 +1,183 @@ +// ============================================================================= +// Voice Activity Detection — Web Audio API based speech detection +// ============================================================================= + +import { createLogger } from "@lib/logger"; + +const log = createLogger("vad"); + +export interface VadOptions { + /** Audio volume threshold (0-1) to detect speech. Default 0.01 */ + readonly threshold?: number; + /** How often to check volume in ms. Default 50 */ + readonly intervalMs?: number; + /** Minimum consecutive detections before triggering. Default 3 */ + readonly minConsecutive?: number; +} + +export interface VadDetector { + start(stream: MediaStream): void; + stop(): void; + setThreshold(threshold: number): void; + onSpeakingChange(callback: (speaking: boolean) => void): () => void; + isSpeaking(): boolean; + destroy(): void; +} + +type SpeakingCallback = (speaking: boolean) => void; + +const DEFAULT_THRESHOLD = 0.01; +const DEFAULT_INTERVAL_MS = 50; +const DEFAULT_MIN_CONSECUTIVE = 3; + +/** Max VAD threshold value. Sensitivity 0% maps to this threshold. */ +const MAX_THRESHOLD = 0.15; + +/** Convert sensitivity slider (0-100) to VAD threshold (0-MAX_THRESHOLD). + * High sensitivity = low threshold (picks up quiet sounds). + * 0% sensitivity = threshold 0.15 (only loud sounds trigger). + * 100% sensitivity = threshold 0.0 (everything triggers). */ +export function sensitivityToThreshold(sensitivity: number): number { + return ((100 - sensitivity) / 100) * MAX_THRESHOLD; +} +// Require more silence samples than speech samples to prevent flicker +const SILENCE_MULTIPLIER = 2; + +function computeRms(data: Uint8Array): number { + let sum = 0; + for (let i = 0; i < data.length; i++) { + const val = data[i]; + if (val === undefined) continue; + // getByteFrequencyData returns 0-255 where 0 = silence, 255 = max. + // Normalize to 0-1 range. + const normalized = val / 255; + sum += normalized * normalized; + } + return Math.sqrt(sum / data.length); +} + +export function createVadDetector(options?: VadOptions): VadDetector { + let threshold = options?.threshold ?? DEFAULT_THRESHOLD; + const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; + const minConsecutive = options?.minConsecutive ?? DEFAULT_MIN_CONSECUTIVE; + const silenceRequired = minConsecutive * SILENCE_MULTIPLIER; + + let audioContext: AudioContext | null = null; + let analyser: AnalyserNode | null = null; + let sourceNode: MediaStreamAudioSourceNode | null = null; + let intervalId: ReturnType<typeof setInterval> | null = null; + let destroyed = false; + + let speaking = false; + let consecutiveAbove = 0; + let consecutiveBelow = 0; + + const callbacks = new Set<SpeakingCallback>(); + + function emitChange(newState: boolean): void { + if (speaking === newState) return; + speaking = newState; + for (const cb of callbacks) { + cb(speaking); + } + } + + function tick(): void { + if (analyser === null) return; + + const data = new Uint8Array(analyser.frequencyBinCount); + analyser.getByteFrequencyData(data); + const rms = computeRms(data); + + if (rms >= threshold) { + consecutiveAbove++; + consecutiveBelow = 0; + if (!speaking && consecutiveAbove >= minConsecutive) { + emitChange(true); + } + } else { + consecutiveBelow++; + consecutiveAbove = 0; + if (speaking && consecutiveBelow >= silenceRequired) { + emitChange(false); + } + } + } + + function cleanup(): void { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + if (sourceNode !== null) { + sourceNode.disconnect(); + sourceNode = null; + } + if (analyser !== null) { + analyser.disconnect(); + analyser = null; + } + if (audioContext !== null) { + void audioContext.close(); + audioContext = null; + } + consecutiveAbove = 0; + consecutiveBelow = 0; + if (speaking) { + emitChange(false); + } + } + + return { + start(stream: MediaStream): void { + if (destroyed) throw new Error("VadDetector has been destroyed"); + // Stop any existing monitoring first + cleanup(); + + // Force 48kHz so FFT bins cover the voice-frequency range (0-24kHz) + // consistently regardless of the system audio device's native rate. + // At high native rates (e.g. 192kHz), most bins would be above voice + // frequencies, making the RMS calculation artificially low. + audioContext = new AudioContext({ sampleRate: 48000 }); + analyser = audioContext.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.5; + + sourceNode = audioContext.createMediaStreamSource(stream); + sourceNode.connect(analyser); + + intervalId = setInterval(tick, intervalMs); + log.debug("VAD started", { threshold, intervalMs, minConsecutive, sampleRate: audioContext.sampleRate }); + }, + + stop(): void { + if (destroyed) return; + cleanup(); + }, + + setThreshold(newThreshold: number): void { + if (newThreshold < 0 || newThreshold > 1) { + throw new Error("Threshold must be between 0 and 1"); + } + log.debug("VAD threshold changed", { old: threshold, new: newThreshold }); + threshold = newThreshold; + }, + + onSpeakingChange(callback: SpeakingCallback): () => void { + callbacks.add(callback); + return () => { callbacks.delete(callback); }; + }, + + isSpeaking(): boolean { + return speaking; + }, + + destroy(): void { + if (destroyed) return; + destroyed = true; + cleanup(); + callbacks.clear(); + log.debug("VAD destroyed"); + }, + }; +} diff --git a/Client/tauri-client/src/lib/voiceSession.ts b/Client/tauri-client/src/lib/voiceSession.ts new file mode 100644 index 00000000..d855f89b --- /dev/null +++ b/Client/tauri-client/src/lib/voiceSession.ts @@ -0,0 +1,926 @@ +// ============================================================================= +// Voice Session — lifecycle orchestrator for voice chat +// +// Manages audio capture, WebRTC connection, remote audio playback, and +// WS signaling. Singleton module: only one voice session at a time. +// ============================================================================= + +import type { WsClient } from "@lib/ws"; +import type { VoiceConfigPayload, IceServer } from "@lib/types"; +import type { WebRtcService } from "@lib/webrtc"; +import type { AudioManager } from "@lib/audio"; +import type { VadDetector } from "@lib/vad"; +import { createWebRtcService } from "@lib/webrtc"; +import { createAudioManager } from "@lib/audio"; +import { createVadDetector, sensitivityToThreshold } from "@lib/vad"; +import { createNoiseSuppressor } from "@lib/noise-suppression"; +import type { NoiseSuppressor } from "@lib/noise-suppression"; +import { voiceStore, setLocalMuted, setLocalDeafened, setLocalSpeaking } from "@stores/voice.store"; +import { loadPref, savePref } from "@components/settings/helpers"; +import { createLogger } from "@lib/logger"; + +const log = createLogger("voiceSession"); + +// --------------------------------------------------------------------------- +// Module-level state (singleton) +// --------------------------------------------------------------------------- + +let audioManager: AudioManager | null = null; +let webrtcService: WebRtcService | null = null; +let vadDetector: VadDetector | null = null; +let noiseSuppressor: NoiseSuppressor | null = null; +let localStream: MediaStream | null = null; +/** The stream actually sent to WebRTC (may be noise-suppressed). */ +let processedStream: MediaStream | null = null; +let ws: WsClient | null = null; +const audioElements = new Map<string, HTMLAudioElement>(); +/** Shared AudioContext for all remote audio processing (avoids browser limit of ~6 contexts). */ +let sharedAudioCtx: AudioContext | null = null; + +function getSharedAudioContext(): AudioContext { + if (sharedAudioCtx === null || sharedAudioCtx.state === "closed") { + // Force 48kHz to match WebRTC Opus output. At high native rates + // (e.g. 192kHz), the Web Audio resampling pipeline can introduce + // issues or silence when bridging MediaStream → GainNode → destination. + sharedAudioCtx = new AudioContext({ sampleRate: 48000 }); + } + return sharedAudioCtx; +} + +/** Map userId → GainNode for per-user volume control (legacy, unused — kept for diagnostics). */ +const userGainNodes = new Map<number, GainNode>(); +/** Map userId → HTMLAudioElement for per-user volume control via element.volume. */ +const userAudioElements = new Map<number, HTMLAudioElement>(); +/** Map stream.id → userId (parsed from server's "user-{id}" stream label). */ +const streamUserMap = new Map<string, number>(); +let audioContainer: HTMLDivElement | null = null; + +// Optional error callback for UI feedback (e.g. toast on WebRTC failure) +let onErrorCallback: ((message: string) => void) | null = null; + +// Track event-unsubscribe functions for cleanup +let unsubIce: (() => void) | null = null; +let unsubTrack: (() => void) | null = null; +let unsubState: (() => void) | null = null; +let unsubIceState: (() => void) | null = null; +let unsubVad: (() => void) | null = null; + +// ICE restart state +const ICE_RESTART_DELAY_MS = 5000; +let iceRestartTimer: ReturnType<typeof setTimeout> | null = null; +let currentChannelId: number | null = null; + +// Guard against concurrent joinVoice calls +let joinInProgress = false; + +// Cached silence suppression preference (avoid localStorage reads in hot path) +let silenceSuppressionEnabled = true; + +/** Update cached silence suppression preference. Called from settings. */ +export function updateSilenceSuppressionPref(): void { + silenceSuppressionEnabled = loadPref<boolean>("silenceSuppression", true); +} + +/** Shared VAD speaking callback — includes silence suppression logic. */ +function onVadSpeakingChange(speaking: boolean): void { + setLocalSpeaking(speaking); + if (silenceSuppressionEnabled && webrtcService !== null) { + webrtcService.setSilenced(!speaking); + } +} + +/** Pipe a raw mic stream through noise suppression if enabled, returning the + * stream to send to WebRTC. Destroys any existing suppressor first. */ +async function applyNoiseSuppression(raw: MediaStream): Promise<MediaStream> { + if (noiseSuppressor !== null) { + noiseSuppressor.destroy(); + noiseSuppressor = null; + } + if (!loadPref<boolean>("enhancedNoiseSuppression", false)) return raw; + try { + noiseSuppressor = createNoiseSuppressor(); + const cleaned = await noiseSuppressor.process(raw); + log.info("Enhanced noise suppression enabled"); + return cleaned; + } catch (err) { + log.warn("Failed to init noise suppression, using raw stream", err); + return raw; + } +} + +/** Start (or restart) VAD on the stream that's actually sent to WebRTC. + * When noise suppression is active, this is the processed stream so the + * threshold matches what's transmitted (not raw mic noise). */ +function startVad(stream: MediaStream): void { + // Destroy old detector to avoid reusing a closed AudioContext + if (vadDetector !== null) { + if (unsubVad !== null) { unsubVad(); unsubVad = null; } + vadDetector.destroy(); + vadDetector = null; + } + const sensitivity = loadPref<number>("voiceSensitivity", 50); + vadDetector = createVadDetector({ threshold: sensitivityToThreshold(sensitivity) }); + vadDetector.start(stream); + unsubVad = vadDetector.onSpeakingChange(onVadSpeakingChange); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Get or create the hidden container for remote audio elements. */ +function getOrCreateAudioContainer(): HTMLDivElement { + if (audioContainer !== null) return audioContainer; + + const existing = document.getElementById("voice-audio-container"); + if (existing instanceof HTMLDivElement) { + audioContainer = existing; + return audioContainer; + } + + const div = document.createElement("div"); + div.id = "voice-audio-container"; + div.style.display = "none"; + document.body.appendChild(div); + audioContainer = div; + return audioContainer; +} + +/** Parse userId from server's stream label "user-{id}". Returns 0 if unparseable. */ +function parseUserIdFromStream(stream: MediaStream): number { + // The server creates tracks with streamID = "user-{userID}" + const match = stream.id.match(/^user-(\d+)$/); + if (match !== null && match[1] !== undefined) { + return Number(match[1]); + } + // Fallback: check track labels "audio-{userID}" + for (const track of stream.getTracks()) { + const trackMatch = track.id.match(/^audio-(\d+)$/); + if (trackMatch !== null && trackMatch[1] !== undefined) { + return Number(trackMatch[1]); + } + } + log.warn("Could not parse userId from remote stream", { + streamId: stream.id, + trackIds: stream.getTracks().map((t) => t.id), + }); + return 0; +} + +/** Get saved per-user volume (0-200 range, default 100). */ +function getSavedUserVolume(userId: number): number { + return loadPref<number>(`userVolume_${userId}`, 100); +} + +/** Add a remote MediaStream as an <audio> element with per-user volume. + * Uses HTMLAudioElement.volume directly instead of Web Audio GainNode — + * WebView2/Chromium silences remote WebRTC streams routed through + * createMediaStreamSource → GainNode → createMediaStreamDestination. */ +function addRemoteStream(stream: MediaStream): void { + if (audioElements.has(stream.id)) return; + + const container = getOrCreateAudioContainer(); + const userId = parseUserIdFromStream(stream); + if (userId > 0) { + streamUserMap.set(stream.id, userId); + } + + const audio = document.createElement("audio"); + audio.autoplay = true; + audio.setAttribute("playsinline", ""); + audio.srcObject = stream; + + // Apply saved per-user volume via HTMLAudioElement.volume (0.0-1.0 range). + // We clamp the stored 0-200 range to 0-100 for element volume. + const savedVolume = userId > 0 ? getSavedUserVolume(userId) : 100; + audio.volume = Math.min(savedVolume, 100) / 100; + + if (userId > 0) { + userAudioElements.set(userId, audio); + } + log.debug("Remote audio stream attached (direct playback)", { userId, volume: audio.volume }); + + // Monitor playback state — autoplay may be blocked by browser policy + audio.addEventListener("playing", () => { + log.info("Remote audio playing", { streamId: stream.id, userId }); + }); + audio.addEventListener("pause", () => { + // Ignore pause events that fire before the element is attached to DOM + if (!audio.parentElement) return; + log.warn("Remote audio paused", { streamId: stream.id, userId }); + }); + audio.addEventListener("error", () => { + log.error("Remote audio element error", { + streamId: stream.id, + userId, + error: audio.error?.message ?? "unknown", + code: audio.error?.code, + }); + }); + + // Apply saved output device + const savedOutput = loadPref<string>("audioOutputDevice", ""); + if (savedOutput !== "" && typeof audio.setSinkId === "function") { + audio.setSinkId(savedOutput).catch((err) => { + log.warn("Failed to set output device on remote audio", err); + }); + } + + // Auto-remove when all tracks end + stream.onremovetrack = () => { + if (stream.getTracks().length === 0) { + audio.srcObject = null; + audio.remove(); + audioElements.delete(stream.id); + streamUserMap.delete(stream.id); + if (userId > 0) { + userGainNodes.delete(userId); + userAudioElements.delete(userId); + } + log.debug("Removed remote audio element", { streamId: stream.id, userId }); + } + }; + + container.appendChild(audio); + audioElements.set(stream.id, audio); + log.debug("Added remote audio element", { streamId: stream.id, userId }); + + // Kick playback after DOM attachment — avoids "interrupted by a new load request" + // race between autoplay and srcObject assignment. + queueMicrotask(() => { + if (audio.paused && audio.srcObject !== null) { + audio.play().catch((err) => { + log.error("Remote audio play() rejected", { + streamId: stream.id, + userId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + }); +} + +/** Attempt to acquire the microphone, falling back to system default. */ +async function acquireMicrophone(): Promise<MediaStream | null> { + if (audioManager === null) { + audioManager = createAudioManager(); + } + + const savedDevice = loadPref<string>("audioInputDevice", ""); + + // Try saved device first + if (savedDevice !== "") { + try { + return await audioManager.getUserMedia(savedDevice); + } catch (err) { + log.warn("Failed to use saved input device, trying default", err); + } + } + + // Fall back to system default + try { + return await audioManager.getUserMedia(); + } catch (err) { + log.warn("Failed to acquire microphone — entering listen-only mode", err); + return null; + } +} + +/** Clean up all remote audio elements and per-user gain nodes. */ +function cleanupAudioElements(): void { + for (const el of audioElements.values()) { + el.srcObject = null; + el.remove(); + } + audioElements.clear(); + userGainNodes.clear(); + userAudioElements.clear(); + streamUserMap.clear(); + + // Close the shared AudioContext (will be re-created on next join) + if (sharedAudioCtx !== null) { + void sharedAudioCtx.close(); + sharedAudioCtx = null; + } +} + +/** Attempt ICE restart by creating a new offer with iceRestart flag. */ +async function attemptIceRestart(): Promise<void> { + if (webrtcService === null || ws === null || currentChannelId === null) { + log.warn("Cannot ICE restart — no active session"); + return; + } + try { + log.info("Attempting ICE restart", { channelId: currentChannelId }); + const offerSdp = await webrtcService.createOffer(true); + ws.send({ + type: "voice_offer", + payload: { channel_id: currentChannelId, sdp: offerSdp }, + }); + log.info("ICE restart offer sent"); + } catch (err) { + log.error("ICE restart failed", err); + onErrorCallback?.("Voice reconnection failed — please rejoin"); + leaveVoice(); + } +} + +/** Unsubscribe WebRTC and VAD event handlers. */ +function cleanupWebrtcSubs(): void { + if (unsubIce !== null) { + unsubIce(); + unsubIce = null; + } + if (unsubTrack !== null) { + unsubTrack(); + unsubTrack = null; + } + if (unsubState !== null) { + unsubState(); + unsubState = null; + } + if (unsubIceState !== null) { + unsubIceState(); + unsubIceState = null; + } + if (unsubVad !== null) { + unsubVad(); + unsubVad = null; + } + // Cancel any pending ICE restart + if (iceRestartTimer !== null) { + clearTimeout(iceRestartTimer); + iceRestartTimer = null; + } + currentChannelId = null; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Set the WS client reference used for signaling. */ +export function setWsClient(client: WsClient): void { + ws = client; +} + +/** Set error callback for UI feedback (e.g. toast on WebRTC failure). */ +export function setOnError(cb: (message: string) => void): void { + onErrorCallback = cb; +} + +/** Clear the error callback (call on component destroy to avoid stale refs). */ +export function clearOnError(): void { + onErrorCallback = null; +} + +/** + * Fetch ICE servers (TURN/STUN credentials) for WebRTC. + * Falls back to empty array on failure so voice still works on LAN. + */ +export type IceServerFetcher = () => Promise<readonly IceServer[]>; + +/** Join a voice channel: acquire mic, set up WebRTC, send offer. */ +export async function joinVoice( + channelId: number, + config: VoiceConfigPayload, + fetchIceServers?: IceServerFetcher, +): Promise<void> { + if (ws === null) { + log.error("Cannot join voice: WS client not set"); + return; + } + + // Prevent concurrent join attempts + if (joinInProgress) { + log.warn("Join already in progress, ignoring"); + return; + } + joinInProgress = true; + + // Clean up any existing voice session to prevent stale callbacks + // from killing the new session (don't send voice_leave — server + // already handled the channel switch). + if (webrtcService !== null) { + leaveVoice(false); + } + + // Cache silence suppression pref at join time + silenceSuppressionEnabled = loadPref<boolean>("silenceSuppression", true); + + try { + // 1. Acquire microphone and ICE servers in parallel + const [stream, iceServers] = await Promise.all([ + acquireMicrophone(), + fetchIceServers + ? fetchIceServers().catch((err) => { + log.warn("Failed to fetch ICE servers, falling back to direct", err); + return [] as readonly IceServer[]; + }) + : Promise.resolve([] as readonly IceServer[]), + ]); + localStream = stream; + + // 2. Create WebRTC peer connection with TURN/STUN servers + webrtcService = createWebRtcService(); + webrtcService.createConnection({ + iceServers: iceServers.map((s) => ({ + urls: s.urls, + username: s.username, + credential: s.credential, + })), + opusBitrate: config.bitrate, + }); + + // 3. Apply noise suppression if enabled, then attach to WebRTC + processedStream = localStream !== null + ? await applyNoiseSuppression(localStream) + : null; + + // 4. Attach local stream if available + if (processedStream !== null) { + webrtcService.setLocalStream(processedStream); + } + + // 5. Wire ICE candidate forwarding + unsubIce = webrtcService.onIceCandidate((candidate) => { + if (ws === null) return; + ws.send({ + type: "voice_ice", + payload: { channel_id: channelId, candidate }, + }); + }); + + // 6. Wire remote track playback + unsubTrack = webrtcService.onRemoteTrack((stream) => { + addRemoteStream(stream); + }); + + // 7. Wire connection state monitoring + unsubState = webrtcService.onStateChange((state) => { + log.info("WebRTC connection state changed", { state }); + if (state === "failed") { + log.error("WebRTC connection failed, leaving voice"); + onErrorCallback?.("Voice connection failed — disconnected"); + leaveVoice(); + } + }); + + // 7b. Wire ICE connection state for automatic ICE restart + currentChannelId = channelId; + unsubIceState = webrtcService.onIceStateChange((state) => { + log.info("ICE connection state changed", { state }); + + if (state === "disconnected") { + // Start timer — ICE may self-recover. If not, restart after delay. + if (iceRestartTimer === null) { + log.info("ICE disconnected, scheduling restart", { delayMs: ICE_RESTART_DELAY_MS }); + iceRestartTimer = setTimeout(() => { + iceRestartTimer = null; + void attemptIceRestart(); + }, ICE_RESTART_DELAY_MS); + } + } else if (state === "connected" || state === "completed") { + // ICE recovered on its own — cancel pending restart + if (iceRestartTimer !== null) { + log.info("ICE recovered, cancelling restart timer"); + clearTimeout(iceRestartTimer); + iceRestartTimer = null; + } + } else if (state === "failed") { + // ICE failed — attempt restart immediately + if (iceRestartTimer !== null) { + clearTimeout(iceRestartTimer); + iceRestartTimer = null; + } + void attemptIceRestart(); + } + }); + + // 8. Start VAD on the processed stream (so threshold matches what's sent) + if (localStream !== null && processedStream !== null) { + startVad(processedStream); + } + + // 9. Create and send SDP offer (guard: session may have been destroyed + // by a connection-failed event firing between wire-up and offer) + if (webrtcService === null) { + log.warn("WebRTC service destroyed before offer — aborting join"); + return; + } + // If the server already sent us an offer (renegotiation arrived before we + // could create ours), we're in have-remote-offer state — skip our offer. + // The handleServerOffer path will send an answer instead. + try { + const offerSdp = await webrtcService.createOffer(); + ws.send({ + type: "voice_offer", + payload: { channel_id: channelId, sdp: offerSdp }, + }); + } catch (offerErr) { + // Likely "Called in wrong state: have-remote-offer" — server renegotiation + // arrived first. The onRemoteTrack + handleServerOffer path handles it. + log.info("Skipping initial offer — server offer arrived first", { + error: offerErr instanceof Error ? offerErr.message : String(offerErr), + }); + } + + log.info("Joined voice channel", { channelId }); + } catch (err) { + log.error("Failed to join voice channel", err); + leaveVoice(); + } finally { + joinInProgress = false; + } +} + +/** + * Leave the current voice session and clean up all resources. + * If sendWs is true (default), also notifies the server via voice_leave. + * Pass sendWs=false when the server already knows (e.g. explicit UI leave + * that sends voice_leave separately). + */ +export function leaveVoice(sendWs = true): void { + // Notify server so it cleans up our voice state + if (sendWs && ws !== null) { + ws.send({ type: "voice_leave", payload: {} }); + } + // Clear join guard so a new join can proceed after leave + joinInProgress = false; + + // Stop processedStream tracks first (before nulling localStream so guard works) + if (processedStream !== null && processedStream !== localStream) { + for (const track of processedStream.getTracks()) { + track.stop(); + } + } + processedStream = null; + + // Stop all local media tracks + if (localStream !== null) { + for (const track of localStream.getTracks()) { + track.stop(); + } + localStream = null; + } + + // Destroy VAD + if (vadDetector !== null) { + vadDetector.destroy(); + vadDetector = null; + } + + // Clean up WebRTC subscriptions before destroying + cleanupWebrtcSubs(); + + // Destroy noise suppressor + if (noiseSuppressor !== null) { + noiseSuppressor.destroy(); + noiseSuppressor = null; + } + + // Destroy WebRTC + if (webrtcService !== null) { + webrtcService.destroy(); + webrtcService = null; + } + + // Clean up remote audio playback + cleanupAudioElements(); + + // Destroy audio manager + if (audioManager !== null) { + audioManager.destroy(); + audioManager = null; + } + + log.info("Left voice session"); +} + +/** Mute or unmute the local microphone. */ +export function setMuted(muted: boolean): void { + setLocalMuted(muted); + if (webrtcService !== null) { + // Mute via track.enabled — no renegotiation, instant, no race conditions. + webrtcService.setMuted(muted); + } else { + // Fallback for listen-only mode (no WebRTC): disable raw mic tracks + if (localStream !== null) { + for (const track of localStream.getAudioTracks()) { + track.enabled = !muted; + } + } + } +} + +/** Deafen or undeafen — mutes all remote audio playback. */ +export function setDeafened(deafened: boolean): void { + setLocalDeafened(deafened); + for (const el of audioElements.values()) { + el.muted = deafened; + } + log.debug("Deafen state changed", { deafened, audioElements: audioElements.size }); +} + +/** Switch the input (microphone) device on an active session. */ +export async function switchInputDevice(deviceId: string): Promise<void> { + // Don't acquire microphone if there's no active voice session + if (webrtcService === null) { + log.debug("Skipping input device switch — no active voice session"); + return; + } + if (audioManager === null) { + audioManager = createAudioManager(); + } + + // Save old state so we can roll back on failure + const oldLocalStream = localStream; + const oldProcessedStream = processedStream; + const oldSuppressor = noiseSuppressor; + + try { + const newStream = await audioManager.getUserMedia(deviceId || undefined); + if (newStream === null) return; + + // Guard: session may have ended during the async getUserMedia call + if (webrtcService === null) { + for (const track of newStream.getTracks()) track.stop(); + return; + } + + // Temporarily detach old suppressor so applyNoiseSuppression doesn't + // destroy it — we need the old pipeline alive for rollback on failure. + noiseSuppressor = null; + let newProcessed: MediaStream; + try { + newProcessed = await applyNoiseSuppression(newStream); + } catch (err) { + // Noise suppression failed — restore old suppressor, stop new stream + noiseSuppressor = oldSuppressor; + log.warn("Noise suppression failed during device switch, keeping old device", err); + for (const track of newStream.getTracks()) track.stop(); + onErrorCallback?.("Failed to switch microphone — noise suppression error"); + return; + } + + // Guard: session may have ended during noise suppression setup + if (webrtcService === null) { + if (newProcessed !== newStream) { + for (const track of newProcessed.getTracks()) track.stop(); + } + for (const track of newStream.getTracks()) track.stop(); + return; + } + + // Swap track on WebRTC sender — no renegotiation needed + await webrtcService.replaceTrack(newProcessed); + + // Success — update module state and stop old tracks + localStream = newStream; + processedStream = newProcessed; + + // Restart VAD on processed stream with full silence suppression + startVad(newProcessed); + + // NOW clean up old resources (after new pipeline is fully wired) + if (oldSuppressor !== null && oldSuppressor !== noiseSuppressor) { + oldSuppressor.destroy(); + } + if (oldProcessedStream !== null && oldProcessedStream !== oldLocalStream) { + for (const track of oldProcessedStream.getTracks()) { + track.stop(); + } + } + if (oldLocalStream !== null) { + for (const track of oldLocalStream.getTracks()) { + track.stop(); + } + } + + log.info("Switched input device", { deviceId }); + } catch (err) { + log.error("Failed to switch input device", err); + onErrorCallback?.("Failed to switch microphone"); + } +} + +/** Switch the output (speaker) device on an active session. */ +export async function switchOutputDevice(deviceId: string): Promise<void> { + let hadError = false; + for (const el of audioElements.values()) { + if (typeof el.setSinkId === "function") { + try { + await el.setSinkId(deviceId); + } catch (err) { + log.error("Failed to set output device on audio element", err); + hadError = true; + } + } + } + if (hadError) { + onErrorCallback?.("Failed to switch some audio to new speaker"); + } + log.info("Switched output device", { deviceId }); +} + +/** + * Set per-user volume (0-200%). Persisted to localStorage. + * Like Discord, this only affects YOUR playback of that user's audio. + * Note: HTMLAudioElement.volume only supports 0.0-1.0, so volumes above + * 100% are clamped. For boost beyond 100%, a Web Audio GainNode would + * be needed, but WebView2 silences remote WebRTC streams through GainNode. + */ +export function setUserVolume(userId: number, volume: number): void { + const clamped = Math.max(0, Math.min(200, volume)); + savePref(`userVolume_${userId}`, clamped); + + const audioEl = userAudioElements.get(userId); + if (audioEl !== undefined) { + audioEl.volume = Math.min(clamped, 100) / 100; + } +} + +/** Get the current per-user volume (0-200%, default 100). */ +export function getUserVolume(userId: number): number { + return getSavedUserVolume(userId); +} + +/** Update the VAD sensitivity threshold on an active session. */ +export function setVoiceSensitivity(sensitivity: number): void { + if (vadDetector === null) return; + vadDetector.setThreshold(sensitivityToThreshold(sensitivity)); +} + +/** Get raw WebRTC remote streams for diagnostics (before GainNode). */ +export function getRemoteStreams(): readonly MediaStream[] { + return webrtcService?.getRemoteStreams() ?? []; +} + +/** Get the local processed stream for diagnostics. */ +export function getLocalProcessedStream(): MediaStream | null { + return processedStream; +} + +/** Handle an SDP offer from the server (re-negotiation). */ +export async function handleServerOffer( + sdp: string, + channelId: number, +): Promise<void> { + if (webrtcService === null) { + log.warn("Received server offer but no WebRTC service active"); + return; + } + if (ws === null) { + log.warn("Received server offer but no WS client set"); + return; + } + + try { + const answerSdp = await webrtcService.handleServerOffer(sdp); + ws.send({ + type: "voice_answer", + payload: { channel_id: channelId, sdp: answerSdp }, + }); + log.debug("Responded to server offer with answer", { channelId }); + } catch (err) { + log.error("Failed to handle server offer", err); + } +} + +/** Handle an SDP answer from the server. */ +export async function handleServerAnswer(sdp: string): Promise<void> { + if (webrtcService === null) { + log.warn("Received server answer but no WebRTC service active"); + return; + } + + try { + await webrtcService.handleAnswer(sdp); + log.debug("Applied server answer"); + } catch (err) { + log.error("Failed to handle server answer", err); + } +} + +/** Measure RMS audio level on a MediaStream (0-1). Returns 0 if no data. */ +export function measureStreamLevel(stream: MediaStream): Promise<number> { + return new Promise((resolve) => { + try { + const ctx = new AudioContext({ sampleRate: 48000 }); + const source = ctx.createMediaStreamSource(stream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + source.connect(analyser); + const data = new Uint8Array(analyser.frequencyBinCount); + + // Wait a few frames for data to flow + let attempts = 0; + const check = (): void => { + analyser.getByteFrequencyData(data); + let sum = 0; + for (let i = 0; i < data.length; i++) { + const v = (data[i] ?? 0) / 255; + sum += v * v; + } + const rms = Math.sqrt(sum / data.length); + attempts++; + if (rms > 0 || attempts >= 10) { + source.disconnect(); + void ctx.close(); + resolve(Math.round(rms * 1000) / 1000); + } else { + setTimeout(check, 50); + } + }; + setTimeout(check, 100); + } catch { + resolve(-1); + } + }); +} + +/** Snapshot of current voice session state for debugging. */ +export function getSessionDebugInfo(): Record<string, unknown> { + // Gather detailed remote track info + const remoteTrackDetails: Record<string, unknown>[] = []; + for (const [streamId, audioEl] of audioElements.entries()) { + const userId = streamUserMap.get(streamId) ?? 0; + const gainNode = userId > 0 ? userGainNodes.get(userId) : undefined; + const srcObj = audioEl.srcObject as MediaStream | null; + const tracks = srcObj?.getAudioTracks() ?? []; + const trackInfo = tracks.map((t) => ({ + id: t.id, + enabled: t.enabled, + muted: t.muted, + readyState: t.readyState, + })); + + remoteTrackDetails.push({ + streamId, + userId, + audioPaused: audioEl.paused, + audioMuted: audioEl.muted, + audioVolume: audioEl.volume, + audioReadyState: audioEl.readyState, + hasSrcObject: audioEl.srcObject !== null, + gainValue: gainNode?.gain.value ?? "no-node", + tracks: trackInfo, + }); + } + + // Local track info + const localTracks = processedStream?.getAudioTracks() ?? []; + const localTrackInfo = localTracks.map((t) => ({ + id: t.id, + enabled: t.enabled, + muted: t.muted, + readyState: t.readyState, + })); + + // WebRTC remote streams info + const webrtcRemoteStreams = webrtcService?.getRemoteStreams() ?? []; + const webrtcRemoteInfo = webrtcRemoteStreams.map((s) => ({ + streamId: s.id, + trackCount: s.getTracks().length, + audioTracks: s.getAudioTracks().map((t) => ({ + id: t.id, + enabled: t.enabled, + muted: t.muted, + readyState: t.readyState, + })), + })); + + return { + hasAudioManager: audioManager !== null, + hasWebrtc: webrtcService !== null, + hasVad: vadDetector !== null, + hasNoiseSuppressor: noiseSuppressor !== null, + hasLocalStream: localStream !== null, + hasProcessedStream: processedStream !== null, + joinInProgress, + silenceSuppressionEnabled, + sharedAudioCtx: sharedAudioCtx !== null + ? { state: sharedAudioCtx.state, sampleRate: sharedAudioCtx.sampleRate } + : null, + localTracks: localTrackInfo, + remoteAudioElements: remoteTrackDetails, + webrtcRemoteStreams: webrtcRemoteInfo, + }; +} + +/** Handle an ICE candidate from the server. */ +export async function handleServerIce( + candidate: RTCIceCandidateInit, +): Promise<void> { + if (webrtcService === null) { + log.warn("Received ICE candidate but no WebRTC service active"); + return; + } + + try { + await webrtcService.handleIceCandidate(candidate); + log.debug("Added server ICE candidate"); + } catch (err) { + log.error("Failed to handle server ICE candidate", err); + } +} diff --git a/Client/tauri-client/src/lib/webrtc.ts b/Client/tauri-client/src/lib/webrtc.ts new file mode 100644 index 00000000..3e6e3459 --- /dev/null +++ b/Client/tauri-client/src/lib/webrtc.ts @@ -0,0 +1,370 @@ +// ============================================================================= +// WebRTC Service — peer connection management for voice communication +// ============================================================================= + +import { createLogger } from "@lib/logger"; + +const log = createLogger("webrtc"); + +export interface WebRtcConfig { + readonly iceServers: readonly RTCIceServer[]; + readonly opusBitrate?: number; +} + +export interface WebRtcService { + createConnection(config: WebRtcConfig): void; + handleOffer(sdp: string): Promise<string>; + handleAnswer(sdp: string): Promise<void>; + handleServerOffer(sdp: string): Promise<string>; + createOffer(iceRestart?: boolean): Promise<string>; + handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void>; + setLocalStream(stream: MediaStream): void; + /** Swap the media track on existing senders without SDP renegotiation. */ + replaceTrack(stream: MediaStream): Promise<void>; + getRemoteStreams(): readonly MediaStream[]; + setMuted(muted: boolean): void; + setSilenced(silenced: boolean): void; + onIceCandidate(callback: (candidate: RTCIceCandidateInit) => void): () => void; + onRemoteTrack(callback: (stream: MediaStream) => void): () => void; + onStateChange(callback: (state: RTCPeerConnectionState) => void): () => void; + onIceStateChange(callback: (state: RTCIceConnectionState) => void): () => void; + destroy(): void; +} + +type IceCandidateCallback = (candidate: RTCIceCandidateInit) => void; +type RemoteTrackCallback = (stream: MediaStream) => void; +type StateChangeCallback = (state: RTCPeerConnectionState) => void; +type IceStateCallback = (state: RTCIceConnectionState) => void; + +/** Apply Opus bitrate and FEC constraints via SDP munging. */ +function applyOpusSettings(sdp: string, bitrate: number | undefined): string { + const lines = sdp.split("\r\n"); + const result: string[] = []; + let inAudioSection = false; + let bitrateInserted = false; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + if (line === undefined) continue; + + // Track which media section we're in + if (line.startsWith("m=audio")) { + inAudioSection = true; + bitrateInserted = false; + } else if (line.startsWith("m=")) { + inAudioSection = false; + } + + // Enable Opus in-band FEC for packet loss resilience + if (line.startsWith("a=fmtp:111 ")) { + if (!line.includes("useinbandfec=")) { + line += ";useinbandfec=1"; + } + } + + result.push(line); + + // Insert b=AS after m=audio line (always present, unlike c= which may + // only exist at session level) + if (inAudioSection && !bitrateInserted && bitrate !== undefined && line.startsWith("m=audio")) { + result.push(`b=AS:${Math.round(bitrate / 1000)}`); + bitrateInserted = true; + } + } + return result.join("\r\n"); +} + +export function createWebRtcService(): WebRtcService { + let pc: RTCPeerConnection | null = null; + let localSenders: readonly RTCRtpSender[] = []; + let isMuted = false; + let isSilenced = false; + let remoteStreams: readonly MediaStream[] = []; + let opusBitrate: number | undefined; + let destroyed = false; + /** True once setRemoteDescription has been called (ICE candidates are safe). */ + let hasRemoteDescription = false; + /** Queue ICE candidates that arrive before the remote description is set. */ + const pendingIceCandidates: RTCIceCandidateInit[] = []; + + const iceCandidateCallbacks = new Set<IceCandidateCallback>(); + const remoteTrackCallbacks = new Set<RemoteTrackCallback>(); + const stateChangeCallbacks = new Set<StateChangeCallback>(); + const iceStateCallbacks = new Set<IceStateCallback>(); + + function assertConnection(): RTCPeerConnection { + if (destroyed) throw new Error("WebRTC service has been destroyed"); + if (pc === null) throw new Error("No peer connection created"); + return pc; + } + + /** Flush queued ICE candidates now that the remote description is set. */ + async function flushIceCandidates(conn: RTCPeerConnection): Promise<void> { + hasRemoteDescription = true; + const queued = pendingIceCandidates.splice(0); + if (queued.length > 0) { + log.debug("Flushing queued ICE candidates", { count: queued.length }); + } + for (const c of queued) { + await conn.addIceCandidate(c); + } + } + + /** Apply track.enabled based on current mute + silence state. */ + function applyTrackEnabled(): void { + for (const sender of localSenders) { + const track = sender.track; + if (track !== null) { + track.enabled = !isMuted && !isSilenced; + } + } + } + + function handleIceCandidateEvent(event: RTCPeerConnectionIceEvent): void { + if (event.candidate === null) { + log.debug("ICE gathering complete"); + return; + } + const c = event.candidate; + log.debug("Local ICE candidate", { + type: c.type, + address: c.address, + port: c.port, + protocol: c.protocol, + candidate: c.candidate, + }); + const init: RTCIceCandidateInit = { + candidate: c.candidate, + sdpMid: c.sdpMid, + sdpMLineIndex: c.sdpMLineIndex, + }; + for (const cb of iceCandidateCallbacks) { + cb(init); + } + } + + function handleTrackEvent(event: RTCTrackEvent): void { + const stream = event.streams[0]; + if (stream === undefined) { + log.warn("Remote track event with no stream"); + return; + } + if (remoteStreams.some((s) => s.id === stream.id)) { + log.debug("Duplicate remote stream ignored", { streamId: stream.id }); + return; + } + log.info("Remote track received", { + streamId: stream.id, + trackId: event.track.id, + kind: event.track.kind, + totalStreams: remoteStreams.length + 1, + }); + remoteStreams = [...remoteStreams, stream]; + for (const cb of remoteTrackCallbacks) { + cb(stream); + } + } + + function handleConnectionStateChange(): void { + if (pc === null) return; + const state = pc.connectionState; + for (const cb of stateChangeCallbacks) { + cb(state); + } + } + + function handleIceConnectionStateChange(): void { + if (pc === null) return; + const state = pc.iceConnectionState; + for (const cb of iceStateCallbacks) { + cb(state); + } + } + + function handleNegotiationNeeded(): void { + // Log canary — if this fires, something triggered SDP renegotiation + // that our explicit offer/answer flow didn't handle. Upgrade to a + // full handler (auto-create offer) if this shows up in production. + console.warn("[WebRTC] negotiationneeded fired unexpectedly — signalingState:", pc?.signalingState); + } + + function mungeIfNeeded(sdp: string | undefined): string { + if (sdp === undefined) return ""; + return applyOpusSettings(sdp, opusBitrate); + } + + return { + createConnection(config: WebRtcConfig): void { + if (destroyed) throw new Error("WebRTC service has been destroyed"); + if (pc !== null) { + pc.close(); + } + opusBitrate = config.opusBitrate; + remoteStreams = []; + localSenders = []; + isMuted = false; + isSilenced = false; + hasRemoteDescription = false; + pendingIceCandidates.length = 0; + + pc = new RTCPeerConnection({ + iceServers: [...config.iceServers], + }); + pc.addEventListener("icecandidate", handleIceCandidateEvent); + pc.addEventListener("track", handleTrackEvent); + pc.addEventListener("connectionstatechange", handleConnectionStateChange); + pc.addEventListener("iceconnectionstatechange", handleIceConnectionStateChange); + pc.addEventListener("negotiationneeded", handleNegotiationNeeded); + log.info("PeerConnection created", { + iceServerCount: config.iceServers.length, + opusBitrate: config.opusBitrate, + }); + }, + + async handleOffer(sdp: string): Promise<string> { + const conn = assertConnection(); + await conn.setRemoteDescription({ type: "offer", sdp }); + await flushIceCandidates(conn); + const answer = await conn.createAnswer(); + const mungedSdp = mungeIfNeeded(answer.sdp); + await conn.setLocalDescription({ type: "answer", sdp: mungedSdp }); + return mungedSdp; + }, + + async handleAnswer(sdp: string): Promise<void> { + const conn = assertConnection(); + await conn.setRemoteDescription({ type: "answer", sdp }); + await flushIceCandidates(conn); + }, + + async handleServerOffer(sdp: string): Promise<string> { + const conn = assertConnection(); + if (conn.signalingState === "have-local-offer") { + log.info("Rolling back local offer for server renegotiation (glare)"); + await conn.setLocalDescription({ type: "rollback" }); + } + await conn.setRemoteDescription({ type: "offer", sdp }); + await flushIceCandidates(conn); + const answer = await conn.createAnswer(); + const mungedSdp = mungeIfNeeded(answer.sdp); + await conn.setLocalDescription({ type: "answer", sdp: mungedSdp }); + return mungedSdp; + }, + + async createOffer(iceRestart = false): Promise<string> { + const conn = assertConnection(); + const offer = await conn.createOffer({ iceRestart }); + const mungedSdp = mungeIfNeeded(offer.sdp); + await conn.setLocalDescription({ type: "offer", sdp: mungedSdp }); + return mungedSdp; + }, + + async handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void> { + const conn = assertConnection(); + if (!hasRemoteDescription) { + pendingIceCandidates.push(candidate); + log.debug("ICE candidate queued (no remote description yet)", { queueDepth: pendingIceCandidates.length }); + return; + } + await conn.addIceCandidate(candidate); + }, + + setLocalStream(stream: MediaStream): void { + const conn = assertConnection(); + const removedCount = localSenders.length; + for (const sender of localSenders) { + conn.removeTrack(sender); + } + + const newSenders = stream.getTracks().map((track) => conn.addTrack(track, stream)); + localSenders = newSenders; + + // Apply current mute/silence state to new tracks + applyTrackEnabled(); + log.debug("Local stream set", { removedSenders: removedCount, addedTracks: newSenders.length }); + }, + + async replaceTrack(stream: MediaStream): Promise<void> { + assertConnection(); + const newTracks = stream.getAudioTracks(); + if (newTracks.length === 0) { + log.warn("replaceTrack called with no audio tracks"); + return; + } + const newTrack = newTracks[0]!; + + if (localSenders.length > 0) { + // Swap track on existing sender — no SDP renegotiation needed + for (const sender of localSenders) { + await sender.replaceTrack(newTrack); + } + log.debug("Track replaced on existing senders", { senderCount: localSenders.length, trackId: newTrack.id }); + } else { + // No existing senders — fall back to addTrack (initial attach) + log.debug("replaceTrack fallback: no senders, using addTrack"); + const conn = assertConnection(); + const newSenders = stream.getTracks().map((track) => conn.addTrack(track, stream)); + localSenders = newSenders; + } + + // Apply current mute/silence state to the new track + applyTrackEnabled(); + }, + + getRemoteStreams(): readonly MediaStream[] { + return remoteStreams; + }, + + setMuted(muted: boolean): void { + isMuted = muted; + applyTrackEnabled(); + }, + + setSilenced(silenced: boolean): void { + isSilenced = silenced; + applyTrackEnabled(); + }, + + onIceCandidate(callback: IceCandidateCallback): () => void { + iceCandidateCallbacks.add(callback); + return () => { iceCandidateCallbacks.delete(callback); }; + }, + + onRemoteTrack(callback: RemoteTrackCallback): () => void { + remoteTrackCallbacks.add(callback); + return () => { remoteTrackCallbacks.delete(callback); }; + }, + + onStateChange(callback: StateChangeCallback): () => void { + stateChangeCallbacks.add(callback); + return () => { stateChangeCallbacks.delete(callback); }; + }, + + onIceStateChange(callback: IceStateCallback): () => void { + iceStateCallbacks.add(callback); + return () => { iceStateCallbacks.delete(callback); }; + }, + + destroy(): void { + if (destroyed) return; + destroyed = true; + log.debug("WebRTC service destroying", { remoteStreams: remoteStreams.length, localSenders: localSenders.length }); + if (pc !== null) { + pc.removeEventListener("icecandidate", handleIceCandidateEvent); + pc.removeEventListener("track", handleTrackEvent); + pc.removeEventListener("connectionstatechange", handleConnectionStateChange); + pc.removeEventListener("iceconnectionstatechange", handleIceConnectionStateChange); + pc.removeEventListener("negotiationneeded", handleNegotiationNeeded); + pc.close(); + pc = null; + } + localSenders = []; + remoteStreams = []; + pendingIceCandidates.length = 0; + iceCandidateCallbacks.clear(); + remoteTrackCallbacks.clear(); + stateChangeCallbacks.clear(); + iceStateCallbacks.clear(); + }, + }; +} diff --git a/Client/tauri-client/src/lib/window-state.ts b/Client/tauri-client/src/lib/window-state.ts new file mode 100644 index 00000000..2bd98e98 --- /dev/null +++ b/Client/tauri-client/src/lib/window-state.ts @@ -0,0 +1,157 @@ +/** + * Window state persistence — saves/restores window position and size. + * Uses Tauri IPC commands backed by tauri-plugin-store. + */ + +import { createLogger } from "./logger"; + +const log = createLogger("window-state"); + +export interface WindowState { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly maximized: boolean; +} + +const STORAGE_KEY = "windowState"; +const SAVE_DEBOUNCE_MS = 500; + +const invokePromise: Promise< + ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null +> = import("@tauri-apps/api/core") + .then((m) => m.invoke) + .catch(() => null); + +/** + * Save the current window state to the Tauri settings store. + */ +async function saveState(state: WindowState): Promise<void> { + const invoke = await invokePromise; + if (!invoke) return; + try { + await invoke("save_settings", { key: STORAGE_KEY, value: state }); + } catch (err) { + log.error("Failed to save window state", { error: String(err) }); + } +} + +/** + * Load the previously saved window state. + */ +async function loadState(): Promise<WindowState | null> { + const invoke = await invokePromise; + if (!invoke) return null; + try { + const all = (await invoke("get_settings")) as Record<string, unknown>; + const raw = all[STORAGE_KEY]; + if (raw && typeof raw === "object") { + const s = raw as Record<string, unknown>; + if ( + typeof s.x === "number" && + typeof s.y === "number" && + typeof s.width === "number" && + typeof s.height === "number" && + typeof s.maximized === "boolean" + ) { + return { + x: s.x, + y: s.y, + width: s.width, + height: s.height, + maximized: s.maximized, + }; + } + } + return null; + } catch (err) { + log.error("Failed to load window state", { error: String(err) }); + return null; + } +} + +/** + * Initialize window state persistence. + * Restores saved position/size on startup and listens for changes. + * Returns a cleanup function. + */ +export async function initWindowState(): Promise<() => void> { + let tauriWindow: typeof import("@tauri-apps/api/window") | undefined; + try { + tauriWindow = await import("@tauri-apps/api/window"); + } catch { + return () => {}; + } + + const win = tauriWindow.getCurrentWindow(); + const cleanups: Array<() => void> = []; + + // Restore saved state + const saved = await loadState(); + if (saved !== null) { + try { + if (saved.maximized) { + await win.maximize(); + } else { + const pos = new tauriWindow.PhysicalPosition(saved.x, saved.y); + const size = new tauriWindow.PhysicalSize(saved.width, saved.height); + await win.setPosition(pos); + await win.setSize(size); + } + log.info("Restored window state", { x: saved.x, y: saved.y, width: saved.width, height: saved.height }); + } catch (err) { + log.warn("Failed to restore window state", { error: String(err) }); + } + } + + // Debounced save on move/resize + let saveTimer: ReturnType<typeof setTimeout> | null = null; + + function debouncedSave(): void { + if (saveTimer !== null) { + clearTimeout(saveTimer); + } + saveTimer = setTimeout(() => { + void (async () => { + try { + const pos = await win.outerPosition(); + const size = await win.outerSize(); + const maximized = await win.isMaximized(); + await saveState({ + x: pos.x, + y: pos.y, + width: size.width, + height: size.height, + maximized, + }); + } catch { + // Window may have been closed during save + } + })(); + }, SAVE_DEBOUNCE_MS); + } + + try { + const unlistenMoved = await win.onMoved(() => debouncedSave()); + cleanups.push(unlistenMoved); + } catch { + // onMoved may not be available + } + + try { + const unlistenResized = await win.onResized(() => debouncedSave()); + cleanups.push(unlistenResized); + } catch { + // onResized may not be available + } + + return () => { + if (saveTimer !== null) { + clearTimeout(saveTimer); + } + for (const cleanup of cleanups) { + cleanup(); + } + }; +} diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts new file mode 100644 index 00000000..687eb6ab --- /dev/null +++ b/Client/tauri-client/src/lib/ws.ts @@ -0,0 +1,426 @@ +// Step 2.15 — WebSocket Client +// Uses Tauri IPC (ws_connect/ws_send/ws_disconnect commands + events) +// to proxy WSS through Rust, bypassing self-signed cert issues in webview. + +import type { ServerMessage, ClientMessage } from "./types"; +import { createLogger } from "./logger"; + +const log = createLogger("ws"); + +// Tauri IPC imports — resolved at runtime in Tauri context +let tauriInvoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null; +let tauriListen: ((event: string, handler: (e: { payload: unknown }) => void) => Promise<() => void>) | null = null; + +// Dynamically load Tauri APIs (avoids import errors in test/browser env) +async function ensureTauriApis(): Promise<void> { + if (tauriInvoke !== null) return; + try { + const core = await import("@tauri-apps/api/core"); + const event = await import("@tauri-apps/api/event"); + tauriInvoke = core.invoke; + tauriListen = event.listen; + } catch { + log.warn("Tauri APIs not available — WebSocket proxy will not work"); + } +} + +export type ConnectionState = + | "disconnected" + | "connecting" + | "authenticating" + | "connected" + | "reconnecting"; + +export type WsListener<T extends ServerMessage["type"]> = ( + payload: Extract<ServerMessage, { type: T }>["payload"], + id?: string, +) => void; + +/** TOFU certificate event emitted by the Rust WS proxy. */ +export interface CertTofuEvent { + readonly host: string; + readonly fingerprint: string; + readonly status: "trusted_first_use" | "trusted" | "mismatch"; + readonly message?: string; + readonly storedFingerprint?: string; +} + +/** Parse the stored fingerprint from the Rust cert-tofu message string. */ +export function parseStoredFingerprint(message?: string): string | undefined { + if (!message) return undefined; + const match = /Stored:\s+(\S+)/.exec(message); + return match?.[1]; +} + +export type CertMismatchListener = (event: CertTofuEvent) => void; + +export interface WsClientConfig { + readonly host: string; + readonly token: string; + readonly maxReconnectDelayMs?: number; + readonly maxMessageSizeBytes?: number; +} + +const DEFAULT_MAX_RECONNECT_DELAY = 30_000; +const DEFAULT_MAX_MESSAGE_SIZE = 1_048_576; // 1MB +const HEARTBEAT_INTERVAL_MS = 30_000; + +function uuid(): string { + return crypto.randomUUID(); +} + +export function createWsClient() { + let config: WsClientConfig | null = null; + let state: ConnectionState = "disconnected"; + let reconnectAttempt = 0; + let reconnectTimer: ReturnType<typeof setTimeout> | null = null; + let heartbeatTimer: ReturnType<typeof setInterval> | null = null; + let intentionalClose = false; + let certMismatchBlock = false; // blocks reconnect on TOFU mismatch + let proxyOpen = false; + + // Tauri event unsubscribe functions + const eventUnsubs: Array<() => void> = []; + + // Type-safe listener registry + const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>(); + + // State change listeners + const stateListeners = new Set<(state: ConnectionState) => void>(); + + // TOFU cert mismatch listeners + const certMismatchListeners = new Set<CertMismatchListener>(); + + function setState(newState: ConnectionState): void { + if (state !== newState) { + state = newState; + for (const listener of stateListeners) { + listener(state); + } + } + } + + function getReconnectDelay(): number { + const maxDelay = config?.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY; + return Math.min(1000 * Math.pow(2, reconnectAttempt), maxDelay); + } + + function startHeartbeat(): void { + stopHeartbeat(); + heartbeatTimer = setInterval(() => { + if (proxyOpen) { + try { + sendRaw(JSON.stringify({ type: "ping", payload: {} })); + } catch { + // Connection may have dropped + } + } + }, HEARTBEAT_INTERVAL_MS); + } + + function stopHeartbeat(): void { + if (heartbeatTimer !== null) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + } + + function scheduleReconnect(): void { + if (intentionalClose || certMismatchBlock || !config) return; + const delay = getReconnectDelay(); + log.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`); + setState("reconnecting"); + reconnectTimer = setTimeout(() => { + reconnectAttempt++; + connect(config!); + }, delay); + } + + function cancelReconnect(): void { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + } + + function handleMessage(raw: string): void { + const maxSize = config?.maxMessageSizeBytes ?? DEFAULT_MAX_MESSAGE_SIZE; + + if (raw.length > maxSize) { + log.warn("Message exceeds size limit, dropping", { size: raw.length }); + return; + } + + let parsed: { type?: string; payload?: unknown; id?: string }; + try { + parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string }; + } catch { + log.warn("Failed to parse WS message", { data: raw }); + return; + } + + // Server pong messages have no payload — silently ignore. + if (parsed.type === "pong") return; + + if (!parsed.type || parsed.payload === undefined) { + log.warn("Invalid WS message: missing type or payload", { parsed }); + return; + } + + const msg = parsed as unknown as ServerMessage; + + log.debug("WS ←", { type: msg.type, id: msg.id }); + + // auth_error — non-recoverable + if (msg.type === "auth_error") { + log.error("Authentication failed", { message: msg.payload.message }); + intentionalClose = true; + dispatch(msg); + void disconnectProxy(); + setState("disconnected"); + return; + } + + // auth_ok — mark as connected + if (msg.type === "auth_ok") { + setState("connected"); + reconnectAttempt = 0; + startHeartbeat(); + } + + dispatch(msg); + } + + function dispatch(msg: ServerMessage): void { + const typeListeners = listeners.get(msg.type); + if (!typeListeners || typeListeners.size === 0) { + log.debug("WS dispatch: no listeners", { type: msg.type }); + return; + } + for (const listener of typeListeners) { + try { + (listener as WsListener<typeof msg.type>)( + msg.payload as Extract<ServerMessage, { type: typeof msg.type }>["payload"], + msg.id, + ); + } catch (err) { + log.error(`Listener error for ${msg.type}`, err); + } + } + } + + async function setupEventListeners(): Promise<void> { + if (tauriListen === null) return; + + // Server messages + const unsubMsg = await tauriListen("ws-message", (e) => { + handleMessage(e.payload as string); + }); + eventUnsubs.push(unsubMsg); + + // Connection state changes from Rust + const unsubState = await tauriListen("ws-state", (e) => { + const rustState = e.payload as string; + log.debug("Rust WS state", { state: rustState }); + + if (rustState === "open") { + proxyOpen = true; + log.info("WebSocket open, sending auth"); + setState("authenticating"); + send({ type: "auth", payload: { token: config!.token } }); + } else if (rustState === "closed") { + proxyOpen = false; + log.info("WebSocket closed (proxy)"); + stopHeartbeat(); + if (!intentionalClose) { + scheduleReconnect(); + } else { + setState("disconnected"); + } + } + }); + eventUnsubs.push(unsubState); + + // Errors + const unsubErr = await tauriListen("ws-error", (e) => { + log.warn("WebSocket error (proxy)", { error: e.payload }); + }); + eventUnsubs.push(unsubErr); + + // TOFU certificate events + const unsubCert = await tauriListen("cert-tofu", (e) => { + const raw = e.payload as CertTofuEvent; + log.info("TOFU cert event", { host: raw.host, status: raw.status }); + + if (raw.status === "mismatch") { + const evt: CertTofuEvent = { + ...raw, + storedFingerprint: parseStoredFingerprint(raw.message), + }; + log.error("Certificate fingerprint mismatch!", { + host: evt.host, + fingerprint: evt.fingerprint, + storedFingerprint: evt.storedFingerprint, + }); + certMismatchBlock = true; + setState("disconnected"); + for (const listener of certMismatchListeners) { + listener(evt); + } + } + }); + eventUnsubs.push(unsubCert); + } + + function cleanupEventListeners(): void { + for (const unsub of eventUnsubs) { + try { + // Unsub may return a rejected promise if the Tauri resource + // was already invalidated after disconnect — safe to ignore. + const result = unsub() as unknown; + if (result instanceof Promise) { + result.catch(() => {}); + } + } catch { + // Sync errors also safe to ignore. + } + } + eventUnsubs.length = 0; + } + + async function connect(cfg: WsClientConfig): Promise<void> { + config = cfg; + intentionalClose = false; + cancelReconnect(); + + setState("connecting"); + + await ensureTauriApis(); + if (tauriInvoke === null) { + log.error("Tauri APIs not available, cannot connect WebSocket"); + setState("disconnected"); + return; + } + + const wsUrl = `wss://${cfg.host}/api/v1/ws`; + log.info("Connecting to", { url: wsUrl }); + + // Set up event listeners before connecting + cleanupEventListeners(); + await setupEventListeners(); + + try { + await tauriInvoke("ws_connect", { url: wsUrl }); + } catch (err) { + log.error("ws_connect failed", err); + proxyOpen = false; + + // Cert mismatch is handled by the cert-tofu event listener + // (which sets certMismatchBlock before this catch runs). + // scheduleReconnect() checks certMismatchBlock and will no-op if set. + scheduleReconnect(); + } + } + + function sendRaw(json: string): void { + if (tauriInvoke === null || !proxyOpen) { + log.warn("Cannot send, WebSocket not open"); + return; + } + tauriInvoke("ws_send", { message: json }).catch((err) => { + log.error("ws_send failed", err); + }); + } + + function send(msg: ClientMessage | { type: string; payload: unknown }): string { + const id = uuid(); + const envelope = { ...msg, id }; + log.debug("WS →", { type: msg.type, id }); + sendRaw(JSON.stringify(envelope)); + return id; + } + + async function disconnectProxy(): Promise<void> { + if (tauriInvoke !== null) { + try { + await tauriInvoke("ws_disconnect"); + } catch { + // ignore + } + } + proxyOpen = false; + } + + function disconnect(): void { + intentionalClose = true; + certMismatchBlock = false; + cancelReconnect(); + stopHeartbeat(); + cleanupEventListeners(); + void disconnectProxy(); + setState("disconnected"); + } + + return { + connect(cfg: WsClientConfig): void { + void connect(cfg); + }, + + disconnect, + + send(msg: ClientMessage): string { + return send(msg); + }, + + on<T extends ServerMessage["type"]>( + type: T, + listener: WsListener<T>, + ): () => void { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + const set = listeners.get(type)!; + set.add(listener as unknown as WsListener<ServerMessage["type"]>); + return () => { + set.delete(listener as unknown as WsListener<ServerMessage["type"]>); + }; + }, + + onStateChange(listener: (state: ConnectionState) => void): () => void { + stateListeners.add(listener); + return () => stateListeners.delete(listener); + }, + + /** Register a listener for TOFU certificate mismatch events. */ + onCertMismatch(listener: CertMismatchListener): () => void { + certMismatchListeners.add(listener); + return () => certMismatchListeners.delete(listener); + }, + + /** + * Accept a changed certificate fingerprint for a host. + * Call after the user acknowledges a cert mismatch warning, + * then reconnect. + */ + async acceptCertFingerprint(host: string, fingerprint: string): Promise<void> { + await ensureTauriApis(); + if (tauriInvoke === null) { + throw new Error("Tauri APIs not available"); + } + await tauriInvoke("accept_cert_fingerprint", { host, fingerprint }); + certMismatchBlock = false; + log.info("Accepted new cert fingerprint", { host }); + }, + + getState(): ConnectionState { + return state; + }, + + /** @internal for testing */ + _getWs(): WebSocket | null { + return null; + }, + }; +} + +export type WsClient = ReturnType<typeof createWsClient>; diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts new file mode 100644 index 00000000..c498efca --- /dev/null +++ b/Client/tauri-client/src/main.ts @@ -0,0 +1,345 @@ +// OwnCord Tauri v2 Client — Entry Point + +import "@styles/tokens.css"; +import "@styles/base.css"; +import "@styles/login.css"; +import "@styles/app.css"; + +import { installGlobalErrorHandlers, safeMount } from "@lib/safe-render"; +import { createRouter } from "@lib/router"; +import { createApiClient } from "@lib/api"; +import { createWsClient } from "@lib/ws"; +import { wireDispatcher } from "@lib/dispatcher"; +import { authStore, setAuth, clearAuth } from "@stores/auth.store"; +import { voiceStore, leaveVoiceChannel } from "@stores/voice.store"; +import { leaveVoice as voiceSessionLeave } from "@lib/voiceSession"; +import { createConnectPage } from "@pages/ConnectPage"; +import { createMainPage } from "@pages/MainPage"; +import { applyStoredAppearance } from "@components/SettingsOverlay"; +import { createConnectedOverlay } from "@components/ConnectedOverlay"; +import type { ConnectedOverlayControl } from "@components/ConnectedOverlay"; +import { createLogger } from "@lib/logger"; +import { saveCredential, deleteCredential } from "@lib/credentials"; +import { initWindowState } from "@lib/window-state"; +import { createCertMismatchModal } from "@components/CertMismatchModal"; +import { createProfileManager, createTauriBackend } from "@lib/profiles"; +import type { CertTofuEvent } from "@lib/ws"; + +import { openUrl } from "@tauri-apps/plugin-opener"; + +const log = createLogger("main"); + +// Disable the default browser context menu globally. +document.addEventListener("contextmenu", (e) => { + e.preventDefault(); +}); + +// Open external links (target="_blank") in the user's default browser. +document.addEventListener("click", (e) => { + const link = (e.target as HTMLElement).closest("a[target='_blank']") as HTMLAnchorElement | null; + if (link === null) return; + e.preventDefault(); + const href = link.href; + if (href && (href.startsWith("http://") || href.startsWith("https://"))) { + void openUrl(href); + } +}); + +// Install global error handlers first +installGlobalErrorHandlers(); + +// Apply stored theme/font/compact preferences before first render +applyStoredAppearance(); + +const appEl = document.getElementById("app"); +if (!appEl) { + throw new Error("Missing #app element"); +} + +// Create core services +const router = createRouter("connect"); +const api = createApiClient({ host: "" }, () => { + log.warn("Session expired (401), clearing auth"); + clearAuth(); +}); +const ws = createWsClient(); +const profileManager = createProfileManager(createTauriBackend()); +let dispatcherCleanup: (() => void) | null = null; +let connectedOverlay: ConnectedOverlayControl | null = null; +let lastConnectHost = ""; +let lastConnectToken = ""; + +// Certificate mismatch modal handler +let certModalActive = false; +ws.onCertMismatch((evt: CertTofuEvent) => { + if (certModalActive) return; + certModalActive = true; + + const modal = createCertMismatchModal({ + host: evt.host, + storedFingerprint: evt.storedFingerprint ?? "Unknown", + newFingerprint: evt.fingerprint, + onAccept: () => { + modal.destroy?.(); + certModalActive = false; + void (async () => { + try { + await ws.acceptCertFingerprint(evt.host, evt.fingerprint); + if (lastConnectHost && lastConnectToken) { + ws.connect({ host: lastConnectHost, token: lastConnectToken }); + } + } catch (err) { + log.error("Failed to accept cert fingerprint", err); + } + })(); + }, + onReject: () => { + modal.destroy?.(); + certModalActive = false; + ws.disconnect(); + clearAuth(); + router.navigate("connect"); + }, + }); + modal.mount(document.body); +}); + +// Current page component reference for cleanup +let currentPage: { destroy?(): void } | null = null; + +/** Run health checks for a list of profiles and update the connect page. */ +function runHealthChecks( + connectPage: { updateHealthStatus(host: string, status: { status: string; latencyMs: number | null; version: string | null }): void }, + profiles: readonly { host: string }[], +): void { + for (const profile of profiles) { + void (async () => { + try { + connectPage.updateHealthStatus(profile.host, { + status: "checking", + latencyMs: null, + version: null, + }); + const start = performance.now(); + const health = await api.getHealth(profile.host, 3000); + const elapsed = Math.round(performance.now() - start); + connectPage.updateHealthStatus(profile.host, { + status: elapsed > 1500 ? "slow" : "online", + latencyMs: elapsed, + version: health.version, + }); + } catch { + connectPage.updateHealthStatus(profile.host, { + status: "offline", + latencyMs: null, + version: null, + }); + } + })(); + } +} + +// Render the appropriate page based on router state +function renderPage(pageId: "connect" | "main"): void { + log.info("Navigating to page", { pageId }); + // Destroy previous page + currentPage?.destroy?.(); + currentPage = null; + appEl!.textContent = ""; + + // Shared helper for post-auth WS connect + overlay flow + function wirePostAuth(host: string, token: string, username: string, password?: string): void { + log.info("Post-auth wiring", { host, username }); + api.setConfig({ token }); + // Store token in authStore so the dispatcher's auth_ok handler has it + authStore.setState((prev) => ({ ...prev, token })); + lastConnectHost = host; + lastConnectToken = token; + ws.connect({ host, token }); + dispatcherCleanup = wireDispatcher(ws); + log.info("Dispatcher wired, connecting WS"); + + // Save credential for auto-reconnect (fire-and-forget) + void saveCredential(host, username, token, password); + + const unsubState = ws.onStateChange((wsState) => { + log.debug("WS state change", { state: wsState }); + if (wsState === "connected") { + unsubState(); + const auth = authStore.getState(); + connectedOverlay = createConnectedOverlay({ + serverName: auth.serverName ?? host, + username: auth.user?.username ?? username, + motd: auth.motd ?? "", + onReady: () => { + connectedOverlay?.destroy(); + connectedOverlay = null; + router.navigate("main"); + }, + }); + appEl!.appendChild(connectedOverlay.element); + connectedOverlay.show(); + + const unsubReady = ws.on("ready", () => { + unsubReady(); + connectedOverlay?.markReady(); + }); + } + }); + } + + // Track partial auth state for TOTP flow + let pendingTotpHost = ""; + let pendingTotpPartialToken = ""; + let pendingTotpUsername = ""; + + if (pageId === "connect") { + // Helper to get the profile list for the ConnectPage + function getProfileList(): readonly { name: string; host: string; id?: string; username?: string }[] { + const saved = profileManager.getAll(); + if (saved.length > 0) return saved; + // Fallback: show a default local server entry + return [{ name: "Local Server", host: "localhost:8443" }]; + } + + // Auto-save a profile for a host after successful login (if not already saved) + function ensureProfileExists(host: string, username: string): void { + const existing = profileManager.getAll().find((p) => p.host === host); + if (existing) { + // Update username and lastConnected + profileManager.updateProfile(existing.id, { username }); + profileManager.setLastConnected(existing.id); + } else { + const created = profileManager.addProfile({ + name: host.split(":")[0] ?? host, + host, + username, + autoConnect: false, + rememberPassword: false, + color: "#5865F2", + }); + profileManager.setLastConnected(created.id); + } + void profileManager.saveProfiles(); + } + + const connectPage = createConnectPage({ + async onLogin(host, username, password) { + api.setConfig({ host }); + const result = await api.login(username, password); + if (result.requires_2fa) { + pendingTotpHost = host; + pendingTotpPartialToken = result.partial_token ?? ""; + pendingTotpUsername = username; + connectPage.showTotp(); + return; + } + if (result.token) { + const savedPassword = connectPage.getRememberPassword() ? password : undefined; + ensureProfileExists(host, username); + wirePostAuth(host, result.token, username, savedPassword); + } + }, + async onRegister(host, username, password, inviteCode) { + api.setConfig({ host }); + const result = await api.register(username, password, inviteCode); + const savedPassword = connectPage.getRememberPassword() ? password : undefined; + ensureProfileExists(host, username); + wirePostAuth(host, result.token, username, savedPassword); + }, + async onTotpSubmit(code) { + if (!pendingTotpPartialToken) { + log.error("TOTP submit without pending partial token"); + return; + } + const result = await api.verifyTotp(code, pendingTotpPartialToken); + if (result.token) { + const savedPassword = connectPage.getRememberPassword() ? connectPage.getPassword() : undefined; + ensureProfileExists(pendingTotpHost, pendingTotpUsername); + wirePostAuth(pendingTotpHost, result.token, pendingTotpUsername, savedPassword); + } + }, + onAddProfile(name, host) { + profileManager.addProfile({ + name, + host, + username: "", + autoConnect: false, + rememberPassword: false, + color: "#5865F2", + }); + void profileManager.saveProfiles(); + connectPage.refreshProfiles(getProfileList()); + // Check health for the new profile + runHealthChecks(connectPage, getProfileList()); + }, + onDeleteProfile(profileId) { + profileManager.removeProfile(profileId); + void profileManager.saveProfiles(); + connectPage.refreshProfiles(getProfileList()); + }, + }, getProfileList()); + + safeMount(connectPage, appEl!); + currentPage = connectPage; + + // Load saved profiles and kick off health checks + void (async () => { + try { + await profileManager.loadProfiles(); + const profiles = getProfileList(); + connectPage.refreshProfiles(profiles); + runHealthChecks(connectPage, profiles); + } catch (err) { + log.warn("Failed to load profiles, using defaults", err); + runHealthChecks(connectPage, getProfileList()); + } + })(); + } else { + const mainPage = createMainPage({ ws, api }); + safeMount(mainPage, appEl!); + currentPage = mainPage; + } +} + +// Listen for navigation changes +router.onNavigate(renderPage); + +// Handle logout / disconnect +authStore.subscribe((state) => { + if (!state.isAuthenticated && router.getCurrentPage() === "main") { + // Leave voice channel before disconnecting so other clients see it immediately + const voice = voiceStore.getState(); + if (voice.currentChannelId !== null) { + voiceSessionLeave(false); // false: we send voice_leave below + ws.send({ type: "voice_leave", payload: {} }); + leaveVoiceChannel(); + } + dispatcherCleanup?.(); + dispatcherCleanup = null; + ws.disconnect(); + // Clear stored credential on logout + const host = api.getConfig().host; + if (host) { + void deleteCredential(host); + } + router.navigate("connect"); + } +}); + +// Send voice_leave on window close (best-effort — server readPump defer is the safety net) +window.addEventListener("beforeunload", () => { + const voice = voiceStore.getState(); + if (voice.currentChannelId !== null) { + voiceSessionLeave(false); // false: we send voice_leave below + ws.send({ type: "voice_leave", payload: {} }); + } +}); + +// Initial render +renderPage(router.getCurrentPage()); + +// Initialize window state persistence (fire-and-forget) +void initWindowState(); + +log.info("OwnCord client initialized"); diff --git a/Client/tauri-client/src/pages/ConnectPage.ts b/Client/tauri-client/src/pages/ConnectPage.ts new file mode 100644 index 00000000..9d110342 --- /dev/null +++ b/Client/tauri-client/src/pages/ConnectPage.ts @@ -0,0 +1,838 @@ +// ConnectPage — login/register page component. +// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. + +import { + createElement, + setText, + appendChildren, + clearChildren, + qs, +} from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import { openSettings, closeSettings, uiStore, setTransientError } from "@stores/ui.store"; +import { createSettingsOverlay } from "@components/SettingsOverlay"; +import type { HealthStatus, ServerProfile } from "@lib/profiles"; +import { loadCredential } from "@lib/credentials"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Form state machine states. */ +export type FormState = "idle" | "loading" | "totp" | "connecting" | "error"; + +/** Form mode: login or register. */ +export type FormMode = "login" | "register"; + +/** Callbacks for external wiring (API integration added later). */ +export interface ConnectPageCallbacks { + onLogin(host: string, username: string, password: string): Promise<void>; + onRegister( + host: string, + username: string, + password: string, + inviteCode: string, + ): Promise<void>; + onTotpSubmit(code: string): Promise<void>; + onAddProfile?(name: string, host: string): void; + onDeleteProfile?(profileId: string): void; +} + +/** Minimal profile shape for the default profile list (backward compat). */ +export interface SimpleProfile { + readonly name: string; + readonly host: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MIN_PASSWORD_LENGTH = 8; + +const DEFAULT_PROFILES: readonly SimpleProfile[] = [ + { name: "Local Server", host: "localhost:8443" }, +]; + +/** Color palette for server icons. */ +const ICON_COLORS = [ + "#5865F2", "#57F287", "#FEE75C", "#EB459E", "#ED4245", + "#3BA55D", "#FAA61A", "#5865F2", +]; + +function getIconColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash * 31 + name.charCodeAt(i)) | 0; + } + return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length] ?? "#5865f2"; +} + +function getIconInitials(name: string): string { + return name.slice(0, 2).toUpperCase(); +} + +// --------------------------------------------------------------------------- +// ConnectPage +// --------------------------------------------------------------------------- + +export function createConnectPage( + callbacks: ConnectPageCallbacks, + initialProfiles: readonly SimpleProfile[] = DEFAULT_PROFILES, +): MountableComponent & { + showTotp(): void; + showConnecting(): void; + showError(message: string): void; + resetToIdle(): void; + updateHealthStatus(host: string, status: HealthStatus): void; + getRememberPassword(): boolean; + getPassword(): string; + /** Re-render the server profile list with updated data. */ + refreshProfiles(profiles: readonly SimpleProfile[]): void; +} { + // --- internal state (mutable, local to this instance) --- + let formState: FormState = "idle"; + let formMode: FormMode = "login"; + let errorMessage = ""; + let container: Element | null = null; + + // Cleanup tracking + const abortController = new AbortController(); + + // --- cached DOM references (set during build) --- + let root: HTMLDivElement; + let serverListEl: HTMLDivElement; + let formTitle: HTMLHeadingElement; + let hostInput: HTMLInputElement; + let usernameInput: HTMLInputElement; + let passwordInput: HTMLInputElement; + let inviteGroup: HTMLDivElement; + let inviteInput: HTMLInputElement; + let submitBtn: HTMLButtonElement; + let submitBtnText: HTMLSpanElement; + let toggleModeBtn: HTMLAnchorElement; + let errorBanner: HTMLDivElement; + let totpOverlay: HTMLDivElement; + let totpInput: HTMLInputElement; + let totpSubmitBtn: HTMLButtonElement; + let rememberPasswordCheckbox: HTMLInputElement; + let statusBar: HTMLDivElement; + let statusBarFill: HTMLDivElement; + + // --------------------------------------------------------------------------- + // DOM construction + // --------------------------------------------------------------------------- + + function buildRoot(): HTMLDivElement { + root = createElement("div", { class: "connect-page" }); + + const leftPanel = buildServerPanel(); + const rightPanel = buildFormPanel(); + + appendChildren(root, leftPanel, rightPanel); + + // Status bar at bottom (hidden by default, shown with .visible class) + statusBar = createElement("div", { class: "status-bar" }); + statusBarFill = createElement("div", { class: "status-bar-fill" }); + statusBar.appendChild(statusBarFill); + root.appendChild(statusBar); + + // TOTP overlay (hidden by default) + totpOverlay = buildTotpOverlay(); + root.appendChild(totpOverlay); + + return root; + } + + function buildServerPanel(): HTMLDivElement { + const panel = createElement("div", { class: "server-panel" }); + + const header = createElement("div", { class: "server-panel-header" }); + const heading = createElement("h2", {}, "Servers"); + header.appendChild(heading); + + serverListEl = createElement("div", { class: "server-list" }); + + renderServerProfiles(initialProfiles); + + // Footer with "Add Server" button + const footer = createElement("div", { class: "server-panel-footer" }); + const addBtn = createElement("button", { + class: "btn-add-server", + type: "button", + }); + setText(addBtn, "+ Add Server"); + addBtn.addEventListener("click", handleAddServer, { signal: abortController.signal }); + footer.appendChild(addBtn); + + appendChildren(panel, header, serverListEl, footer); + return panel; + } + + // Map of host -> DOM elements for health status updates + const healthElements = new Map<string, { dot: HTMLDivElement; latency: HTMLSpanElement }>(); + + function renderServerProfiles(profiles: readonly SimpleProfile[]): void { + clearChildren(serverListEl); + healthElements.clear(); + for (const profile of profiles) { + const item = createElement("div", { + class: "server-item", + "data-host": profile.host, + }); + + const icon = createElement("div", { + class: "srv-icon", + style: `background:${getIconColor(profile.name)}`, + }); + setText(icon, getIconInitials(profile.name)); + + // Health status dot on the icon + const statusDot = createElement("div", { class: "srv-status-dot unknown" }); + icon.appendChild(statusDot); + + const info = createElement("div", { class: "srv-info" }); + const name = createElement("div", { class: "srv-name" }, profile.name); + const meta = createElement("div", { class: "srv-meta" }); + const host = createElement("span", { class: "srv-host" }, profile.host); + const latency = createElement("span", { class: "srv-latency" }); + appendChildren(meta, host, latency); + + // Show username if available (full profile has it) + const fullProfile = profile as Partial<ServerProfile>; + if (fullProfile.username) { + const usernameEl = createElement("span", { class: "srv-host" }, fullProfile.username); + appendChildren(meta, usernameEl); + } + + appendChildren(info, name, meta); + + healthElements.set(profile.host, { dot: statusDot, latency }); + + // Delete button (only for full profiles that have an id) + const actions = createElement("div", { class: "srv-actions" }); + if (fullProfile.id && callbacks.onDeleteProfile) { + const deleteBtn = createElement("button", { + class: "srv-btn danger", + type: "button", + "aria-label": "Delete server", + }); + setText(deleteBtn, "\u2715"); + deleteBtn.addEventListener( + "click", + (e) => { + e.stopPropagation(); + callbacks.onDeleteProfile!(fullProfile.id!); + }, + { signal: abortController.signal }, + ); + actions.appendChild(deleteBtn); + } + + appendChildren(item, icon, info, actions); + + item.addEventListener( + "click", + () => { + hostInput.value = profile.host; + // Auto-fill username from profile + if (fullProfile.username) { + usernameInput.value = fullProfile.username; + } + // Auto-fill credentials from credential store + const requestedHost = profile.host; + void (async () => { + const cred = await loadCredential(requestedHost); + // Guard: user may have clicked a different profile while loading + if (cred && hostInput.value === requestedHost) { + usernameInput.value = cred.username; + if (cred.password) { + passwordInput.value = cred.password; + rememberPasswordCheckbox.checked = true; + } + } + })(); + }, + { signal: abortController.signal }, + ); + + serverListEl.appendChild(item); + } + } + + function updateHealthStatus(host: string, status: HealthStatus): void { + const els = healthElements.get(host); + if (!els) return; + + // Update status dot + els.dot.className = `srv-status-dot ${status.status}`; + + // Update latency badge + if (status.latencyMs !== null) { + const ms = status.latencyMs; + setText(els.latency, `${ms}ms`); + els.latency.className = `srv-latency ${ms < 100 ? "good" : ms < 500 ? "warn" : "bad"}`; + } else { + setText(els.latency, ""); + els.latency.className = "srv-latency"; + } + } + + function buildFormPanel(): HTMLDivElement { + const panel = createElement("div", { class: "form-panel" }); + + // Settings gear (top right) + const settingsBtn = createElement("button", { + class: "settings-gear", + type: "button", + "aria-label": "Settings", + }); + setText(settingsBtn, "\u2699"); + settingsBtn.addEventListener("click", () => openSettings(), { signal: abortController.signal }); + + // Form container + const formContainer = createElement("div", { class: "form-container" }); + + // Logo section + const formLogo = createElement("div", { class: "form-logo" }); + const logoMark = createElement("div", { class: "form-logo-mark" }, "OC"); + const logoTitle = createElement("h1", {}, "OwnCord"); + const logoSubtitle = createElement("p", {}, "Connect to your server"); + appendChildren(formLogo, logoMark, logoTitle, logoSubtitle); + + // Form title + formTitle = createElement("h1", {}, "Login"); + + // Error banner (hidden by default via CSS display:none, shown with .visible) + errorBanner = createElement("div", { + class: "error-banner", + role: "alert", + }); + + // Form + const form = createElement("form", { class: "connect-form" }); + form.setAttribute("novalidate", ""); + + // Host + const hostGroup = buildFormGroup("host", "Server Address", "text", "localhost:8443"); + hostInput = qs("input", hostGroup) as HTMLInputElement; + + // Username + const usernameGroup = buildFormGroup("username", "Username", "text", ""); + usernameInput = qs("input", usernameGroup) as HTMLInputElement; + + // Password + const passwordGroup = buildFormGroup("password", "Password", "password", ""); + passwordInput = qs("input", passwordGroup) as HTMLInputElement; + + // Remember password checkbox + const rememberGroup = createElement("div", { class: "form-group remember-password-group" }); + rememberPasswordCheckbox = createElement("input", { + type: "checkbox", + id: "remember-password", + }); + const rememberLabel = createElement("label", { + for: "remember-password", + class: "remember-password-label", + }, "Remember password"); + appendChildren(rememberGroup, rememberPasswordCheckbox, rememberLabel); + + // Invite code (register only, hidden by default) + inviteGroup = buildFormGroup("invite", "Invite Code", "text", ""); + inviteGroup.classList.add("form-group--hidden"); + inviteInput = qs("input", inviteGroup) as HTMLInputElement; + + // Submit button + submitBtn = createElement("button", { + class: "btn-primary", + type: "submit", + }); + submitBtnText = createElement("span", { class: "btn-text" }, "Login"); + const spinnerWrapper = createElement("span", { class: "btn-spinner" }); + const spinner = createElement("div", { class: "spinner" }); + spinnerWrapper.appendChild(spinner); + appendChildren(submitBtn, spinnerWrapper, submitBtnText); + + // Toggle mode link + const formSwitch = createElement("div", { class: "form-switch" }); + toggleModeBtn = createElement("a", {}, "Need an account? Register") as HTMLAnchorElement; + formSwitch.appendChild(toggleModeBtn); + + appendChildren(form, hostGroup, usernameGroup, passwordGroup, rememberGroup, inviteGroup, submitBtn, formSwitch); + + // Wire form events + form.addEventListener("submit", handleFormSubmit, { signal: abortController.signal }); + toggleModeBtn.addEventListener("click", handleToggleMode, { signal: abortController.signal }); + + appendChildren(formContainer, formLogo, errorBanner, form); + appendChildren(panel, settingsBtn, formContainer); + return panel; + } + + function buildFormGroup( + id: string, + labelText: string, + inputType: string, + placeholder: string, + ): HTMLDivElement { + const group = createElement("div", { class: "form-group" }); + const label = createElement("label", { class: "form-label", for: id }, labelText); + const input = createElement("input", { + class: "form-input", + id, + name: id, + type: inputType, + placeholder, + autocomplete: inputType === "password" ? "current-password" : "off", + }); + if (id === "host") { + input.setAttribute("required", ""); + } + if (id === "username" || id === "password") { + input.setAttribute("required", ""); + } + + if (inputType === "password") { + const wrapper = createElement("div", { class: "password-wrapper" }); + const toggle = createElement("button", { + class: "password-toggle", + type: "button", + "aria-label": "Toggle password visibility", + }, "\uD83D\uDC41"); + toggle.addEventListener( + "click", + () => { + const isPassword = input.getAttribute("type") === "password"; + input.setAttribute("type", isPassword ? "text" : "password"); + }, + { signal: abortController.signal }, + ); + appendChildren(wrapper, input, toggle); + appendChildren(group, label, wrapper); + } else { + appendChildren(group, label, input); + } + + return group; + } + + function buildTotpOverlay(): HTMLDivElement { + const overlay = createElement("div", { class: "totp-overlay totp-overlay--hidden" }); + const card = createElement("div", { class: "totp-card" }); + const title = createElement("h2", { class: "totp-title" }, "Two-Factor Authentication"); + const description = createElement("p", { + class: "totp-subtitle", + }, "Enter the 6-digit code from your authenticator app."); + + totpInput = createElement("input", { + class: "form-input", + type: "text", + maxlength: "6", + placeholder: "000000", + inputmode: "numeric", + pattern: "[0-9]{6}", + autocomplete: "one-time-code", + }); + + totpSubmitBtn = createElement("button", { + class: "btn-primary", + type: "button", + }, "Verify"); + + const cancelBtn = createElement("button", { + class: "totp-back", + type: "button", + }, "Cancel"); + + totpSubmitBtn.addEventListener("click", handleTotpSubmit, { signal: abortController.signal }); + cancelBtn.addEventListener("click", handleTotpCancel, { signal: abortController.signal }); + + // Allow Enter key in TOTP input + totpInput.addEventListener( + "keydown", + (e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleTotpSubmit(); + } + }, + { signal: abortController.signal }, + ); + + appendChildren(card, title, description, totpInput, totpSubmitBtn, cancelBtn); + overlay.appendChild(card); + return overlay; + } + + // --------------------------------------------------------------------------- + // Add Server modal + // --------------------------------------------------------------------------- + + function handleAddServer(): void { + if (!callbacks.onAddProfile) return; + + const overlay = createElement("div", { class: "modal-overlay visible" }); + const modal = createElement("div", { class: "modal" }); + + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Add Server"); + const closeBtn = createElement("button", { class: "modal-close", type: "button" }); + setText(closeBtn, "\u2715"); + appendChildren(header, title, closeBtn); + + const body = createElement("div", { class: "modal-body" }); + const nameGroup = createElement("div", { class: "form-group" }); + const nameLabel = createElement("label", { class: "form-label" }, "Server Name"); + const nameInput = createElement("input", { + class: "form-input", + type: "text", + placeholder: "My Server", + }); + appendChildren(nameGroup, nameLabel, nameInput); + + const hostGroup = createElement("div", { class: "form-group" }); + const hostLabel = createElement("label", { class: "form-label" }, "Host Address"); + const hostAddrInput = createElement("input", { + class: "form-input", + type: "text", + placeholder: "example.com:8443", + }); + appendChildren(hostGroup, hostLabel, hostAddrInput); + + appendChildren(body, nameGroup, hostGroup); + + const footer = createElement("div", { class: "modal-footer" }); + const cancelBtn = createElement("button", { class: "btn-ghost", type: "button" }); + setText(cancelBtn, "Cancel"); + const saveBtn = createElement("button", { class: "btn-primary", type: "button" }); + setText(saveBtn, "Add Server"); + appendChildren(footer, cancelBtn, saveBtn); + + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + function closeModal(): void { + overlay.remove(); + } + + function handleSave(): void { + const name = (nameInput as HTMLInputElement).value.trim(); + const addr = (hostAddrInput as HTMLInputElement).value.trim(); + if (!name || !addr) return; + callbacks.onAddProfile!(name, addr); + closeModal(); + } + + closeBtn.addEventListener("click", closeModal, { signal: abortController.signal }); + cancelBtn.addEventListener("click", closeModal, { signal: abortController.signal }); + saveBtn.addEventListener("click", handleSave, { signal: abortController.signal }); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) closeModal(); + }, { signal: abortController.signal }); + + // Allow backdrop stop propagation on modal body + modal.addEventListener("click", (e) => e.stopPropagation(), { signal: abortController.signal }); + + // Enter key submits + hostAddrInput.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") handleSave(); + }, { signal: abortController.signal }); + + root.appendChild(overlay); + (nameInput as HTMLInputElement).focus(); + } + + // --------------------------------------------------------------------------- + // State transitions + // --------------------------------------------------------------------------- + + function transitionTo(state: FormState, error?: string): void { + formState = state; + errorMessage = error ?? ""; + + // Update UI based on state + updateSubmitButton(); + updateErrorBanner(); + updateStatusBar(); + updateTotpOverlay(); + updateFormInputsDisabled(); + } + + function updateSubmitButton(): void { + const isLoading = formState === "loading" || formState === "connecting"; + submitBtn.disabled = isLoading; + submitBtn.classList.toggle("loading", isLoading); + + if (formState === "connecting") { + setText(submitBtnText, "Connecting\u2026"); + } else if (formState === "loading") { + setText(submitBtnText, formMode === "login" ? "Logging in\u2026" : "Registering\u2026"); + } else { + setText(submitBtnText, formMode === "login" ? "Login" : "Register"); + } + } + + function updateErrorBanner(): void { + if (formState === "error" && errorMessage) { + setText(errorBanner, errorMessage); + errorBanner.classList.add("visible"); + // The shakeX animation plays automatically via CSS on .error-banner + // Re-trigger animation by removing and re-adding the element + errorBanner.style.animation = "none"; + // Force reflow to restart animation + void errorBanner.offsetWidth; + errorBanner.style.animation = ""; + } else { + errorBanner.classList.remove("visible"); + } + } + + function updateStatusBar(): void { + switch (formState) { + case "idle": + statusBar.classList.remove("visible", "indeterminate"); + break; + case "loading": + statusBar.classList.add("visible", "indeterminate"); + break; + case "totp": + statusBar.classList.remove("visible", "indeterminate"); + break; + case "connecting": + statusBar.classList.add("visible", "indeterminate"); + break; + case "error": + statusBar.classList.remove("visible", "indeterminate"); + break; + } + } + + function updateTotpOverlay(): void { + if (formState === "totp") { + totpOverlay.classList.remove("totp-overlay--hidden"); + totpInput.value = ""; + totpInput.focus(); + } else { + totpOverlay.classList.add("totp-overlay--hidden"); + } + } + + function updateFormInputsDisabled(): void { + const disable = formState === "loading" || formState === "connecting"; + hostInput.disabled = disable; + usernameInput.disabled = disable; + passwordInput.disabled = disable; + inviteInput.disabled = disable; + } + + // --------------------------------------------------------------------------- + // Event handlers + // --------------------------------------------------------------------------- + + function handleToggleMode(): void { + formMode = formMode === "login" ? "register" : "login"; + + setText(formTitle, formMode === "login" ? "Login" : "Register"); + setText(submitBtnText, formMode === "login" ? "Login" : "Register"); + setText( + toggleModeBtn, + formMode === "login" ? "Need an account? Register" : "Already have an account? Login", + ); + + inviteGroup.classList.toggle("form-group--hidden", formMode === "login"); + + // Clear any existing error + if (formState === "error") { + transitionTo("idle"); + } + } + + function validateForm(): string | null { + const host = hostInput.value.trim(); + const username = usernameInput.value.trim(); + const password = passwordInput.value; + + if (!host) { + return "Server address is required."; + } + if (!username) { + return "Username is required."; + } + if (!password) { + return "Password is required."; + } + if (password.length < MIN_PASSWORD_LENGTH) { + return `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`; + } + if (formMode === "register") { + const inviteCode = inviteInput.value.trim(); + if (!inviteCode) { + return "Invite code is required for registration."; + } + } + return null; + } + + async function handleFormSubmit(e: Event): Promise<void> { + e.preventDefault(); + + if (formState === "loading" || formState === "connecting") { + return; + } + + const validationError = validateForm(); + if (validationError !== null) { + transitionTo("error", validationError); + return; + } + + const host = hostInput.value.trim(); + const username = usernameInput.value.trim(); + const password = passwordInput.value; + + transitionTo("loading"); + + try { + if (formMode === "login") { + await callbacks.onLogin(host, username, password); + } else { + const inviteCode = inviteInput.value.trim(); + await callbacks.onRegister(host, username, password, inviteCode); + } + // If the callback didn't throw, the caller handles navigation. + // The caller may also call showTotp() or showError() on this page. + } catch (err: unknown) { + let message: string; + if (err instanceof Error) { + message = err.message; + } else if (typeof err === "string") { + message = err; + } else if (err !== null && typeof err === "object" && "message" in err) { + message = String((err as { message: unknown }).message); + } else { + message = String(err); + } + transitionTo("error", message); + } + } + + async function handleTotpSubmit(): Promise<void> { + const code = totpInput.value.trim(); + if (code.length !== 6 || !/^\d{6}$/.test(code)) { + // Simple inline feedback — add error class to the input + totpInput.classList.add("error"); + setTimeout(() => totpInput.classList.remove("error"), 500); + return; + } + + totpSubmitBtn.disabled = true; + setText(totpSubmitBtn, "Verifying\u2026"); + + try { + await callbacks.onTotpSubmit(code); + } catch (err) { + const message = err instanceof Error ? err.message : "Verification failed."; + transitionTo("error", message); + } finally { + totpSubmitBtn.disabled = false; + setText(totpSubmitBtn, "Verify"); + } + } + + function handleTotpCancel(): void { + transitionTo("idle"); + } + + // --------------------------------------------------------------------------- + // Public API for external state control + // --------------------------------------------------------------------------- + + /** Called externally when login returns requires_2fa. */ + function showTotp(): void { + transitionTo("totp"); + } + + /** Called externally to show a connection-in-progress state. */ + function showConnecting(): void { + transitionTo("connecting"); + } + + /** Called externally to display an error. */ + function showError(message: string): void { + transitionTo("error", message); + } + + /** Reset form to idle state. */ + function resetToIdle(): void { + transitionTo("idle"); + } + + // --------------------------------------------------------------------------- + // MountableComponent + // --------------------------------------------------------------------------- + + // Settings overlay instance + let settingsOverlay: ReturnType<typeof createSettingsOverlay> | null = null; + + function mount(target: Element): void { + container = target; + const rootEl = buildRoot(); + container.appendChild(rootEl); + + // Mount settings overlay on the connect page + settingsOverlay = createSettingsOverlay({ + onClose: () => closeSettings(), + onChangePassword: async () => { /* no-op on connect page */ }, + onUpdateProfile: async () => { /* no-op on connect page */ }, + onLogout: () => { /* no-op on connect page */ }, + }); + settingsOverlay.mount(rootEl); + + // Show any pending auth error (e.g. "already connected from another client") + const pendingError = uiStore.getState().transientError; + if (pendingError) { + transitionTo("error", pendingError); + setTransientError(null); + } + + // Focus the first input + hostInput.focus(); + } + + function destroy(): void { + // Abort all event listeners registered with the signal + abortController.abort(); + settingsOverlay?.destroy?.(); + settingsOverlay = null; + + if (container && root) { + container.removeChild(root); + } + container = null; + } + + return { + mount, + destroy, + // Extended API for external control + showTotp, + showConnecting, + showError, + resetToIdle, + updateHealthStatus, + /** Whether the "Remember Password" checkbox is checked. */ + getRememberPassword(): boolean { + return rememberPasswordCheckbox?.checked ?? false; + }, + /** Get the current password input value (for saving when remember is checked). */ + getPassword(): string { + return passwordInput?.value ?? ""; + }, + /** Re-render the server profile list with updated data. */ + refreshProfiles(profiles: readonly SimpleProfile[]): void { + renderServerProfiles(profiles); + }, + }; +} + +export type ConnectPage = ReturnType<typeof createConnectPage>; diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts new file mode 100644 index 00000000..18743748 --- /dev/null +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -0,0 +1,747 @@ +// MainPage — primary app layout after login. +// Composes standalone components; never sets innerHTML with user content. + +import { createElement, appendChildren, setText, clearChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; +import type { WsClient } from "@lib/ws"; +import type { ApiClient } from "@lib/api"; +import { createLogger } from "@lib/logger"; +import { createRateLimiterSet } from "@lib/rate-limiter"; +import { createServerStrip } from "@components/ServerStrip"; +import { createChannelSidebar } from "@components/ChannelSidebar"; +import { createCreateChannelModal } from "@components/CreateChannelModal"; +import { createEditChannelModal } from "@components/EditChannelModal"; +import { createDeleteChannelModal } from "@components/DeleteChannelModal"; +import { createUserBar } from "@components/UserBar"; +import { createVoiceWidget } from "@components/VoiceWidget"; +import { createMemberList } from "@components/MemberList"; +import { createMessageList } from "@components/MessageList"; +import type { MessageListComponent } from "@components/MessageList"; +import { createMessageInput } from "@components/MessageInput"; +import type { MessageInputComponent } from "@components/MessageInput"; +import { createTypingIndicator } from "@components/TypingIndicator"; +import { createServerBanner } from "@components/ServerBanner"; +import type { ServerBannerControl } from "@components/ServerBanner"; +import { createSettingsOverlay } from "@components/SettingsOverlay"; +import { createToastContainer } from "@components/Toast"; +import type { ToastContainer } from "@components/Toast"; +import { authStore, clearAuth, updateUser } from "@stores/auth.store"; +import { closeSettings, toggleMemberList, uiStore } from "@stores/ui.store"; +import { channelsStore, getActiveChannel, setActiveChannel } from "@stores/channels.store"; +import { + voiceStore, + joinVoiceChannel, + leaveVoiceChannel, + setLocalCamera, + setLocalScreenshare, +} from "@stores/voice.store"; +import { + joinVoice, + leaveVoice as voiceSessionLeave, + setMuted as voiceSessionSetMuted, + setDeafened as voiceSessionSetDeafened, + setWsClient, + setOnError as setVoiceOnError, + clearOnError as clearVoiceOnError, +} from "@lib/voiceSession"; +import { + setMessages, + prependMessages, + isChannelLoaded, + getChannelMessages, +} from "@stores/messages.store"; +import { buildChatHeader } from "./main-page/ChatHeader"; +import { setServerHost } from "@components/message-list/renderers"; +import { + createQuickSwitcherManager, + createInviteManagerController, + createPinnedPanelController, +} from "./main-page/OverlayManagers"; +import { createUpdateNotifier } from "@components/UpdateNotifier"; + +const log = createLogger("main-page"); + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface MainPageOptions { + readonly ws: WsClient; + readonly api: ApiClient; +} + +// --------------------------------------------------------------------------- +// MainPage +// --------------------------------------------------------------------------- + +export function createMainPage(options: MainPageOptions): MountableComponent { + const { ws, api } = options; + + // Let voiceSession send signaling messages over this WS connection + setWsClient(ws); + + // Set server host for resolving relative attachment URLs + const apiConfig = api.getConfig(); + if (apiConfig.host) { + setServerHost(apiConfig.host); + } + + const limiters = createRateLimiterSet(); + + let container: Element | null = null; + let root: HTMLDivElement | null = null; + + // Child components tracked for cleanup + const children: MountableComponent[] = []; + const unsubscribers: Array<() => void> = []; + + // Refs we need to update reactively + let banner: ServerBannerControl | null = null; + let messageList: MessageListComponent | null = null; + let messageInput: MessageInputComponent | null = null; + let typingIndicator: MountableComponent | null = null; + let chatHeaderName: HTMLSpanElement | null = null; + + // Containers for swappable sub-components + let messagesSlot: HTMLDivElement | null = null; + let typingSlot: HTMLDivElement | null = null; + let inputSlot: HTMLDivElement | null = null; + + // Track currently mounted channel to avoid redundant rebuilds + let currentChannelId: number | null = null; + + // Pending delete confirmations (double-click to delete pattern) + const pendingDeletes = new Map<number, number>(); + + // Abort controller for channel-scoped async operations (e.g. message fetch) + let channelAbort: AbortController | null = null; + + // Toast container for user-facing error feedback + let toast: ToastContainer | null = null; + + // Overlay controllers — created in mount() + let pinnedCtrl: ReturnType<typeof createPinnedPanelController> | null = null; + let inviteCtrl: ReturnType<typeof createInviteManagerController> | null = null; + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + function getCurrentUserId(): number { + return authStore.getState().user?.id ?? 0; + } + + // --------------------------------------------------------------------------- + // Message loading (REST) + // --------------------------------------------------------------------------- + + async function loadMessages(channelId: number, signal: AbortSignal): Promise<void> { + if (isChannelLoaded(channelId)) { + log.debug("Messages already loaded", { channelId }); + return; + } + try { + const resp = await api.getMessages(channelId, { limit: 50 }, signal); + if (!signal.aborted) { + log.info("Messages loaded", { channelId, count: resp.messages.length, hasMore: resp.has_more }); + setMessages(channelId, resp.messages, resp.has_more); + } + } catch (err) { + if (!signal.aborted) { + log.error("Failed to load messages", { channelId, error: String(err) }); + toast?.show("Failed to load messages", "error"); + } + } + } + + async function loadOlderMessages(channelId: number, signal: AbortSignal): Promise<void> { + const messages = getChannelMessages(channelId); + if (messages.length === 0) return; + const oldest = messages[0]; + if (oldest === undefined) return; + try { + const resp = await api.getMessages( + channelId, + { before: oldest.id, limit: 50 }, + signal, + ); + if (!signal.aborted) { + prependMessages(channelId, resp.messages, resp.has_more); + } + } catch (err) { + if (!signal.aborted) { + log.error("Failed to load older messages", { channelId, error: String(err) }); + toast?.show("Failed to load older messages", "error"); + } + } + } + + // --------------------------------------------------------------------------- + // Channel switching — rebuild channel-dependent components + // --------------------------------------------------------------------------- + + function mountChannelComponents(channelId: number, channelName: string): void { + if (currentChannelId === channelId) return; + + destroyChannelComponents(); + currentChannelId = channelId; + + log.info("Switching channel", { channelId, channelName }); + + // Notify server which channel we're viewing so channel-scoped + // broadcasts (chat_message, typing, etc.) are delivered to us. + ws.send({ + type: "channel_focus", + payload: { channel_id: channelId }, + }); + + channelAbort = new AbortController(); + const signal = channelAbort.signal; + const userId = getCurrentUserId(); + + void loadMessages(channelId, signal); + + // MessageList + messageList = createMessageList({ + channelId, + currentUserId: userId, + onScrollTop: () => { + if (channelAbort !== null) { + void loadOlderMessages(channelId, channelAbort.signal); + } + }, + onReplyClick: (msgId: number) => { + const msgs = getChannelMessages(channelId); + const msg = msgs.find((m) => m.id === msgId); + messageInput?.setReplyTo(msgId, msg?.user.username ?? ""); + }, + onEditClick: (msgId: number) => { + const msgs = getChannelMessages(channelId); + const msg = msgs.find((m) => m.id === msgId); + if (msg !== undefined) { + messageInput?.startEdit(msgId, msg.content); + } + }, + onDeleteClick: (msgId: number) => { + if (pendingDeletes.has(msgId)) { + window.clearTimeout(pendingDeletes.get(msgId)); + pendingDeletes.delete(msgId); + ws.send({ + type: "chat_delete", + payload: { message_id: msgId }, + }); + toast?.show("Message deleted", "success"); + } else { + toast?.show("Click delete again to confirm", "info"); + const tid = window.setTimeout(() => pendingDeletes.delete(msgId), 5000); + pendingDeletes.set(msgId, tid); + } + }, + onReactionClick: (msgId: number, emoji: string) => { + if (emoji === "") return; + if (!limiters.reactions.tryConsume()) { + toast?.show("Slow down! Please wait before reacting again.", "error"); + return; + } + const msgs = getChannelMessages(channelId); + const msg = msgs.find((m) => m.id === msgId); + const existing = msg?.reactions.find((r) => r.emoji === emoji); + const type = existing?.me ? "reaction_remove" : "reaction_add"; + ws.send({ type, payload: { message_id: msgId, emoji } }); + }, + }); + if (messagesSlot !== null) { + messageList.mount(messagesSlot); + } + children.push(messageList); + + // TypingIndicator + typingIndicator = createTypingIndicator({ + channelId, + currentUserId: userId, + }); + if (typingSlot !== null) { + typingIndicator.mount(typingSlot); + } + children.push(typingIndicator); + + // MessageInput + messageInput = createMessageInput({ + channelId, + channelName, + onSend: (content: string, replyTo: number | null, attachments: readonly string[]) => { + if (ws.getState() !== "connected") { + log.warn("Cannot send message: not connected"); + toast?.show("Not connected — message not sent", "error"); + return; + } + ws.send({ + type: "chat_send", + payload: { + channel_id: channelId, + content, + reply_to: replyTo, + attachments, + }, + }); + }, + onUploadFile: async (file: File) => { + const result = await api.uploadFile(file); + return { id: result.id, url: result.url, filename: result.filename }; + }, + onTyping: () => { + if (limiters.typing.tryConsume(String(channelId))) { + ws.send({ + type: "typing_start", + payload: { channel_id: channelId }, + }); + } + }, + onEditMessage: (messageId: number, content: string) => { + const trimmed = content.trim(); + if (trimmed === "") { + toast?.show("Message cannot be empty", "error"); + return; + } + const msgs = getChannelMessages(channelId); + const original = msgs.find((m) => m.id === messageId); + if (original !== undefined && original.content === trimmed) { + return; + } + ws.send({ + type: "chat_edit", + payload: { message_id: messageId, content: trimmed }, + }); + toast?.show("Message edited", "success"); + }, + }); + if (inputSlot !== null) { + messageInput.mount(inputSlot); + } + children.push(messageInput); + + // Update header + if (chatHeaderName !== null) { + setText(chatHeaderName, channelName); + } + } + + function destroyChannelComponents(): void { + for (const tid of pendingDeletes.values()) { + window.clearTimeout(tid); + } + pendingDeletes.clear(); + + if (channelAbort !== null) { + channelAbort.abort(); + channelAbort = null; + } + + if (messageList !== null) { + messageList.destroy?.(); + const idx = children.indexOf(messageList); + if (idx !== -1) children.splice(idx, 1); + messageList = null; + } + if (typingIndicator !== null) { + typingIndicator.destroy?.(); + const idx = children.indexOf(typingIndicator); + if (idx !== -1) children.splice(idx, 1); + typingIndicator = null; + } + if (messageInput !== null) { + messageInput.destroy?.(); + const idx = children.indexOf(messageInput as MountableComponent); + if (idx !== -1) children.splice(idx, 1); + messageInput = null; + } + if (messagesSlot !== null) { clearChildren(messagesSlot); } + if (typingSlot !== null) { clearChildren(typingSlot); } + if (inputSlot !== null) { clearChildren(inputSlot); } + + currentChannelId = null; + } + + // --------------------------------------------------------------------------- + // Mount / Destroy + // --------------------------------------------------------------------------- + + function mount(target: Element): void { + log.info("MainPage mounting"); + container = target; + + root = createElement("div", { + style: "display:flex;flex-direction:column;height:100vh;width:100%", + }); + + // --- Reconnect banner --- + banner = createServerBanner(); + root.appendChild(banner.element); + + unsubscribers.push( + ws.onStateChange((wsState) => { + if (banner === null) return; + if (wsState === "reconnecting") { + banner.showReconnecting(); + } else if (wsState === "connected") { + banner.hide(); + } + }), + ); + + unsubscribers.push( + ws.on("server_restart", (payload) => { + if (banner !== null) { + banner.showRestart(payload.delay_seconds); + } + }), + ); + + // --- Voice config: trigger WebRTC join flow --- + unsubscribers.push( + ws.on("voice_config", (payload) => { + void joinVoice(payload.channel_id, payload, async () => { + const creds = await api.getVoiceCredentials(); + return creds.ice_servers; + }); + }), + ); + + // --- Main .app row --- + const app = createElement("div", { class: "app", "data-testid": "app-layout" }); + + // Server strip + const serverStripSlot = createElement("div", {}); + const serverStrip = createServerStrip(); + serverStrip.mount(serverStripSlot); + children.push(serverStrip); + + // Channel sidebar (composed: sidebar + voice widget + user bar) + const sidebarWrapper = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" }); + + const channelSidebarSlot = createElement("div", {}); + let activeModal: MountableComponent | null = null; + + const channelSidebar = createChannelSidebar({ + onVoiceJoin: (channelId) => { + log.info("Joining voice channel", { channelId }); + joinVoiceChannel(channelId); + ws.send({ type: "voice_join", payload: { channel_id: channelId } }); + }, + onVoiceLeave: () => { + log.info("Leaving voice channel"); + voiceSessionLeave(false); // false: we send voice_leave below + leaveVoiceChannel(); + ws.send({ type: "voice_leave", payload: {} }); + }, + onCreateChannel: (category) => { + if (activeModal !== null) { + return; + } + const modal = createCreateChannelModal({ + category, + onCreate: async (data) => { + await api.adminCreateChannel(data); + // Server broadcasts channel_create via WS — store updates automatically + modal.destroy?.(); + activeModal = null; + }, + onClose: () => { + modal.destroy?.(); + activeModal = null; + }, + }); + activeModal = modal; + modal.mount(document.body); + }, + onEditChannel: (channel) => { + if (activeModal !== null) { + return; + } + const modal = createEditChannelModal({ + channelId: channel.id, + channelName: channel.name, + channelType: channel.type, + onSave: async (data) => { + await api.adminUpdateChannel(channel.id, data); + // Server broadcasts channel_update via WS — store updates automatically + modal.destroy?.(); + activeModal = null; + }, + onClose: () => { + modal.destroy?.(); + activeModal = null; + }, + }); + activeModal = modal; + modal.mount(document.body); + }, + onDeleteChannel: (channel) => { + if (activeModal !== null) { + return; + } + const modal = createDeleteChannelModal({ + channelId: channel.id, + channelName: channel.name, + onConfirm: async () => { + await api.adminDeleteChannel(channel.id); + // Server broadcasts channel_delete via WS — store updates automatically + modal.destroy?.(); + activeModal = null; + }, + onClose: () => { + modal.destroy?.(); + activeModal = null; + }, + }); + activeModal = modal; + modal.mount(document.body); + }, + onReorderChannel: (reorders) => { + for (const r of reorders) { + void api.adminUpdateChannel(r.channelId, { position: r.newPosition }); + } + }, + }); + channelSidebar.mount(channelSidebarSlot); + children.push(channelSidebar); + + const mountedSidebar = channelSidebarSlot.firstElementChild; + if (mountedSidebar !== null) { + while (mountedSidebar.firstChild !== null) { + sidebarWrapper.appendChild(mountedSidebar.firstChild); + } + } + + // Invite button in sidebar header + inviteCtrl = createInviteManagerController({ + api, + getRoot: () => root, + getToast: () => toast, + }); + const sidebarHeader = sidebarWrapper.querySelector(".channel-sidebar-header"); + if (sidebarHeader !== null) { + const inviteBtn = createElement("button", { + class: "invite-btn", + title: "Invite", + }, "Invite"); + inviteBtn.addEventListener("click", () => { + void inviteCtrl!.open(); + }); + sidebarHeader.appendChild(inviteBtn); + } + unsubscribers.push(() => { inviteCtrl?.cleanup(); }); + + // Voice widget + const voiceWidgetSlot = createElement("div", {}); + const voiceWidget = createVoiceWidget({ + onDisconnect: () => { + if (voiceStore.getState().currentChannelId === null) return; + log.info("Leaving voice channel (widget disconnect)"); + voiceSessionLeave(false); // false: we send voice_leave below + leaveVoiceChannel(); + ws.send({ type: "voice_leave", payload: {} }); + }, + onMuteToggle: () => { + if (!limiters.voice.tryConsume()) return; + const state = voiceStore.getState(); + if (state.localMuted) { + // Unmuting: also undeafen if deafened + voiceSessionSetMuted(false); + ws.send({ type: "voice_mute", payload: { muted: false } }); + if (state.localDeafened) { + voiceSessionSetDeafened(false); + ws.send({ type: "voice_deafen", payload: { deafened: false } }); + } + } else { + voiceSessionSetMuted(true); + ws.send({ type: "voice_mute", payload: { muted: true } }); + } + }, + onDeafenToggle: () => { + if (!limiters.voice.tryConsume()) return; + const state = voiceStore.getState(); + if (state.localDeafened) { + // Undeafening: also unmute mic + voiceSessionSetDeafened(false); + ws.send({ type: "voice_deafen", payload: { deafened: false } }); + voiceSessionSetMuted(false); + ws.send({ type: "voice_mute", payload: { muted: false } }); + } else { + // Deafening: also mute mic + voiceSessionSetDeafened(true); + ws.send({ type: "voice_deafen", payload: { deafened: true } }); + if (!state.localMuted) { + voiceSessionSetMuted(true); + ws.send({ type: "voice_mute", payload: { muted: true } }); + } + } + }, + onCameraToggle: () => { + if (!limiters.voiceVideo.tryConsume()) return; + const next = !voiceStore.getState().localCamera; + setLocalCamera(next); + ws.send({ type: "voice_camera", payload: { enabled: next } }); + }, + onScreenshareToggle: () => { + if (!limiters.voiceVideo.tryConsume()) return; + const next = !voiceStore.getState().localScreenshare; + setLocalScreenshare(next); + ws.send({ type: "voice_screenshare", payload: { enabled: next } }); + }, + }); + voiceWidget.mount(voiceWidgetSlot); + children.push(voiceWidget); + sidebarWrapper.appendChild(voiceWidgetSlot); + + // User bar + const userBarSlot = createElement("div", {}); + const userBar = createUserBar(); + userBar.mount(userBarSlot); + children.push(userBar); + sidebarWrapper.appendChild(userBarSlot); + + // Chat area + const chatArea = createElement("div", { class: "chat-area", "data-testid": "chat-area" }); + + pinnedCtrl = createPinnedPanelController({ + api, + getRoot: () => root, + getToast: () => toast, + getCurrentChannelId: () => currentChannelId, + onJumpToMessage: (msgId: number) => { + if (messageList === null) return false; + return messageList.scrollToMessage(msgId); + }, + }); + unsubscribers.push(() => { pinnedCtrl?.cleanup(); }); + + const chatHeader = buildChatHeader({ + onTogglePins: () => { void pinnedCtrl!.toggle(); }, + onToggleMembers: () => toggleMemberList(), + }); + chatHeaderName = chatHeader.refs.nameEl; + chatArea.appendChild(chatHeader.element); + + messagesSlot = createElement("div", { class: "messages-slot", "data-testid": "messages-slot" }); + typingSlot = createElement("div", { class: "typing-slot", "data-testid": "typing-slot" }); + inputSlot = createElement("div", { class: "input-slot", "data-testid": "input-slot" }); + appendChildren(chatArea, messagesSlot, typingSlot, inputSlot); + + // Member list + const memberListSlot = createElement("div", {}); + const memberList = createMemberList(); + memberList.mount(memberListSlot); + children.push(memberList); + + const memberListEl = memberListSlot.querySelector(".member-list"); + const unsubMemberList = uiStore.subscribe((state) => { + if (memberListEl !== null) { + memberListEl.classList.toggle("hidden", !state.memberListVisible); + } + }); + unsubscribers.push(unsubMemberList); + + appendChildren(app, serverStripSlot, sidebarWrapper, chatArea, memberListSlot); + root.appendChild(app); + + // Settings overlay + const settingsOverlay = createSettingsOverlay({ + onClose: () => closeSettings(), + onChangePassword: async (oldPassword, newPassword) => { + try { + await api.changePassword(oldPassword, newPassword); + toast?.show("Password changed successfully", "success"); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to change password"; + toast?.show(msg, "error"); + throw err; + } + }, + onUpdateProfile: async (username) => { + try { + const updated = await api.updateProfile({ username }); + updateUser({ username: updated.username }); + toast?.show("Profile updated", "success"); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to update profile"; + toast?.show(msg, "error"); + throw err; + } + }, + onLogout: () => clearAuth(), + }); + settingsOverlay.mount(root); + children.push(settingsOverlay); + + // Quick switcher (Ctrl+K) + const qsManager = createQuickSwitcherManager(() => root); + unsubscribers.push(qsManager.attach()); + + // Toast container + toast = createToastContainer(); + toast.mount(root); + children.push(toast); + + // Wire voice error callback to toast + setVoiceOnError((msg) => toast?.show(msg, "error")); + + // Auto-update notifier — checks server for newer client version + if (apiConfig.host) { + const serverUrl = `https://${apiConfig.host}`; + const updateNotifier = createUpdateNotifier({ serverUrl }); + updateNotifier.mount(root); + children.push(updateNotifier); + } + + container.appendChild(root); + + // --- Subscribe to channel changes --- + const unsubChannels = channelsStore.subscribe(() => { + const active = getActiveChannel(); + if (active !== null) { + mountChannelComponents(active.id, active.name); + } + }); + unsubscribers.push(unsubChannels); + + const active = getActiveChannel(); + if (active !== null) { + mountChannelComponents(active.id, active.name); + } + } + + function destroy(): void { + log.info("MainPage destroying"); + // Clean up voice session before destroying UI — prevents stale + // module-level state persisting across logout/reconnect cycles. + voiceSessionLeave(false); + clearVoiceOnError(); + destroyChannelComponents(); + + for (const child of children) { + child.destroy?.(); + } + children.length = 0; + + for (const unsub of unsubscribers) { + unsub(); + } + unsubscribers.length = 0; + + if (banner !== null) { + banner.destroy(); + banner = null; + } + + if (root !== null) { + root.remove(); + root = null; + } + container = null; + } + + return { mount, destroy }; +} + +export type MainPage = ReturnType<typeof createMainPage>; diff --git a/Client/tauri-client/src/pages/main-page/ChatHeader.ts b/Client/tauri-client/src/pages/main-page/ChatHeader.ts new file mode 100644 index 00000000..18a77dcf --- /dev/null +++ b/Client/tauri-client/src/pages/main-page/ChatHeader.ts @@ -0,0 +1,59 @@ +/** + * ChatHeader — builds the channel header bar with name, topic, pins, search, + * and member-list toggle. + */ + +import { createElement, appendChildren } from "@lib/dom"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ChatHeaderRefs { + readonly nameEl: HTMLSpanElement; + readonly topicEl: HTMLSpanElement; +} + +export interface ChatHeaderOptions { + readonly onTogglePins: () => void; + readonly onToggleMembers: () => void; +} + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +export function buildChatHeader( + opts: ChatHeaderOptions, +): { element: HTMLDivElement; refs: ChatHeaderRefs } { + const header = createElement("div", { class: "chat-header", "data-testid": "chat-header" }); + const hash = createElement("span", { class: "ch-hash" }, "#"); + const nameEl = createElement("span", { class: "ch-name", "data-testid": "chat-header-name" }, "general"); + const divider = createElement("div", { class: "ch-divider" }); + const topicEl = createElement("span", { class: "ch-topic" }, ""); + + const tools = createElement("div", { class: "ch-tools" }); + const pinBtn = createElement("button", { + type: "button", + class: "pin-btn", + title: "Pins", + "aria-label": "Pins", + "data-testid": "pin-btn", + }, "\uD83D\uDCCC"); + pinBtn.addEventListener("click", () => { opts.onTogglePins(); }); + const searchInput = createElement("input", { + class: "search-input", + type: "text", + placeholder: "Search...", + }); + const membersToggle = createElement("button", { + type: "button", + "aria-label": "Toggle member list", + "data-testid": "members-toggle", + }, "\uD83D\uDC65"); + membersToggle.addEventListener("click", () => opts.onToggleMembers()); + appendChildren(tools, searchInput, pinBtn, membersToggle); + + appendChildren(header, hash, nameEl, divider, topicEl, tools); + return { element: header, refs: { nameEl, topicEl } }; +} diff --git a/Client/tauri-client/src/pages/main-page/OverlayManagers.ts b/Client/tauri-client/src/pages/main-page/OverlayManagers.ts new file mode 100644 index 00000000..6de08065 --- /dev/null +++ b/Client/tauri-client/src/pages/main-page/OverlayManagers.ts @@ -0,0 +1,254 @@ +/** + * Overlay managers — quick switcher, invite manager, and pinned messages panel. + * Each factory returns an open/toggle + cleanup pair for use in MainPage. + */ + +import type { MountableComponent } from "@lib/safe-render"; +import type { ApiClient } from "@lib/api"; +import { createLogger } from "@lib/logger"; +import { createQuickSwitcher } from "@components/QuickSwitcher"; +import { createInviteManager } from "@components/InviteManager"; +import type { InviteItem } from "@components/InviteManager"; +import type { InviteResponse } from "@lib/types"; +import { createPinnedMessages } from "@components/PinnedMessages"; +import type { PinnedMessage } from "@components/PinnedMessages"; +import type { ToastContainer } from "@components/Toast"; +import { setActiveChannel } from "@stores/channels.store"; + +const log = createLogger("overlays"); + +// --------------------------------------------------------------------------- +// Invite response mapping +// --------------------------------------------------------------------------- + +export function mapInviteResponse(r: InviteResponse): InviteItem { + const extra = r as unknown as Record<string, unknown>; + const createdBy = typeof extra["created_by"] === "object" + && extra["created_by"] !== null + ? (extra["created_by"] as { username?: string }).username ?? "unknown" + : "unknown"; + const uses = r.use_count + ?? (typeof extra["uses"] === "number" ? (extra["uses"] as number) : 0); + return { + code: r.code, + createdBy, + createdAt: r.expires_at ?? "", + uses, + maxUses: r.max_uses, + expiresAt: r.expires_at, + }; +} + +// --------------------------------------------------------------------------- +// Pinned message mapping +// --------------------------------------------------------------------------- + +export function mapToPinnedMessage(msg: { + readonly id: number; + readonly user: { readonly username: string }; + readonly content: string; + readonly created_at?: string; + readonly timestamp?: string; +}): PinnedMessage { + return { + id: msg.id, + author: msg.user.username, + content: msg.content, + timestamp: msg.created_at ?? msg.timestamp ?? "", + }; +} + +// --------------------------------------------------------------------------- +// Quick Switcher Manager +// --------------------------------------------------------------------------- + +export interface QuickSwitcherManager { + /** Attach Ctrl+K handler; returns cleanup function. */ + attach(): () => void; +} + +export function createQuickSwitcherManager( + getRoot: () => HTMLDivElement | null, +): QuickSwitcherManager { + let instance: MountableComponent | null = null; + + function open(): void { + const root = getRoot(); + if (instance !== null || root === null) return; + instance = createQuickSwitcher({ + onSelectChannel: (channelId: number) => { + setActiveChannel(channelId); + }, + onClose: close, + }); + instance.mount(root); + } + + function close(): void { + if (instance !== null) { + instance.destroy?.(); + instance = null; + } + } + + function attach(): () => void { + const handler = (e: KeyboardEvent): void => { + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + if (instance !== null) { + close(); + } else { + open(); + } + } + }; + document.addEventListener("keydown", handler); + return () => { + document.removeEventListener("keydown", handler); + close(); + }; + } + + return { attach }; +} + +// --------------------------------------------------------------------------- +// Invite Manager Controller +// --------------------------------------------------------------------------- + +export interface InviteManagerController { + open(): Promise<void>; + cleanup(): void; +} + +export function createInviteManagerController(opts: { + readonly api: ApiClient; + readonly getRoot: () => HTMLDivElement | null; + readonly getToast: () => ToastContainer | null; +}): InviteManagerController { + let instance: MountableComponent | null = null; + + function close(): void { + if (instance !== null) { + instance.destroy?.(); + instance = null; + } + } + + async function open(): Promise<void> { + const root = opts.getRoot(); + if (instance !== null || root === null) return; + try { + const raw = await opts.api.getInvites(); + const invites = raw.map(mapInviteResponse); + instance = createInviteManager({ + invites, + onCreateInvite: async () => { + const created = await opts.api.createInvite({}); + return mapInviteResponse(created); + }, + onRevokeInvite: async (code: string) => { + try { + const raw2 = await opts.api.getInvites(); + const match = raw2.find((i) => i.code === code); + if (match !== undefined) { + await opts.api.revokeInvite(match.id); + } + } catch (err) { + log.error("Invite revoke failed", { code, error: String(err) }); + throw err; + } + }, + onCopyLink: (code: string) => { + void navigator.clipboard.writeText(code); + }, + onClose: close, + onError: (message: string) => { + log.error(message); + opts.getToast()?.show(message, "error"); + }, + }); + if (root !== null) { + instance.mount(root); + } + } catch (err) { + log.error("Failed to open invite manager", { error: String(err) }); + opts.getToast()?.show("Failed to load invites", "error"); + } + } + + return { open, cleanup: close }; +} + +// --------------------------------------------------------------------------- +// Pinned Panel Controller +// --------------------------------------------------------------------------- + +export interface PinnedPanelController { + toggle(): Promise<void>; + cleanup(): void; +} + +export function createPinnedPanelController(opts: { + readonly api: ApiClient; + readonly getRoot: () => HTMLDivElement | null; + readonly getToast: () => ToastContainer | null; + readonly getCurrentChannelId: () => number | null; + readonly onJumpToMessage?: (messageId: number) => boolean; +}): PinnedPanelController { + let instance: MountableComponent | null = null; + + function close(): void { + if (instance !== null) { + instance.destroy?.(); + instance = null; + } + } + + async function toggle(): Promise<void> { + if (instance !== null) { + close(); + return; + } + const root = opts.getRoot(); + const channelId = opts.getCurrentChannelId(); + if (root === null || channelId === null) return; + try { + const resp = await opts.api.getPins(channelId); + const pins = resp.messages.map(mapToPinnedMessage); + instance = createPinnedMessages({ + channelId, + pinnedMessages: pins, + onJumpToMessage: (msgId: number) => { + if (opts.onJumpToMessage !== undefined) { + const found = opts.onJumpToMessage(msgId); + if (found) { + close(); + } else { + opts.getToast()?.show("Message not in loaded window", "info"); + } + } else { + close(); + } + }, + onUnpin: (msgId: number) => { + void opts.api.unpinMessage(channelId, msgId).then(() => { + close(); + }).catch((err: unknown) => { + log.error("Failed to unpin message", { msgId, error: String(err) }); + opts.getToast()?.show("Failed to unpin message", "error"); + }); + }, + onClose: close, + }); + if (root !== null) { + instance.mount(root); + } + } catch (err) { + log.error("Failed to load pinned messages", { error: String(err) }); + opts.getToast()?.show("Failed to load pinned messages", "error"); + } + } + + return { toggle, cleanup: close }; +} diff --git a/Client/tauri-client/src/stores/auth.store.ts b/Client/tauri-client/src/stores/auth.store.ts new file mode 100644 index 00000000..4e6093ed --- /dev/null +++ b/Client/tauri-client/src/stores/auth.store.ts @@ -0,0 +1,70 @@ +/** + * Auth store — holds authentication state after login/auth_ok. + * Immutable state updates only. + */ + +import { createStore } from "@lib/store"; +import type { UserWithRole } from "@lib/types"; +import { resetVoiceStore } from "@stores/voice.store"; +import { leaveVoice } from "@lib/voiceSession"; + +export interface AuthState { + readonly token: string | null; + readonly user: UserWithRole | null; + readonly serverName: string | null; + readonly motd: string | null; + readonly isAuthenticated: boolean; +} + +const INITIAL_STATE: AuthState = { + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, +}; + +export const authStore = createStore<AuthState>(INITIAL_STATE); + +/** Populate auth state after a successful auth_ok message. */ +export function setAuth( + token: string, + user: UserWithRole, + serverName: string, + motd: string, +): void { + authStore.setState(() => ({ + token, + user, + serverName, + motd, + isAuthenticated: true, + })); +} + +/** Reset auth state (logout / disconnect). Also cleans up the voice + * session (WebRTC, AudioContext, streams) and clears voice store state. + * Safe to call even if no voice session is active — leaveVoice is idempotent. */ +export function clearAuth(): void { + leaveVoice(false); + resetVoiceStore(); + authStore.setState(() => ({ ...INITIAL_STATE })); +} + +/** Shorthand selector for the current token. */ +export function getToken(): string | null { + return authStore.select((s) => s.token); +} + +/** Update the current user fields (e.g. after profile edit). */ +export function updateUser(patch: Partial<UserWithRole>): void { + authStore.setState((prev) => ({ + ...prev, + user: prev.user ? { ...prev.user, ...patch } : prev.user, + })); +} + +/** Shorthand selector for the current user. */ +export function getCurrentUser(): UserWithRole | null { + return authStore.select((s) => s.user); +} diff --git a/Client/tauri-client/src/stores/channels.store.ts b/Client/tauri-client/src/stores/channels.store.ts new file mode 100644 index 00000000..7fda210c --- /dev/null +++ b/Client/tauri-client/src/stores/channels.store.ts @@ -0,0 +1,199 @@ +/** + * Channels store — holds channel list, active channel, and unread counts. + * Immutable state updates only. + */ + +import { createStore } from "@lib/store"; +import type { + ReadyChannel, + ChannelCreatePayload, + ChannelUpdatePayload, + ChannelType, +} from "@lib/types"; + +export interface Channel { + readonly id: number; + readonly name: string; + readonly type: ChannelType; + readonly category: string | null; + readonly position: number; + readonly unreadCount: number; + readonly lastMessageId: number | null; +} + +export interface ChannelsState { + readonly channels: ReadonlyMap<number, Channel>; + readonly activeChannelId: number | null; +} + +const INITIAL_STATE: ChannelsState = { + channels: new Map(), + activeChannelId: null, +}; + +export const channelsStore = createStore<ChannelsState>(INITIAL_STATE); + +/** Bulk set channels from the ready payload. Converts ReadyChannel[] to Map. */ +export function setChannels(channels: readonly ReadyChannel[]): void { + const map = new Map<number, Channel>(); + for (const ch of channels) { + map.set(ch.id, { + id: ch.id, + name: ch.name, + type: ch.type, + category: ch.category, + position: ch.position, + unreadCount: ch.unread_count ?? 0, + lastMessageId: ch.last_message_id ?? null, + }); + } + channelsStore.setState((prev) => ({ + ...prev, + channels: map, + })); +} + +/** Add a single channel from a channel_create event. */ +export function addChannel(channel: ChannelCreatePayload): void { + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.set(channel.id, { + id: channel.id, + name: channel.name, + type: channel.type, + category: channel.category, + position: channel.position, + unreadCount: 0, + lastMessageId: null, + }); + return { ...prev, channels: next }; + }); +} + +/** Update a channel's name and/or position immutably. */ +export function updateChannel(update: ChannelUpdatePayload): void { + channelsStore.setState((prev) => { + const existing = prev.channels.get(update.id); + if (existing === undefined) { + return prev; + } + const updated: Channel = { + ...existing, + ...(update.name !== undefined ? { name: update.name } : {}), + ...(update.position !== undefined ? { position: update.position } : {}), + }; + const next = new Map(prev.channels); + next.set(update.id, updated); + return { ...prev, channels: next }; + }); +} + +/** Update a single channel's position immutably. */ +export function updateChannelPosition(id: number, position: number): void { + channelsStore.setState((prev) => { + const existing = prev.channels.get(id); + if (existing === undefined || existing.position === position) { + return prev; + } + const updated: Channel = { ...existing, position }; + const next = new Map(prev.channels); + next.set(id, updated); + return { ...prev, channels: next }; + }); +} + +/** Remove a channel. Clears activeChannelId if it was the removed channel. */ +export function removeChannel(id: number): void { + channelsStore.setState((prev) => { + const next = new Map(prev.channels); + next.delete(id); + return { + ...prev, + channels: next, + activeChannelId: prev.activeChannelId === id ? null : prev.activeChannelId, + }; + }); +} + +/** Set the active channel by id (or null to deselect). Clears unread count for the activated channel. */ +export function setActiveChannel(id: number | null): void { + channelsStore.setState((prev) => { + if (id === null) { + return { ...prev, activeChannelId: null }; + } + const existing = prev.channels.get(id); + if (existing === undefined || existing.unreadCount === 0) { + return { ...prev, activeChannelId: id }; + } + const updated: Channel = { ...existing, unreadCount: 0 }; + const next = new Map(prev.channels); + next.set(id, updated); + return { ...prev, activeChannelId: id, channels: next }; + }); +} + +/** Get the currently active Channel object, or null. */ +export function getActiveChannel(): Channel | null { + return channelsStore.select((s) => { + if (s.activeChannelId === null) { + return null; + } + return s.channels.get(s.activeChannelId) ?? null; + }); +} + +/** Group channels by category, sorted by position within each group. */ +export function getChannelsByCategory(): Map<string | null, Channel[]> { + return channelsStore.select((s) => { + const grouped = new Map<string | null, Channel[]>(); + for (const channel of s.channels.values()) { + const existing = grouped.get(channel.category); + if (existing !== undefined) { + existing.push(channel); + } else { + grouped.set(channel.category, [channel]); + } + } + for (const channels of grouped.values()) { + channels.sort((a, b) => a.position - b.position); + } + return grouped; + }); +} + +/** Increment unread count for a channel, unless it is the active channel. */ +export function incrementUnread(channelId: number): void { + channelsStore.setState((prev) => { + if (prev.activeChannelId === channelId) { + return prev; + } + const existing = prev.channels.get(channelId); + if (existing === undefined) { + return prev; + } + const updated: Channel = { + ...existing, + unreadCount: existing.unreadCount + 1, + }; + const next = new Map(prev.channels); + next.set(channelId, updated); + return { ...prev, channels: next }; + }); +} + +/** Clear unread count for a channel. */ +export function clearUnread(channelId: number): void { + channelsStore.setState((prev) => { + const existing = prev.channels.get(channelId); + if (existing === undefined) { + return prev; + } + const updated: Channel = { + ...existing, + unreadCount: 0, + }; + const next = new Map(prev.channels); + next.set(channelId, updated); + return { ...prev, channels: next }; + }); +} diff --git a/Client/tauri-client/src/stores/members.store.ts b/Client/tauri-client/src/stores/members.store.ts new file mode 100644 index 00000000..6945556d --- /dev/null +++ b/Client/tauri-client/src/stores/members.store.ts @@ -0,0 +1,186 @@ +/** + * Members store — holds all server members, presence, and typing state. + * Immutable state updates only. + */ + +import { createStore } from "@lib/store"; +import type { + ReadyMember, + MemberJoinPayload, + UserStatus, +} from "@lib/types"; + +export interface Member { + readonly id: number; + readonly username: string; + readonly avatar: string | null; + readonly role: string; + readonly status: UserStatus; +} + +export interface MembersState { + readonly members: ReadonlyMap<number, Member>; + readonly typingUsers: ReadonlyMap<number, ReadonlySet<number>>; // channelId -> Set<userId> +} + +const INITIAL_STATE: MembersState = { + members: new Map(), + typingUsers: new Map(), +}; + +export const membersStore = createStore<MembersState>(INITIAL_STATE); + +/** Track active typing timeouts so they can be cleared. */ +const typingTimers = new Map<string, ReturnType<typeof setTimeout>>(); + +function typingKey(channelId: number, userId: number): string { + return `${channelId}:${userId}`; +} + +/** Bulk set members from the ready payload. */ +export function setMembers(members: readonly ReadyMember[]): void { + const map = new Map<number, Member>(); + for (const m of members) { + map.set(m.id, { + id: m.id, + username: m.username, + avatar: m.avatar, + role: m.role, + status: m.status, + }); + } + membersStore.setState((prev) => ({ + ...prev, + members: map, + })); +} + +/** Add a member from a member_join event. */ +export function addMember(payload: MemberJoinPayload): void { + membersStore.setState((prev) => { + const next = new Map(prev.members); + next.set(payload.user.id, { + id: payload.user.id, + username: payload.user.username, + avatar: payload.user.avatar, + role: payload.user.role, + status: "online" as UserStatus, + }); + return { ...prev, members: next }; + }); +} + +/** Remove a member from a member_leave event. */ +export function removeMember(userId: number): void { + membersStore.setState((prev) => { + const next = new Map(prev.members); + next.delete(userId); + return { ...prev, members: next }; + }); +} + +/** Update a member's role from a member_update event. */ +export function updateMemberRole(userId: number, role: string): void { + membersStore.setState((prev) => { + const existing = prev.members.get(userId); + if (!existing) return prev; + const next = new Map(prev.members); + next.set(userId, { ...existing, role }); + return { ...prev, members: next }; + }); +} + +/** Update a member's presence status. */ +export function updatePresence(userId: number, status: UserStatus): void { + membersStore.setState((prev) => { + const existing = prev.members.get(userId); + if (!existing) return prev; + const next = new Map(prev.members); + next.set(userId, { ...existing, status }); + return { ...prev, members: next }; + }); +} + +/** Mark a user as typing in a channel. Auto-clears after 5 seconds. */ +export function setTyping(channelId: number, userId: number): void { + const key = typingKey(channelId, userId); + + // Clear any existing timer for this user+channel + const existing = typingTimers.get(key); + if (existing !== undefined) { + clearTimeout(existing); + } + + membersStore.setState((prev) => { + const nextTyping = new Map(prev.typingUsers); + const channelSet = prev.typingUsers.get(channelId); + const nextSet = new Set(channelSet ?? []); + nextSet.add(userId); + nextTyping.set(channelId, nextSet); + return { ...prev, typingUsers: nextTyping }; + }); + + // Auto-clear after 5 seconds + const timer = setTimeout(() => { + typingTimers.delete(key); + clearTyping(channelId, userId); + }, 5000); + typingTimers.set(key, timer); +} + +/** Remove a user from the typing set for a channel. */ +export function clearTyping(channelId: number, userId: number): void { + const key = typingKey(channelId, userId); + const existing = typingTimers.get(key); + if (existing !== undefined) { + clearTimeout(existing); + typingTimers.delete(key); + } + + membersStore.setState((prev) => { + const channelSet = prev.typingUsers.get(channelId); + if (!channelSet || !channelSet.has(userId)) return prev; + + const nextTyping = new Map(prev.typingUsers); + const nextSet = new Set(channelSet); + nextSet.delete(userId); + + if (nextSet.size === 0) { + nextTyping.delete(channelId); + } else { + nextTyping.set(channelId, nextSet); + } + + return { ...prev, typingUsers: nextTyping }; + }); +} + +/** Selector: members where status is not "offline". */ +export function getOnlineMembers(): readonly Member[] { + return membersStore.select((s) => { + const result: Member[] = []; + for (const member of s.members.values()) { + if (member.status !== "offline") { + result.push(member); + } + } + return result; + }); +} + +/** Selector: array of Member objects currently typing in a channel. */ +export function getTypingUsers(channelId: number): readonly Member[] { + return membersStore.select((s) => { + const userIds = s.typingUsers.get(channelId); + if (!userIds || userIds.size === 0) return []; + + const result: Member[] = []; + for (const userId of userIds) { + const member = s.members.get(userId); + if (member) { + result.push(member); + } + } + return result; + }); +} diff --git a/Client/tauri-client/src/stores/messages.store.ts b/Client/tauri-client/src/stores/messages.store.ts new file mode 100644 index 00000000..c909fcf4 --- /dev/null +++ b/Client/tauri-client/src/stores/messages.store.ts @@ -0,0 +1,312 @@ +/** + * Messages store — holds chat messages per channel, pending send tracking, + * and load state for infinite scroll. + * Immutable state updates only. + */ + +import { createStore } from "@lib/store"; +import type { + ChatMessagePayload, + ChatEditedPayload, + ChatDeletedPayload, + ReactionUpdatePayload, + MessageUser, + Attachment, + ReactionSummary, + MessageResponse, +} from "@lib/types"; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface Message { + readonly id: number; + readonly channelId: number; + readonly user: MessageUser; + readonly content: string; + readonly replyTo: number | null; + readonly attachments: readonly Attachment[]; + readonly reactions: readonly ReactionSummary[]; + readonly editedAt: string | null; + readonly deleted: boolean; + readonly timestamp: string; +} + +export interface MessagesState { + /** Messages per channel: channelId -> ordered array of Message */ + readonly messagesByChannel: ReadonlyMap<number, readonly Message[]>; + /** Pending send confirmations: correlationId -> channelId */ + readonly pendingSends: ReadonlyMap<string, number>; + /** Whether we've loaded initial messages for a channel */ + readonly loadedChannels: ReadonlySet<number>; + /** Whether more messages exist above for a channel */ + readonly hasMore: ReadonlyMap<number, boolean>; +} + +// ----------------------------------------------------------------------------- +// Helpers: convert wire types to store types +// ----------------------------------------------------------------------------- + +function chatPayloadToMessage(payload: ChatMessagePayload): Message { + return { + id: payload.id, + channelId: payload.channel_id, + user: payload.user, + content: payload.content, + replyTo: payload.reply_to, + attachments: payload.attachments, + reactions: [], + editedAt: null, + deleted: false, + timestamp: payload.timestamp, + }; +} + +function messageResponseToMessage(response: MessageResponse): Message { + return { + id: response.id, + channelId: response.channel_id, + user: response.user, + content: response.content, + replyTo: response.reply_to, + attachments: response.attachments, + reactions: response.reactions, + editedAt: response.edited_at, + deleted: response.deleted, + timestamp: response.timestamp, + }; +} + +// ----------------------------------------------------------------------------- +// Initial state +// ----------------------------------------------------------------------------- + +const INITIAL_STATE: MessagesState = { + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), +}; + +// ----------------------------------------------------------------------------- +// Store instance +// ----------------------------------------------------------------------------- + +export const messagesStore = createStore<MessagesState>(INITIAL_STATE); + +// ----------------------------------------------------------------------------- +// Actions +// ----------------------------------------------------------------------------- + +/** Append a new message from a chat_message WS event. */ +export function addMessage(payload: ChatMessagePayload): void { + const message = chatPayloadToMessage(payload); + messagesStore.setState((prev) => { + const channelId = message.channelId; + const existing = prev.messagesByChannel.get(channelId) ?? []; + const updated = new Map(prev.messagesByChannel); + updated.set(channelId, [...existing, message]); + return { ...prev, messagesByChannel: updated }; + }); +} + +/** Bulk set messages from a REST response. Marks channel as loaded. + * The server returns messages newest-first; we reverse to chronological order. */ +export function setMessages( + channelId: number, + messages: readonly MessageResponse[], + hasMore: boolean, +): void { + const converted = messages.map(messageResponseToMessage).reverse(); + messagesStore.setState((prev) => { + const updatedMessages = new Map(prev.messagesByChannel); + updatedMessages.set(channelId, converted); + + const updatedLoaded = new Set(prev.loadedChannels); + updatedLoaded.add(channelId); + + const updatedHasMore = new Map(prev.hasMore); + updatedHasMore.set(channelId, hasMore); + + return { + ...prev, + messagesByChannel: updatedMessages, + loadedChannels: updatedLoaded, + hasMore: updatedHasMore, + }; + }); +} + +/** Prepend older messages for infinite scroll. + * The server returns messages newest-first; we reverse to chronological order. */ +export function prependMessages( + channelId: number, + messages: readonly MessageResponse[], + hasMore: boolean, +): void { + const converted = messages.map(messageResponseToMessage).reverse(); + messagesStore.setState((prev) => { + const existing = prev.messagesByChannel.get(channelId) ?? []; + const updatedMessages = new Map(prev.messagesByChannel); + updatedMessages.set(channelId, [...converted, ...existing]); + + const updatedHasMore = new Map(prev.hasMore); + updatedHasMore.set(channelId, hasMore); + + return { + ...prev, + messagesByChannel: updatedMessages, + hasMore: updatedHasMore, + }; + }); +} + +/** Update message content and editedAt from a chat_edited WS event. */ +export function editMessage(payload: ChatEditedPayload): void { + messagesStore.setState((prev) => { + const channelMessages = prev.messagesByChannel.get(payload.channel_id); + if (!channelMessages) return prev; + + const updatedList = channelMessages.map((msg) => + msg.id === payload.message_id + ? { ...msg, content: payload.content, editedAt: payload.edited_at } + : msg, + ); + + const updatedMessages = new Map(prev.messagesByChannel); + updatedMessages.set(payload.channel_id, updatedList); + return { ...prev, messagesByChannel: updatedMessages }; + }); +} + +/** Soft-delete: mark message as deleted but keep in array. */ +export function deleteMessage(payload: ChatDeletedPayload): void { + messagesStore.setState((prev) => { + const channelMessages = prev.messagesByChannel.get(payload.channel_id); + if (!channelMessages) return prev; + + const updatedList = channelMessages.map((msg) => + msg.id === payload.message_id ? { ...msg, deleted: true } : msg, + ); + + const updatedMessages = new Map(prev.messagesByChannel); + updatedMessages.set(payload.channel_id, updatedList); + return { ...prev, messagesByChannel: updatedMessages }; + }); +} + +/** Track a pending outbound message send. */ +export function addPendingSend( + correlationId: string, + channelId: number, +): void { + messagesStore.setState((prev) => { + const updated = new Map(prev.pendingSends); + updated.set(correlationId, channelId); + return { ...prev, pendingSends: updated }; + }); +} + +/** Confirm a pending send — remove from pending map. */ +export function confirmSend( + correlationId: string, + _messageId: number, + _timestamp: string, +): void { + messagesStore.setState((prev) => { + const updated = new Map(prev.pendingSends); + updated.delete(correlationId); + return { ...prev, pendingSends: updated }; + }); +} + +/** Clear all messages for a channel. */ +export function clearChannelMessages(channelId: number): void { + messagesStore.setState((prev) => { + const updatedMessages = new Map(prev.messagesByChannel); + updatedMessages.delete(channelId); + + const updatedLoaded = new Set(prev.loadedChannels); + updatedLoaded.delete(channelId); + + const updatedHasMore = new Map(prev.hasMore); + updatedHasMore.delete(channelId); + + return { + ...prev, + messagesByChannel: updatedMessages, + loadedChannels: updatedLoaded, + hasMore: updatedHasMore, + }; + }); +} + +/** Update reactions on a message from a reaction_update WS event. */ +export function updateReaction( + payload: ReactionUpdatePayload, + currentUserId: number, +): void { + messagesStore.setState((prev) => { + const channelMessages = prev.messagesByChannel.get(payload.channel_id); + if (!channelMessages) return prev; + + const updatedList = channelMessages.map((msg) => { + if (msg.id !== payload.message_id) return msg; + + const isMe = payload.user_id === currentUserId; + const existing = msg.reactions; + + if (payload.action === "add") { + const found = existing.find((r) => r.emoji === payload.emoji); + if (found !== undefined) { + const updatedReactions = existing.map((r) => + r.emoji === payload.emoji + ? { ...r, count: r.count + 1, me: r.me || isMe } + : r, + ); + return { ...msg, reactions: updatedReactions }; + } + return { + ...msg, + reactions: [...existing, { emoji: payload.emoji, count: 1, me: isMe }], + }; + } + + // action === "remove" + const updatedReactions = existing + .map((r) => + r.emoji === payload.emoji + ? { ...r, count: r.count - 1, me: isMe ? false : r.me } + : r, + ) + .filter((r) => r.count > 0); + return { ...msg, reactions: updatedReactions }; + }); + + const updatedMessages = new Map(prev.messagesByChannel); + updatedMessages.set(payload.channel_id, updatedList); + return { ...prev, messagesByChannel: updatedMessages }; + }); +} + +// ----------------------------------------------------------------------------- +// Selectors +// ----------------------------------------------------------------------------- + +/** Get messages for a channel, or empty array if none loaded. */ +export function getChannelMessages(channelId: number): readonly Message[] { + return messagesStore.select( + (s) => s.messagesByChannel.get(channelId) ?? [], + ); +} + +/** Check whether initial messages have been loaded for a channel. */ +export function isChannelLoaded(channelId: number): boolean { + return messagesStore.select((s) => s.loadedChannels.has(channelId)); +} + +/** Check whether a channel has more older messages to fetch. */ +export function hasMoreMessages(channelId: number): boolean { + return messagesStore.select((s) => s.hasMore.get(channelId) ?? false); +} diff --git a/Client/tauri-client/src/stores/ui.store.ts b/Client/tauri-client/src/stores/ui.store.ts new file mode 100644 index 00000000..04029ab0 --- /dev/null +++ b/Client/tauri-client/src/stores/ui.store.ts @@ -0,0 +1,132 @@ +/** + * UI store — holds transient UI state: sidebar, modals, theme, collapsed categories. + * Immutable state updates only. + */ + +import { createStore } from "@lib/store"; + +export interface UiState { + readonly sidebarCollapsed: boolean; + readonly memberListVisible: boolean; + readonly settingsOpen: boolean; + readonly activeModal: string | null; + readonly theme: "dark" | "midnight" | "light"; + readonly connectionStatus: "connected" | "reconnecting" | "disconnected"; + readonly transientError: string | null; + readonly persistentError: string | null; + readonly collapsedCategories: ReadonlySet<string>; +} + +const INITIAL_STATE: UiState = { + sidebarCollapsed: false, + memberListVisible: true, + settingsOpen: false, + activeModal: null, + theme: "dark", + connectionStatus: "disconnected", + transientError: null, + persistentError: null, + collapsedCategories: new Set(), +}; + +export const uiStore = createStore<UiState>(INITIAL_STATE); + +/** Toggle sidebar collapsed state. */ +export function toggleSidebar(): void { + uiStore.setState((prev) => ({ + ...prev, + sidebarCollapsed: !prev.sidebarCollapsed, + })); +} + +/** Toggle member list visibility. */ +export function toggleMemberList(): void { + uiStore.setState((prev) => ({ + ...prev, + memberListVisible: !prev.memberListVisible, + })); +} + +/** Open the settings panel. */ +export function openSettings(): void { + uiStore.setState((prev) => ({ + ...prev, + settingsOpen: true, + })); +} + +/** Close the settings panel. */ +export function closeSettings(): void { + uiStore.setState((prev) => ({ + ...prev, + settingsOpen: false, + })); +} + +/** Open a named modal. */ +export function openModal(name: string): void { + uiStore.setState((prev) => ({ + ...prev, + activeModal: name, + })); +} + +/** Close the active modal. */ +export function closeModal(): void { + uiStore.setState((prev) => ({ + ...prev, + activeModal: null, + })); +} + +/** Set the UI theme. */ +export function setTheme(theme: "dark" | "midnight" | "light"): void { + uiStore.setState((prev) => ({ + ...prev, + theme, + })); +} + +/** Set the WebSocket connection status. */ +export function setConnectionStatus( + status: "connected" | "reconnecting" | "disconnected", +): void { + uiStore.setState((prev) => ({ + ...prev, + connectionStatus: status, + })); +} + +/** Set a transient (auto-dismissable) error message. */ +export function setTransientError(msg: string | null): void { + uiStore.setState((prev) => ({ + ...prev, + transientError: msg, + })); +} + +/** Set a persistent error message that requires user action. */ +export function setPersistentError(msg: string | null): void { + uiStore.setState((prev) => ({ + ...prev, + persistentError: msg, + })); +} + +/** Toggle a category's collapsed state. */ +export function toggleCategory(category: string): void { + uiStore.setState((prev) => { + const next = new Set(prev.collapsedCategories); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return { ...prev, collapsedCategories: next }; + }); +} + +/** Selector: check if a category is collapsed. */ +export function isCategoryCollapsed(category: string): boolean { + return uiStore.select((s) => s.collapsedCategories.has(category)); +} diff --git a/Client/tauri-client/src/stores/voice.store.ts b/Client/tauri-client/src/stores/voice.store.ts new file mode 100644 index 00000000..3060263f --- /dev/null +++ b/Client/tauri-client/src/stores/voice.store.ts @@ -0,0 +1,293 @@ +/** + * Voice store — holds voice channel state, local audio controls, and per-user voice info. + * Immutable state updates only. + */ + +import { createStore } from "@lib/store"; +import type { + ReadyVoiceState, + VoiceStatePayload, + VoiceLeavePayload, + VoiceConfigPayload, + VoiceSpeakersPayload, +} from "@lib/types"; +import { membersStore } from "@stores/members.store"; +import { authStore } from "@stores/auth.store"; + +export interface VoiceUser { + readonly userId: number; + readonly username: string; + readonly muted: boolean; + readonly deafened: boolean; + readonly speaking: boolean; + readonly camera: boolean; + readonly screenshare: boolean; +} + +export interface VoiceConfig { + readonly quality: string; + readonly bitrate: number; + readonly threshold_mode: string; + readonly mixing_threshold: number; + readonly top_speakers: number; + readonly max_users: number; +} + +export interface VoiceState { + readonly currentChannelId: number | null; + readonly voiceUsers: ReadonlyMap<number, ReadonlyMap<number, VoiceUser>>; // channelId -> userId -> VoiceUser + readonly voiceConfigs: ReadonlyMap<number, VoiceConfig>; // channelId -> VoiceConfig + readonly localMuted: boolean; + readonly localDeafened: boolean; + readonly localCamera: boolean; + readonly localScreenshare: boolean; +} + +const INITIAL_STATE: VoiceState = { + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, +}; + +export const voiceStore = createStore<VoiceState>(INITIAL_STATE); + +/** Reset voice store to initial state (e.g. on logout). */ +export function resetVoiceStore(): void { + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, + })); +} + +/** Bulk set voice states from the ready payload. */ +export function setVoiceStates(states: readonly ReadyVoiceState[]): void { + const channelMap = new Map<number, Map<number, VoiceUser>>(); + + for (const vs of states) { + let userMap = channelMap.get(vs.channel_id); + if (!userMap) { + userMap = new Map(); + channelMap.set(vs.channel_id, userMap); + } + const member = membersStore.getState().members.get(vs.user_id); + userMap.set(vs.user_id, { + userId: vs.user_id, + username: member?.username ?? "", + muted: vs.muted, + deafened: vs.deafened, + speaking: false, + camera: false, + screenshare: false, + }); + } + + // Check if current user is in any voice channel + const currentUserId = authStore.getState().user?.id ?? 0; + let autoJoinChannel: number | null = null; + if (currentUserId !== 0) { + for (const vs of states) { + if (vs.user_id === currentUserId) { + autoJoinChannel = vs.channel_id; + break; + } + } + } + + voiceStore.setState((prev) => ({ + ...prev, + voiceUsers: channelMap, + currentChannelId: autoJoinChannel ?? prev.currentChannelId, + })); +} + +/** Update or add a user's voice state from a voice_state event. */ +export function updateVoiceState(payload: VoiceStatePayload): void { + voiceStore.setState((prev) => { + const nextChannels = new Map(prev.voiceUsers); + const existingChannel = prev.voiceUsers.get(payload.channel_id); + const nextUsers = new Map(existingChannel ?? []); + + nextUsers.set(payload.user_id, { + userId: payload.user_id, + username: payload.username, + muted: payload.muted, + deafened: payload.deafened, + speaking: payload.speaking, + camera: payload.camera, + screenshare: payload.screenshare, + }); + + nextChannels.set(payload.channel_id, nextUsers); + return { ...prev, voiceUsers: nextChannels }; + }); +} + +/** Remove a user from a voice channel. */ +export function removeVoiceUser(payload: VoiceLeavePayload): void { + voiceStore.setState((prev) => { + const existingChannel = prev.voiceUsers.get(payload.channel_id); + if (!existingChannel || !existingChannel.has(payload.user_id)) return prev; + + const nextChannels = new Map(prev.voiceUsers); + const nextUsers = new Map(existingChannel); + nextUsers.delete(payload.user_id); + + if (nextUsers.size === 0) { + nextChannels.delete(payload.channel_id); + } else { + nextChannels.set(payload.channel_id, nextUsers); + } + + return { ...prev, voiceUsers: nextChannels }; + }); +} + +/** Set the current voice channel (local join). */ +export function joinVoiceChannel(channelId: number): void { + voiceStore.setState((prev) => ({ + ...prev, + currentChannelId: channelId, + })); +} + +/** Clear the current voice channel and remove current user from voice users. */ +export function leaveVoiceChannel(): void { + const currentUserId = authStore.getState().user?.id ?? 0; + voiceStore.setState((prev) => { + const channelId = prev.currentChannelId; + if (channelId === null || currentUserId === 0) { + return { ...prev, currentChannelId: null }; + } + const existingChannel = prev.voiceUsers.get(channelId); + if (!existingChannel || !existingChannel.has(currentUserId)) { + return { ...prev, currentChannelId: null }; + } + const nextChannels = new Map(prev.voiceUsers); + const nextUsers = new Map(existingChannel); + nextUsers.delete(currentUserId); + if (nextUsers.size === 0) { + nextChannels.delete(channelId); + } else { + nextChannels.set(channelId, nextUsers); + } + return { ...prev, currentChannelId: null, voiceUsers: nextChannels }; + }); +} + +/** Toggle local mute state. */ +export function setLocalMuted(muted: boolean): void { + voiceStore.setState((prev) => ({ + ...prev, + localMuted: muted, + })); +} + +/** Toggle local deafen state. */ +export function setLocalDeafened(deafened: boolean): void { + voiceStore.setState((prev) => ({ + ...prev, + localDeafened: deafened, + })); +} + +/** Toggle local camera state. */ +export function setLocalCamera(enabled: boolean): void { + voiceStore.setState((prev) => ({ + ...prev, + localCamera: enabled, + })); +} + +/** Toggle local screenshare state. */ +export function setLocalScreenshare(enabled: boolean): void { + voiceStore.setState((prev) => ({ + ...prev, + localScreenshare: enabled, + })); +} + +/** Update the current user's speaking state for local VAD feedback. */ +export function setLocalSpeaking(speaking: boolean): void { + const currentUserId = authStore.getState().user?.id ?? 0; + if (currentUserId === 0) return; + voiceStore.setState((prev) => { + const channelId = prev.currentChannelId; + if (channelId === null) return prev; + const channelUsers = prev.voiceUsers.get(channelId); + if (!channelUsers) return prev; + const user = channelUsers.get(currentUserId); + if (!user || user.speaking === speaking) return prev; + const nextUsers = new Map(channelUsers); + nextUsers.set(currentUserId, { ...user, speaking }); + const nextChannels = new Map(prev.voiceUsers); + nextChannels.set(channelId, nextUsers); + return { ...prev, voiceUsers: nextChannels }; + }); +} + +/** Store voice config for a channel from a voice_config event. */ +export function setVoiceConfig(payload: VoiceConfigPayload): void { + voiceStore.setState((prev) => { + const nextConfigs = new Map(prev.voiceConfigs); + nextConfigs.set(payload.channel_id, { + quality: payload.quality, + bitrate: payload.bitrate, + threshold_mode: payload.threshold_mode, + mixing_threshold: payload.mixing_threshold, + top_speakers: payload.top_speakers, + max_users: payload.max_users, + }); + return { ...prev, voiceConfigs: nextConfigs }; + }); +} + +/** Update speaking state for users from a voice_speakers event. + * Skips the local user — their speaking state is driven by local VAD + * (lower latency, same threshold). Prevents flicker from two sources + * disagreeing on the same field. */ +export function setSpeakers(payload: VoiceSpeakersPayload): void { + voiceStore.setState((prev) => { + const existingChannel = prev.voiceUsers.get(payload.channel_id); + if (!existingChannel) return prev; + + const currentUserId = authStore.getState().user?.id ?? 0; + const speakerSet = new Set(payload.speakers); + const nextUsers = new Map<number, VoiceUser>(); + + for (const [userId, user] of existingChannel) { + // Skip local user — local VAD is the sole authority for our own indicator + if (userId === currentUserId) { + nextUsers.set(userId, user); + continue; + } + const isSpeaking = speakerSet.has(userId); + if (user.speaking !== isSpeaking) { + nextUsers.set(userId, { ...user, speaking: isSpeaking }); + } else { + nextUsers.set(userId, user); + } + } + + const nextChannels = new Map(prev.voiceUsers); + nextChannels.set(payload.channel_id, nextUsers); + return { ...prev, voiceUsers: nextChannels }; + }); +} + +/** Selector: get all voice users in a specific channel. */ +export function getChannelVoiceUsers(channelId: number): readonly VoiceUser[] { + return voiceStore.select((s) => { + const channelUsers = s.voiceUsers.get(channelId); + if (!channelUsers) return []; + return Array.from(channelUsers.values()); + }); +} diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css new file mode 100644 index 00000000..e48693c3 --- /dev/null +++ b/Client/tauri-client/src/styles/app.css @@ -0,0 +1,1187 @@ +/* Main app styles — extracted from ui-mockup.html */ + +/* ═══ Layout ═══ */ +.app { display: flex; flex: 1; min-height: 0; } + +/* ── Server Strip ── */ +.server-strip { + width: 72px; background: var(--bg-tertiary); + display: flex; flex-direction: column; align-items: center; + padding: 12px 0; gap: 8px; flex-shrink: 0; overflow-y: auto; +} +.server-strip::-webkit-scrollbar { width: 4px; } +.server-icon { + width: 48px; height: 48px; border-radius: var(--radius-pill); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 18px; color: white; + cursor: pointer; transition: all .2s ease; + position: relative; flex-shrink: 0; +} +.server-icon:hover, .server-icon.active { border-radius: var(--radius-lg); } +.server-icon::before { + content: ''; position: absolute; left: -16px; + width: 4px; border-radius: 0 4px 4px 0; + background: white; transition: all .2s; + height: 0; opacity: 0; +} +.server-icon:hover::before { height: 20px; opacity: 1; } +.server-icon.active::before { height: 36px; opacity: 1; } +.server-icon .badge { + position: absolute; bottom: -2px; right: -2px; + background: var(--red); color: white; + font-size: 10px; font-weight: 700; + min-width: 18px; height: 18px; border-radius: 9px; + display: flex; align-items: center; justify-content: center; + padding: 0 4px; border: 3px solid var(--bg-tertiary); +} +.server-icon .badge:empty { display: none; } +.server-separator { width: 32px; height: 2px; background: var(--border); border-radius: 1px; flex-shrink: 0; } +.server-icon.add { background: transparent; color: var(--green); border: 2px dashed var(--border-strong); font-size: 24px; } +.server-icon.add:hover { border-color: var(--green); background: rgba(35,165,90,.1); } + +/* ── Channel Sidebar ── */ +.channel-sidebar { + width: 240px; background: var(--bg-secondary); + display: flex; flex-direction: column; flex-shrink: 0; +} +.channel-sidebar-header { + height: 48px; padding: 0 16px; + display: flex; align-items: center; justify-content: space-between; + border-bottom: 1px solid var(--bg-tertiary); cursor: pointer; flex-shrink: 0; +} +.channel-sidebar-header h2 { font-size: 15px; font-weight: 700; color: white; } +.channel-list { flex: 1; overflow-y: auto; padding: 8px 0; } + +.category { + padding: 16px 8px 4px 16px; + display: flex; align-items: center; gap: 4px; + cursor: pointer; user-select: none; +} +.category-arrow { + font-size: 8px; color: var(--text-muted); + transition: transform .2s; display: inline-block; +} +.category.collapsed .category-arrow { transform: rotate(-90deg); } +.category-name { + font-size: 11px; font-weight: 700; + color: var(--text-faint); letter-spacing: .5px; text-transform: uppercase; +} +.category-add-btn { + margin-left: auto; font-size: 16px; color: var(--text-faint); + cursor: pointer; padding: 0 4px; line-height: 1; opacity: 0; + transition: opacity .15s, color .15s; +} +.category:hover .category-add-btn { opacity: 1; } +.category-add-btn:hover { color: var(--text-normal); } +.category-channels { overflow: hidden; transition: max-height .2s; } +.category.collapsed + .category-channels { max-height: 0 !important; overflow: hidden; } + +.channel-item { + display: flex; align-items: center; gap: 6px; + padding: 6px 8px; margin: 1px 8px; + border-radius: var(--radius-sm); cursor: pointer; + color: var(--text-muted); transition: all .1s; +} +.channel-item:hover { background: var(--bg-hover); color: var(--text-normal); } +.channel-item.active { background: var(--bg-active); color: white; } +.channel-draggable { cursor: grab; } +.channel-draggable:active { cursor: grabbing; } +.channel-reordering { user-select: none; cursor: grabbing !important; } +.channel-reordering * { cursor: grabbing !important; } +.channel-draggable.dragging { opacity: .3; } +.channel-drop-indicator { + box-shadow: 0 2px 0 var(--accent) inset, 0 -2px 0 var(--accent) inset; + background: rgba(88, 101, 242, .15); + border-radius: var(--radius-sm); +} +.channel-item .ch-icon { font-size: 18px; opacity: .6; flex-shrink: 0; width: 20px; text-align: center; } +.channel-item.active .ch-icon { opacity: 1; } +.channel-item .ch-name { font-size: 14px; flex: 1; } +.channel-item .unread-badge { + background: var(--accent); color: white; + font-size: 10px; font-weight: 700; + min-width: 18px; height: 18px; border-radius: 9px; + display: flex; align-items: center; justify-content: center; padding: 0 4px; +} +.channel-item .unread-badge:empty { display: none; } +.channel-item.unread .ch-name { color: white; font-weight: 600; } + +/* Voice users nested in sidebar */ +.voice-users-list { padding: 2px 0 4px 36px; } +.voice-user-item { + display: flex; align-items: center; gap: 8px; + padding: 3px 8px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 13px; color: var(--text-muted); +} +.voice-user-item:hover { background: var(--bg-hover); color: var(--text-normal); } +.voice-user-item .vu-avatar { + width: 20px; height: 20px; border-radius: var(--radius-circle); + display: flex; align-items: center; justify-content: center; + font-size: 9px; font-weight: 700; color: white; flex-shrink: 0; +} +.voice-user-item.speaking .vu-avatar { box-shadow: 0 0 0 2px var(--green); } +.voice-user-item .vu-muted { color: var(--red); font-size: 12px; margin-left: 2px; } +.voice-user-item .vu-muted:first-of-type { margin-left: auto; } +.vu-icon-crossed { + position: relative; opacity: .9; +} +.vu-icon-crossed::after { + content: ""; position: absolute; + top: 50%; left: -1px; right: -1px; + height: 2px; background: var(--red); + transform: rotate(-45deg); + border-radius: 1px; +} + +/* Voice widget (above user bar, when connected) */ +.voice-widget { + background: var(--bg-secondary); border-top: 1px solid var(--border); + padding: 8px; flex-shrink: 0; display: none; +} +.voice-widget.visible { display: block; } +.vw-header { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 12px; } +.vw-connected { color: var(--green); font-weight: 700; } +.vw-channel { color: var(--text-muted); } +.vw-controls { display: flex; gap: 4px; padding: 4px 4px 0; } +.vw-controls button { + flex: 1; height: 32px; border-radius: var(--radius-sm); + background: var(--bg-active); color: var(--text-muted); + font-size: 14px; transition: all .1s; + display: flex; align-items: center; justify-content: center; +} +.vw-controls button:hover { background: var(--bg-hover); color: var(--text-normal); } +.vw-controls button.active-ctrl { background: rgba(242,63,67,.2); color: var(--red); } +.vw-controls button.disconnect { background: transparent; color: var(--red); } +.vw-controls button.disconnect:hover { background: rgba(242,63,67,.15); } + +/* ── User Bar ── */ +.user-bar { + height: 52px; background: rgba(17,18,20,.6); + display: flex; align-items: center; + padding: 0 8px; gap: 8px; flex-shrink: 0; +} +.user-bar .ub-avatar { + width: 32px; height: 32px; border-radius: var(--radius-circle); + flex-shrink: 0; position: relative; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 14px; color: white; cursor: pointer; +} +.user-bar .status-dot { + position: absolute; bottom: -1px; right: -1px; + width: 12px; height: 12px; border-radius: var(--radius-circle); + border: 3px solid rgba(17,18,20,.6); +} +.user-bar .ub-info { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.user-bar .ub-name { font-size: 13px; font-weight: 600; color: white; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 1.2; } +.user-bar .ub-status { font-size: 11px; color: var(--text-muted); line-height: 1.2; } +.user-bar .ub-controls { display: flex; gap: 2px; } +.user-bar .ub-controls button { + width: 32px; height: 32px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-muted); + display: flex; align-items: center; justify-content: center; + font-size: 16px; transition: all .1s; +} +.user-bar .ub-controls button:hover { background: var(--bg-hover); color: var(--text-normal); } +.user-bar .ub-controls button.active-ctrl { color: var(--red); } + +/* Status picker popup */ +.status-picker { + position: absolute; bottom: 60px; left: 8px; + background: var(--bg-primary); border: 1px solid var(--border); + border-radius: var(--radius-md); padding: 6px; + box-shadow: 0 8px 24px rgba(0,0,0,.5); + z-index: 100; display: none; min-width: 160px; +} +.status-picker.open { display: block; } +.status-option { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 13px; color: var(--text-normal); + background: transparent; width: 100%; text-align: left; +} +.status-option:hover { background: var(--bg-hover); } +.status-option .so-dot { + width: 10px; height: 10px; border-radius: var(--radius-circle); flex-shrink: 0; +} + +/* ── Chat Area ── */ +.chat-area { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg-primary); } + +.chat-header { + height: 48px; padding: 0 16px; + display: flex; align-items: center; gap: 12px; + border-bottom: 1px solid var(--bg-tertiary); flex-shrink: 0; +} +.chat-header .ch-hash { color: var(--text-micro); font-size: 22px; font-weight: 600; } +.chat-header .ch-name { font-size: 15px; font-weight: 700; color: white; } +.chat-header .ch-divider { width: 1px; height: 24px; background: var(--border); } +.chat-header .ch-topic { font-size: 13px; color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-header .ch-tools { display: flex; gap: 4px; flex-shrink: 0; } +.chat-header .ch-tools button { + width: 28px; height: 28px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-muted); + font-size: 16px; transition: all .1s; + display: flex; align-items: center; justify-content: center; +} +.chat-header .ch-tools button:hover { background: var(--bg-hover); color: var(--text-normal); } +.chat-header .ch-tools button.active { color: white; } +.chat-header .search-input { + width: 160px; height: 28px; border-radius: var(--radius-sm); + background: var(--bg-tertiary); color: var(--text-muted); font-size: 12px; + padding: 0 8px; transition: width .3s; +} +.chat-header .search-input::placeholder { color: var(--text-micro); } +.chat-header .search-input:focus { width: 240px; color: var(--text-normal); } + +/* ── Slot wrappers (inside .chat-area flex column) ── */ +.messages-slot { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.typing-slot { flex-shrink: 0; } +.input-slot { flex-shrink: 0; } + +/* ── Messages ── */ +.messages-container { flex: 1; overflow-y: auto; padding: 16px 0; } +.msg-day-divider { + display: flex; align-items: center; gap: 8px; + padding: 8px 16px 16px; margin-bottom: 8px; +} +.msg-day-divider .line { flex: 1; height: 1px; background: var(--border); } +.msg-day-divider .date { font-size: 11px; font-weight: 700; color: var(--text-muted); } + +.message { + padding: 2px 48px 2px 72px; position: relative; + min-height: 28px; +} +.message:hover { background: rgba(0,0,0,.06); } +.message.grouped { min-height: 20px; } +.message .msg-avatar { + position: absolute; left: 16px; top: 4px; + width: 40px; height: 40px; border-radius: var(--radius-circle); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 16px; color: white; + cursor: pointer; +} +.message.grouped .msg-avatar { display: none; } +.message .msg-hover-time { + position: absolute; left: 16px; top: 4px; + width: 40px; text-align: center; + font-size: 10px; color: var(--text-micro); + display: none; +} +.message.grouped:hover .msg-hover-time { display: block; } +.message .msg-header { display: flex; align-items: baseline; gap: 8px; } +.message.grouped .msg-header { display: none; } +.message .msg-author { font-weight: 600; cursor: pointer; } +.message .msg-author:hover { text-decoration: underline; } +.message .msg-time { font-size: 11px; color: var(--text-micro); } +.message .msg-edited { font-size: 10px; color: var(--text-micro); } +.message .msg-text { line-height: 1.45; word-break: break-word; } +.message .msg-text code { + font-family: var(--font-mono); background: var(--bg-tertiary); + padding: 2px 5px; border-radius: 3px; font-size: 13px; +} + +/* Reply */ +.msg-reply-ref { + display: flex; align-items: center; gap: 6px; + font-size: 12px; color: var(--text-muted); + margin-bottom: 4px; margin-left: -36px; padding-left: 36px; + position: relative; cursor: pointer; +} +.msg-reply-ref::before { + content: ''; position: absolute; left: 20px; top: 50%; + width: 24px; height: 12px; + border-left: 2px solid var(--text-micro); + border-top: 2px solid var(--text-micro); + border-radius: 6px 0 0 0; +} +.msg-reply-ref .rr-author { color: var(--text-normal); font-weight: 600; } +.msg-reply-ref .rr-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.msg-reply-ref:hover .rr-text { color: var(--text-normal); } + +/* URL links in messages */ +.msg-link { color: var(--text-link, #00aff4); text-decoration: none; } +.msg-link:hover { text-decoration: underline; } + +/* URL embeds */ +.msg-embed { + margin-top: 8px; border-left: 4px solid var(--accent); + border-radius: var(--radius-sm); background: var(--bg-secondary); + overflow: hidden; max-width: 420px; +} +.msg-embed-youtube { + width: 420px; max-width: 100%; +} +.msg-embed-yt-header { padding: 10px 12px 6px; } +.msg-embed-yt-title { + font-size: 14px; font-weight: 600; + color: var(--text-link, #00aff4); text-decoration: none; + display: block; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; +} +.msg-embed-yt-title:hover { text-decoration: underline; } +.msg-embed-yt-player { + position: relative; cursor: pointer; +} +.msg-embed-thumb { + display: block; width: 100%; height: auto; +} +.msg-embed-play { + position: absolute; top: 50%; left: 50%; + transform: translate(-50%, -50%); + width: 48px; height: 48px; border-radius: var(--radius-circle); + background: rgba(0,0,0,.7); color: white; + display: flex; align-items: center; justify-content: center; + font-size: 20px; pointer-events: none; + transition: background .15s; +} +.msg-embed-yt-player:hover .msg-embed-play { background: var(--red); } +.msg-embed-iframe { + width: 100%; height: 236px; + border: none; +} +.msg-embed-link { + display: flex; flex-direction: column; + padding: 12px 16px; max-width: 520px; +} +.msg-embed-link-content { min-width: 0; } +.msg-embed-host { + font-size: 12px; font-weight: 600; color: var(--text-faint); + margin-bottom: 4px; +} +.msg-embed-link-title { + font-size: 16px; font-weight: 600; + color: var(--text-link, #00aff4); text-decoration: none; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; line-height: 1.3; +} +.msg-embed-link-title:hover { text-decoration: underline; } +.msg-embed-link-desc { + font-size: 14px; color: var(--text-muted); margin-top: 6px; + line-height: 1.45; overflow: hidden; + display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; +} +.msg-embed-link-image { margin-top: 10px; } +.msg-embed-link-img { + max-width: 100%; max-height: 300px; object-fit: cover; + border-radius: var(--radius-sm); +} +.msg-embed-url { + font-size: 13px; color: var(--text-link, #00aff4); + text-decoration: none; word-break: break-all; +} +.msg-embed-url:hover { text-decoration: underline; } + +/* Reactions */ +.msg-reactions { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; } +.reaction-chip { + display: flex; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: var(--radius-md); + background: rgba(88,101,242,.15); border: 1px solid rgba(88,101,242,.3); + font-size: 14px; cursor: pointer; transition: all .1s; +} +.reaction-chip:hover { background: rgba(88,101,242,.3); } +.reaction-chip.me { border-color: var(--accent); } +.reaction-chip .rc-count { font-size: 12px; color: var(--text-normal); } +.reaction-chip.add-reaction { + background: transparent; border: 1px dashed var(--border); + color: var(--text-micro); font-size: 14px; padding: 2px 6px; +} +.reaction-chip.add-reaction:hover { border-color: var(--text-muted); color: var(--text-muted); } + +/* Code block */ +.msg-codeblock { + background: var(--bg-tertiary); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 12px; margin-top: 4px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.5; overflow-x: auto; white-space: pre; +} + +/* Image attachment */ +.msg-image { + margin-top: 4px; max-width: 400px; border-radius: var(--radius-md); + overflow: hidden; cursor: pointer; +} +.msg-image img { + display: block; max-width: 100%; max-height: 350px; + object-fit: contain; border-radius: var(--radius-md); +} +.msg-image .placeholder-img { + width: 100%; height: 200px; + background: linear-gradient(135deg, #1a1a2e, #16213e, #0f3460); + display: flex; align-items: center; justify-content: center; + color: var(--text-micro); font-size: 12px; +} +.msg-image .placeholder-img.loading { opacity: .6; } + +/* Image lightbox overlay */ +.image-lightbox { + position: fixed; inset: 0; z-index: 600; + background: rgba(0,0,0,.85); + display: flex; align-items: center; justify-content: center; + cursor: zoom-in; animation: fadeIn .2s ease; +} +.image-lightbox.dragging { cursor: grabbing; } +.image-lightbox-wrap { + display: flex; align-items: center; justify-content: center; + overflow: visible; +} +.image-lightbox img { + max-width: 90vw; max-height: 90vh; + object-fit: contain; border-radius: var(--radius-sm); + box-shadow: 0 8px 48px rgba(0,0,0,.5); + transition: transform .1s ease; + cursor: zoom-in; user-select: none; +} +.image-lightbox-close { + position: absolute; top: 16px; right: 16px; + width: 36px; height: 36px; border-radius: var(--radius-circle); + background: rgba(0,0,0,.6); color: white; border: none; + font-size: 20px; cursor: pointer; display: flex; + align-items: center; justify-content: center; z-index: 1; +} +.image-lightbox-close:hover { background: rgba(255,255,255,.2); } + +/* File attachment */ +.msg-file { + margin-top: 4px; border: 1px solid var(--border); + border-radius: var(--radius-md); overflow: hidden; max-width: 400px; +} +.msg-file-inner { + display: flex; align-items: center; gap: 12px; + padding: 12px; background: var(--bg-secondary); +} +.msg-file-icon { + width: 36px; height: 36px; border-radius: var(--radius-sm); + background: var(--accent); color: white; + display: flex; align-items: center; justify-content: center; + font-size: 16px; font-weight: 700; flex-shrink: 0; +} +.msg-file-name { font-size: 13px; color: var(--text-link); cursor: pointer; } +.msg-file-name:hover { text-decoration: underline; } +.msg-file-size { font-size: 11px; color: var(--text-muted); } +.msg-file-download { + margin-left: auto; width: 32px; height: 32px; + border-radius: var(--radius-sm); background: transparent; + color: var(--text-muted); font-size: 16px; cursor: pointer; + display: flex; align-items: center; justify-content: center; + border: none; transition: all .15s; flex-shrink: 0; +} +.msg-file-download:hover { background: var(--bg-hover); color: var(--text-normal); } + +/* ── Update Banner ── */ +.update-banner { + display: flex; align-items: center; gap: 12px; + padding: 8px 16px; background: var(--accent); + color: #fff; font-size: 13px; font-weight: 500; + flex-shrink: 0; z-index: 100; +} +.update-banner-text { flex: 1; } +.update-banner-btn { + padding: 4px 12px; border-radius: var(--radius-sm); + font-size: 12px; font-weight: 600; cursor: pointer; + border: none; transition: opacity .15s; +} +.update-banner-btn:hover { opacity: 0.85; } +.update-banner-install { background: #fff; color: var(--accent); } +.update-banner-later { background: rgba(255,255,255,0.2); color: #fff; } + +/* System message */ +.system-msg { + display: flex; align-items: center; gap: 8px; + padding: 4px 16px; margin-top: 4px; +} +.system-msg .sm-icon { color: var(--green); font-size: 16px; } +.system-msg .sm-text { font-size: 13px; color: var(--text-muted); } +.system-msg .sm-text strong { color: var(--text-normal); } +.system-msg .sm-time { font-size: 11px; color: var(--text-micro); margin-left: auto; } + +/* Message hover actions */ +.msg-actions-bar { + position: absolute; top: -14px; right: 16px; + display: flex; gap: 2px; + background: var(--bg-secondary); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 2px; + opacity: 0; transition: opacity .1s; + box-shadow: 0 2px 8px rgba(0,0,0,.3); z-index: 5; +} +.message:hover .msg-actions-bar { opacity: 1; } +.msg-actions-bar button { + width: 28px; height: 28px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-muted); + font-size: 14px; transition: all .1s; + display: flex; align-items: center; justify-content: center; +} +.msg-actions-bar button:hover { background: var(--bg-hover); color: var(--text-normal); } + +/* Reply compose bar */ +.reply-bar { + display: none; padding: 8px 16px 0; + flex-shrink: 0; +} +.reply-bar.visible { display: flex; align-items: center; gap: 8px; } +.reply-bar-inner { + flex: 1; background: var(--bg-input); + border-radius: var(--radius-md) var(--radius-md) 0 0; + padding: 8px 12px; font-size: 13px; color: var(--text-muted); + display: flex; align-items: center; gap: 8px; +} +.reply-bar-inner strong { color: var(--text-normal); } +.reply-bar .reply-close { + width: 24px; height: 24px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-muted); font-size: 16px; + display: flex; align-items: center; justify-content: center; + margin-left: auto; +} +.reply-bar .reply-close:hover { color: var(--text-normal); } + +/* Typing indicator */ +.typing-bar { + height: 24px; padding: 0 16px; + display: flex; align-items: center; gap: 8px; + font-size: 12px; color: var(--text-muted); flex-shrink: 0; +} +.typing-bar:empty { height: 0; } +.typing-dots { display: inline-flex; gap: 3px; } +.typing-dots span { + width: 4px; height: 4px; border-radius: 50%; + background: var(--text-muted); animation: typingAnim 1.4s infinite; +} +.typing-dots span:nth-child(2) { animation-delay: .2s; } +.typing-dots span:nth-child(3) { animation-delay: .4s; } + +/* Message input */ +.message-input-wrap { padding: 0 16px 20px; flex-shrink: 0; position: relative; } +.message-input-wrap.reply-active { padding-top: 0; } +/* Attachment preview bar (above input box) */ +.attachment-preview-bar { + display: none; gap: 8px; padding: 8px 8px 4px; + background: var(--bg-input); border-radius: var(--radius-md) var(--radius-md) 0 0; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.attachment-preview-bar.visible { display: flex; } +.attachment-preview-item { + position: relative; border-radius: var(--radius-sm); + overflow: hidden; background: var(--bg-secondary); +} +.attachment-preview-img { + display: block; max-width: 120px; max-height: 120px; + object-fit: cover; border-radius: var(--radius-sm); +} +.attachment-preview-file { + display: flex; align-items: center; gap: 6px; + padding: 8px 12px; font-size: 20px; +} +.attachment-preview-name { + font-size: 12px; color: var(--text-muted); max-width: 80px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.attachment-preview-remove { + position: absolute; top: 2px; right: 2px; + width: 20px; height: 20px; border-radius: var(--radius-circle); + background: rgba(0,0,0,.7); color: white; + font-size: 14px; display: flex; align-items: center; + justify-content: center; cursor: pointer; border: none; + transition: background .15s; +} +.attachment-preview-remove:hover { background: rgba(255,255,255,.2); } +.attachment-preview-item.uploading { opacity: .6; } +.attachment-preview-spinner { + position: absolute; top: 50%; left: 50%; + transform: translate(-50%, -50%); + font-size: 18px; animation: spin 1s linear infinite; +} +@keyframes spin { to { transform: translate(-50%, -50%) rotate(360deg); } } +.attachment-upload-error { + padding: 6px 10px; font-size: 12px; color: var(--red); + background: rgba(237,66,69,.1); border-radius: var(--radius-sm); +} +.attachment-preview-bar.visible + .message-input-box { + border-radius: 0 0 var(--radius-md) var(--radius-md); +} + +.message-input-box { + background: var(--bg-input); border-radius: var(--radius-md); + display: flex; align-items: flex-end; padding: 4px; +} +.message-input-box.reply-mode { border-radius: 0 0 var(--radius-md) var(--radius-md); } +.input-btn { + width: 36px; height: 36px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-muted); + font-size: 18px; transition: all .1s; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; +} +.input-btn:hover { color: var(--text-normal); } +.msg-textarea { + flex: 1; background: transparent; color: var(--text-normal); + font-size: 14px; resize: none; padding: 8px 4px; + min-height: 36px; max-height: 200px; line-height: 1.4; +} +.msg-textarea::placeholder { color: var(--text-micro); } + +/* ── Member List ── */ +.member-list { + width: 240px; background: var(--bg-secondary); + flex-shrink: 0; overflow-y: auto; padding: 8px 0; + transition: width .2s; +} +.member-list.hidden { width: 0; padding: 0; overflow: hidden; } +.member-role-group { + padding: 16px 16px 4px; + font-size: 11px; font-weight: 700; + color: var(--text-faint); text-transform: uppercase; letter-spacing: .5px; +} +.member-item { + display: flex; align-items: center; gap: 8px; + padding: 4px 8px; margin: 1px 8px; + border-radius: var(--radius-sm); cursor: pointer; transition: background .1s; +} +.member-item:hover { background: var(--bg-hover); } +.member-item .mi-avatar { + width: 32px; height: 32px; border-radius: var(--radius-circle); + flex-shrink: 0; position: relative; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 13px; color: white; +} +.member-item .mi-status { + position: absolute; bottom: -1px; right: -1px; + width: 12px; height: 12px; border-radius: var(--radius-circle); + border: 3px solid var(--bg-secondary); +} +.member-item .mi-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.member-item.offline { opacity: .4; } + +/* ── User Profile Popup ── */ +.user-popup { + position: fixed; z-index: 200; + background: var(--bg-primary); border: 1px solid var(--border); + border-radius: var(--radius-md); width: 300px; + box-shadow: 0 8px 32px rgba(0,0,0,.6); + display: none; overflow: hidden; +} +.user-popup.open { display: block; } +.up-banner { height: 60px; } +.up-body { padding: 36px 16px 16px; position: relative; } +.up-avatar { + width: 64px; height: 64px; border-radius: var(--radius-circle); + position: absolute; top: -32px; left: 16px; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 24px; color: white; + border: 4px solid var(--bg-primary); +} +.up-name { font-size: 18px; font-weight: 700; color: white; } +.up-role { font-size: 12px; margin-top: 2px; } +.up-section { margin-top: 12px; background: var(--bg-secondary); border-radius: var(--radius-md); padding: 12px; } +.up-section-title { font-size: 11px; font-weight: 700; color: var(--text-faint); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; } +.up-section-text { font-size: 13px; color: var(--text-normal); } +.up-roles { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 8px; } +.up-role-tag { + display: flex; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: var(--radius-pill); + background: var(--bg-hover); font-size: 11px; color: var(--text-normal); +} +.up-role-dot { width: 8px; height: 8px; border-radius: 50%; } + +/* ── Emoji Picker ── */ +.emoji-picker { + position: absolute; z-index: 200; + bottom: 100%; right: 0; + background: var(--bg-primary); border: 1px solid var(--border); + border-radius: var(--radius-md); width: 320px; + box-shadow: 0 8px 32px rgba(0,0,0,.6); + display: none; overflow: hidden; + margin-bottom: 4px; +} +.emoji-picker.open { display: block; } +.ep-header { + padding: 12px; border-bottom: 1px solid var(--border); +} +.ep-search { + width: 100%; background: var(--bg-tertiary); + border: none; border-radius: var(--radius-sm); + padding: 8px 10px; color: var(--text-normal); font-size: 13px; +} +.ep-search::placeholder { color: var(--text-micro); } +.ep-category-label { + padding: 8px 12px 4px; font-size: 11px; font-weight: 700; + color: var(--text-faint); text-transform: uppercase; letter-spacing: .5px; +} +.ep-grid { + display: grid; grid-template-columns: repeat(8, 1fr); + gap: 2px; padding: 4px 8px 8px; +} +.ep-emoji { + width: 100%; aspect-ratio: 1; + display: flex; align-items: center; justify-content: center; + font-size: 22px; cursor: pointer; border-radius: var(--radius-sm); + background: transparent; transition: background .1s; +} +.ep-emoji:hover { background: var(--bg-hover); } + +/* ── Settings Overlay (app) ── */ +.settings-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: var(--bg-primary); z-index: 500; + display: none; +} +.settings-overlay.open { display: flex; } +.settings-sidebar { + width: 220px; background: var(--bg-secondary); + padding: 48px 8px 16px; overflow-y: auto; flex-shrink: 0; +} +.settings-cat { + padding: 8px 12px 4px; font-size: 11px; font-weight: 700; + color: var(--text-faint); text-transform: uppercase; letter-spacing: .5px; +} +.settings-nav-item { + display: flex; align-items: center; gap: 8px; + padding: 6px 12px; margin: 1px 0; + border-radius: var(--radius-sm); cursor: pointer; + font-size: 14px; color: var(--text-muted); + transition: all .1s; background: transparent; width: 100%; text-align: left; +} +.settings-nav-item:hover { background: var(--bg-hover); color: var(--text-normal); } +.settings-nav-item.active { background: var(--bg-active); color: white; } +.settings-sep { height: 1px; background: var(--border); margin: 8px 12px; } +.settings-nav-item.danger { color: var(--red); } +.settings-nav-item.danger:hover { background: rgba(242,63,67,.1); } + +.settings-content { + flex: 1; overflow-y: auto; padding: 48px 40px; max-width: 720px; +} +.settings-content h1 { font-size: 20px; font-weight: 700; color: white; margin-bottom: 24px; } +.settings-content h3 { + font-size: 11px; font-weight: 700; + color: var(--text-faint); text-transform: uppercase; + letter-spacing: .5px; margin: 24px 0 8px; +} +.settings-close-btn { + position: absolute; top: 16px; right: 24px; + width: 36px; height: 36px; border-radius: var(--radius-circle); + background: transparent; border: 2px solid var(--border-strong); + color: var(--text-muted); font-size: 18px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; transition: all .15s; z-index: 10; +} +.settings-close-btn:hover { border-color: var(--text-normal); color: var(--text-normal); } + +/* Settings panes */ +.settings-pane { display: none; } +.settings-pane.active { display: block; } +.setting-row { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 0; border-bottom: 1px solid var(--border); +} +.setting-row:last-child { border-bottom: none; } +.setting-label { font-size: 14px; color: var(--text-normal); } +.setting-desc { font-size: 13px; color: var(--text-muted); margin-top: 4px; } +.toggle { + width: 40px; height: 24px; border-radius: 12px; + background: var(--text-faint); cursor: pointer; + position: relative; transition: background .2s; flex-shrink: 0; +} +.toggle.on { background: var(--green); } +.toggle::after { + content: ''; position: absolute; + width: 18px; height: 18px; border-radius: 50%; + background: white; top: 3px; left: 3px; transition: transform .2s; +} +.toggle.on::after { transform: translateX(16px); } +.settings-select { + background: var(--bg-tertiary); color: var(--text-normal); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 8px 12px; font-size: 14px; width: 200px; cursor: pointer; +} +/* ── Mic Level Meter ── */ +.mic-meter-wrap { margin-bottom: 8px; } +.mic-meter-bar { + position: relative; height: 8px; border-radius: 4px; + background: var(--bg-tertiary); overflow: visible; +} +.mic-meter-level { + height: 100%; border-radius: 4px; width: 0%; + background: #43b581; transition: width 50ms linear; +} +.mic-meter-threshold { + position: absolute; top: -3px; width: 2px; height: 14px; + background: #fff; border-radius: 1px; left: 50%; + pointer-events: none; opacity: 0.8; +} +.slider-row { display: flex; align-items: center; gap: 12px; } +.settings-slider { + flex: 1; -webkit-appearance: none; appearance: none; + height: 6px; border-radius: 3px; + background: var(--bg-tertiary); outline: none; +} +.settings-slider::-webkit-slider-thumb { + -webkit-appearance: none; width: 16px; height: 16px; + border-radius: 50%; background: var(--accent); cursor: pointer; +} +.slider-val { font-size: 13px; color: var(--text-muted); min-width: 40px; text-align: right; } +.account-card { + background: var(--bg-secondary); border-radius: var(--radius-md); + padding: 16px; display: flex; align-items: center; gap: 16px; +} +.ac-avatar { + width: 64px; height: 64px; border-radius: var(--radius-circle); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 24px; color: white; flex-shrink: 0; +} +.ac-name { font-size: 18px; font-weight: 700; color: white; } +.ac-id { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.ac-btn { + padding: 8px 16px; border-radius: var(--radius-sm); + background: var(--accent); color: white; + font-size: 13px; font-weight: 600; transition: background .15s; margin-left: auto; +} +.ac-btn:hover { background: var(--accent-hover); } +.theme-options { display: flex; gap: 12px; margin-top: 8px; } +.theme-opt { + width: 80px; height: 56px; border-radius: var(--radius-md); + cursor: pointer; border: 2px solid transparent; + display: flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: 600; transition: all .15s; +} +.theme-opt:hover { border-color: var(--border-strong); } +.theme-opt.active { border-color: var(--accent); } +.theme-opt.dark { background: #313338; color: white; } +.theme-opt.light { background: #f2f3f5; color: #313338; } +.theme-opt.midnight { background: #0d0d0d; color: #b5bac1; } +.keybind-row { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 0; border-bottom: 1px solid var(--border); +} +.kbd { + background: var(--bg-tertiary); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 4px 8px; + font-family: var(--font-mono); font-size: 12px; color: var(--text-normal); +} + +/* ── Context Menu ── */ +.context-menu { + position: fixed; z-index: 300; + background: var(--bg-primary); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 4px; + box-shadow: 0 8px 24px rgba(0,0,0,.5); + min-width: 180px; +} +.context-menu-item { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; border-radius: var(--radius-sm); + cursor: pointer; font-size: 13px; color: var(--text-normal); + background: transparent; width: 100%; text-align: left; +} +.context-menu-item:hover { background: var(--accent); color: white; } +.context-menu-item.danger { color: var(--red); } +.context-menu-item.danger:hover { background: var(--red); color: white; } +.context-menu-sep { height: 1px; background: var(--border); margin: 4px 0; } + +/* ── Toast Notification ── */ +.toast-container { + position: fixed; bottom: 24px; left: 50%; + transform: translateX(-50%); + z-index: 9999; display: flex; flex-direction: column; gap: 8px; + pointer-events: none; +} +.toast { + background: var(--bg-secondary); border: 1px solid var(--border); + border-radius: var(--radius-md); padding: 12px 20px; + font-size: 13px; color: var(--text-normal); + box-shadow: 0 8px 24px rgba(0,0,0,.5); + opacity: 0; transition: opacity .3s; + pointer-events: none; +} +.toast.show { opacity: 1; } +.toast-info { border-left: 4px solid var(--accent); } +.toast-error { border-left: 4px solid var(--red); } +.toast-success { border-left: 4px solid var(--green); } + +/* ── Reconnecting Banner ── */ +.reconnecting-banner { + background: var(--yellow); color: #1e1f22; + text-align: center; padding: 6px 16px; + font-size: 13px; font-weight: 600; + flex-shrink: 0; display: none; +} +.reconnecting-banner.visible { display: block; } + +/* ── Search Overlay ── */ +.search-overlay { + position: fixed; inset: 0; + background: var(--bg-overlay); + display: none; align-items: flex-start; justify-content: center; + padding-top: 80px; z-index: 400; +} +.search-overlay.open { display: flex; } +.search-overlay-box { + width: 540px; background: var(--bg-primary); + border-radius: var(--radius-md); + box-shadow: 0 8px 48px rgba(0,0,0,.5); + overflow: hidden; +} +.search-overlay-input { + width: 100%; padding: 16px 20px; + background: transparent; color: var(--text-normal); + font-size: 16px; border: none; +} +.search-overlay-input::placeholder { color: var(--text-micro); } +.search-overlay-results { + max-height: 400px; overflow-y: auto; + border-top: 1px solid var(--border); +} + +/* ── Friends / DM View ── */ +.dm-sidebar-header { + padding: 8px; flex-shrink: 0; +} +.dm-search { + width: 100%; background: var(--bg-tertiary); + border: none; border-radius: var(--radius-sm); + padding: 8px 10px; color: var(--text-normal); font-size: 13px; +} +.dm-search::placeholder { color: var(--text-micro); } + +.dm-nav-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; margin: 1px 8px; + border-radius: var(--radius-sm); cursor: pointer; + color: var(--text-muted); transition: all .1s; font-size: 14px; +} +.dm-nav-item:hover { background: var(--bg-hover); color: var(--text-normal); } +.dm-nav-item.active { background: var(--bg-active); color: white; } +.dm-nav-item .dm-nav-icon { font-size: 18px; width: 20px; text-align: center; } + +.dm-section-label { + padding: 16px 16px 4px; + font-size: 11px; font-weight: 700; + color: var(--text-faint); letter-spacing: .5px; text-transform: uppercase; + display: flex; align-items: center; justify-content: space-between; +} +.dm-section-label .dm-add { + width: 18px; height: 18px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-muted); + font-size: 16px; display: flex; align-items: center; justify-content: center; + cursor: pointer; transition: color .1s; +} +.dm-section-label .dm-add:hover { color: var(--text-normal); } + +.dm-item { + display: flex; align-items: center; gap: 10px; + padding: 6px 8px; margin: 1px 8px; + border-radius: var(--radius-sm); cursor: pointer; + color: var(--text-muted); transition: all .1s; +} +.dm-item:hover { background: var(--bg-hover); color: var(--text-normal); } +.dm-item.active { background: var(--bg-active); color: white; } +.dm-item .dm-avatar { + width: 32px; height: 32px; border-radius: var(--radius-circle); + flex-shrink: 0; position: relative; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 13px; color: white; +} +.dm-item .dm-avatar .dm-status { + position: absolute; bottom: -1px; right: -1px; + width: 12px; height: 12px; border-radius: var(--radius-circle); + border: 3px solid var(--bg-secondary); +} +.dm-item.active .dm-avatar .dm-status { border-color: var(--bg-active); } +.dm-item .dm-name { font-size: 14px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dm-item .dm-close { + width: 20px; height: 20px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-micro); + font-size: 14px; display: none; align-items: center; justify-content: center; + cursor: pointer; +} +.dm-item:hover .dm-close { display: flex; } +.dm-item .dm-close:hover { color: var(--text-normal); } +.dm-item .dm-unread { + width: 8px; height: 8px; border-radius: 50%; + background: white; flex-shrink: 0; +} + +/* Friends list (main area when Home > Friends is active) */ +.friends-view { + flex: 1; display: flex; flex-direction: column; + background: var(--bg-primary); +} +.friends-header { + height: 48px; padding: 0 16px; + display: flex; align-items: center; gap: 16px; + border-bottom: 1px solid var(--bg-tertiary); flex-shrink: 0; +} +.friends-header .fh-title { + font-size: 15px; font-weight: 700; color: white; + display: flex; align-items: center; gap: 8px; +} +.friends-header .fh-title .fh-icon { font-size: 20px; } +.friends-header .fh-divider { width: 1px; height: 24px; background: var(--border); } +.friends-tab { + padding: 4px 10px; border-radius: var(--radius-sm); + font-size: 13px; font-weight: 500; color: var(--text-muted); + cursor: pointer; transition: all .1s; background: transparent; +} +.friends-tab:hover { background: var(--bg-hover); color: var(--text-normal); } +.friends-tab.active { background: var(--bg-active); color: white; } +.friends-tab.add-friend { + background: var(--green); color: white; font-weight: 600; +} +.friends-tab.add-friend:hover { background: #1e8e4c; } + +.friends-list { flex: 1; overflow-y: auto; padding: 8px 16px; } +.friends-search-bar { + padding: 8px 0 16px; +} +.friends-search { + width: 100%; background: var(--bg-tertiary); + border: none; border-radius: var(--radius-sm); + padding: 10px 12px; color: var(--text-normal); font-size: 13px; +} +.friends-search::placeholder { color: var(--text-micro); } +.friends-count { + font-size: 11px; font-weight: 700; + color: var(--text-faint); text-transform: uppercase; + letter-spacing: .5px; padding: 8px 0; +} + +.friend-item { + display: flex; align-items: center; gap: 12px; + padding: 10px 8px; + border-top: 1px solid var(--border); + cursor: pointer; transition: background .1s; + border-radius: var(--radius-sm); +} +.friend-item:hover { background: var(--bg-hover); } +.friend-item .fi-avatar { + width: 40px; height: 40px; border-radius: var(--radius-circle); + flex-shrink: 0; position: relative; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 16px; color: white; +} +.friend-item .fi-status-dot { + position: absolute; bottom: 0; right: 0; + width: 14px; height: 14px; border-radius: var(--radius-circle); + border: 3px solid var(--bg-primary); +} +.friend-item:hover .fi-status-dot { border-color: var(--bg-hover); } +.friend-item .fi-info { flex: 1; } +.friend-item .fi-name { font-size: 14px; font-weight: 600; color: white; } +.friend-item .fi-status-text { font-size: 12px; color: var(--text-muted); } +.friend-item .fi-actions { + display: flex; gap: 6px; opacity: 0; transition: opacity .1s; +} +.friend-item:hover .fi-actions { opacity: 1; } +.fi-action-btn { + width: 36px; height: 36px; border-radius: var(--radius-circle); + background: var(--bg-secondary); color: var(--text-muted); + display: flex; align-items: center; justify-content: center; + font-size: 16px; transition: all .1s; cursor: pointer; border: none; +} +.fi-action-btn:hover { background: var(--bg-active); color: var(--text-normal); } + +/* Add friend form */ +.add-friend-form { + padding: 24px; +} +.add-friend-form h2 { + font-size: 16px; font-weight: 700; color: white; margin-bottom: 4px; +} +.add-friend-form p { + font-size: 13px; color: var(--text-muted); margin-bottom: 16px; +} +.add-friend-input-row { + display: flex; gap: 8px; +} +.add-friend-input { + flex: 1; background: var(--bg-tertiary); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 12px; color: var(--text-normal); font-size: 14px; +} +.add-friend-input:focus { border-color: var(--accent); } +.add-friend-input::placeholder { color: var(--text-micro); } +.add-friend-submit { + padding: 12px 20px; border-radius: var(--radius-sm); + background: var(--accent); color: white; + font-size: 14px; font-weight: 600; transition: background .15s; + white-space: nowrap; +} +.add-friend-submit:hover { background: var(--accent-hover); } + +/* DM chat header */ +.dm-chat-header { + height: 48px; padding: 0 16px; + display: flex; align-items: center; gap: 10px; + border-bottom: 1px solid var(--bg-tertiary); flex-shrink: 0; +} +.dm-chat-header .dch-avatar { + width: 24px; height: 24px; border-radius: var(--radius-circle); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 10px; color: white; +} +.dm-chat-header .dch-name { font-size: 15px; font-weight: 700; color: white; } +.dm-chat-header .dch-status { font-size: 12px; color: var(--text-muted); } + +/* DM profile sidebar */ +.dm-profile { + width: 340px; background: var(--bg-secondary); + flex-shrink: 0; overflow-y: auto; + transition: width .2s; +} +.dm-profile.hidden { width: 0; overflow: hidden; } +.dm-profile-banner { height: 120px; } +.dm-profile-body { padding: 48px 16px 16px; position: relative; } +.dm-profile-avatar { + width: 80px; height: 80px; border-radius: var(--radius-circle); + position: absolute; top: -40px; left: 16px; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 28px; color: white; + border: 6px solid var(--bg-secondary); +} +.dm-profile-name { font-size: 20px; font-weight: 700; color: white; } +.dm-profile-role { font-size: 12px; margin-top: 2px; } +.dm-profile-section { + margin-top: 16px; background: var(--bg-primary); + border-radius: var(--radius-md); padding: 12px; +} +.dm-profile-section-title { + font-size: 11px; font-weight: 700; color: var(--text-faint); + text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; +} +.dm-profile-section-text { font-size: 13px; color: var(--text-normal); line-height: 1.5; } +.dm-profile-mutual { + display: flex; align-items: center; gap: 8px; + padding: 6px 0; +} +.dm-profile-mutual .dpm-avatar { + width: 24px; height: 24px; border-radius: var(--radius-circle); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 9px; color: white; flex-shrink: 0; +} +.dm-profile-mutual .dpm-name { font-size: 13px; color: var(--text-muted); } + +/* ═══ Animations ═══ */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes slideIn { + from { opacity: 0; transform: scale(.95); } + to { opacity: 1; transform: scale(1); } +} +@keyframes typingAnim { + 0%, 60%, 100% { opacity: .3; transform: translateY(0); } + 30% { opacity: 1; transform: translateY(-3px); } +} + +.user-popup.open, .emoji-picker.open { animation: slideIn .15s ease; } +.settings-overlay.open { animation: fadeIn .2s ease; } + +/* ═══ Responsive ═══ */ +@media (max-width: 1200px) { + .member-list { width: 0; padding: 0; overflow: hidden; } +} +@media (max-width: 800px) { + .channel-sidebar { width: 0; overflow: hidden; } +} +@media (max-width: 600px) { + .server-strip { width: 0; overflow: hidden; } +} diff --git a/Client/tauri-client/src/styles/base.css b/Client/tauri-client/src/styles/base.css new file mode 100644 index 00000000..8a0d1860 --- /dev/null +++ b/Client/tauri-client/src/styles/base.css @@ -0,0 +1,83 @@ +/* ═══════════════════════════════════════════ + BASE STYLES — reset, scrollbar, fonts + Extracted from ui-mockup.html + ═══════════════════════════════════════════ */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; + overflow: hidden; +} + +body { + font-family: var(--font-body); + font-size: 14px; + color: var(--text-normal); + background: var(--bg-tertiary); + -webkit-font-smoothing: antialiased; +} + +#app { + height: 100%; +} + +button { + font-family: inherit; + border: none; + cursor: pointer; + outline: none; + background: none; + color: inherit; +} + +input, +textarea, +select { + font-family: inherit; + border: none; + outline: none; +} + +a { + color: var(--text-link); + text-decoration: none; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--bg-hover); +} + +/* Utility classes */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} diff --git a/Client/tauri-client/src/styles/login.css b/Client/tauri-client/src/styles/login.css new file mode 100644 index 00000000..12ab5e81 --- /dev/null +++ b/Client/tauri-client/src/styles/login.css @@ -0,0 +1,717 @@ +/* Login page styles — extracted from login-mockup.html */ + +/* ═══ Page layout ═══ */ +.connect-page { + display: flex; height: 100vh; position: relative; overflow: hidden; +} + +/* ═══ Animated background ═══ */ +.connect-page::before { + content: ''; position: absolute; inset: 0; + background: + radial-gradient(ellipse 600px 400px at 15% 80%, rgba(88,101,242,.08) 0%, transparent 70%), + radial-gradient(ellipse 500px 350px at 85% 20%, rgba(88,101,242,.06) 0%, transparent 70%), + radial-gradient(ellipse 300px 300px at 50% 50%, rgba(35,165,90,.04) 0%, transparent 70%); + pointer-events: none; z-index: 0; + animation: bgShift 20s ease-in-out infinite alternate; +} + +/* ═══ Settings gear (top-right) ═══ */ +.settings-gear { + position: absolute; top: 16px; right: 16px; z-index: 10; + width: 36px; height: 36px; border-radius: var(--radius-md); + background: transparent; color: var(--text-faint); + display: flex; align-items: center; justify-content: center; + transition: all .2s; +} +.settings-gear:hover { background: var(--bg-hover); color: var(--text-normal); transform: rotate(30deg); } +.settings-gear svg { width: 20px; height: 20px; } + +/* ═══════════════════════════════════════════ + LEFT PANEL — Saved Server Profiles + ═══════════════════════════════════════════ */ +.server-panel { + width: 320px; background: var(--bg-secondary); + display: flex; flex-direction: column; flex-shrink: 0; + z-index: 1; position: relative; + border-right: 1px solid var(--bg-tertiary); +} +.server-panel-header { + padding: 16px 20px 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + display: flex; align-items: center; justify-content: space-between; +} +.server-panel-header h2 { + font-size: 11px; font-weight: 700; color: var(--text-faint); + letter-spacing: .05em; text-transform: uppercase; +} +.header-actions { display: flex; gap: 4px; } +.header-btn { + width: 28px; height: 28px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-faint); + display: flex; align-items: center; justify-content: center; + transition: all .15s; font-size: 14px; +} +.header-btn:hover { background: var(--bg-hover); color: var(--text-normal); } +.header-btn svg { width: 14px; height: 14px; } + +/* Server list */ +.server-list { flex: 1; overflow-y: auto; padding: 8px; } +.server-item { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; border-radius: 6px; + cursor: pointer; transition: background .15s; + position: relative; +} +.server-item:hover { background: var(--bg-hover); } +.server-item.active { background: var(--bg-active); } +.server-item .srv-icon { + width: 40px; height: 40px; border-radius: 12px; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 16px; color: white; + flex-shrink: 0; transition: border-radius .2s; + position: relative; +} +.server-item:hover .srv-icon, +.server-item.active .srv-icon { border-radius: 10px; } +.server-item .srv-info { flex: 1; min-width: 0; } +.server-item .srv-name { + font-size: 14px; font-weight: 600; color: var(--text-normal); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.server-item .srv-meta { + display: flex; align-items: center; gap: 6px; margin-top: 2px; +} +.server-item .srv-host { + font-size: 12px; color: var(--text-faint); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.server-item .srv-last { + font-size: 10px; color: var(--text-micro); + white-space: nowrap; +} + +/* Status dot on icon */ +.srv-status-dot { + width: 10px; height: 10px; border-radius: 50%; + position: absolute; bottom: -1px; right: -1px; + border: 2.5px solid var(--bg-secondary); +} +.server-item:hover .srv-status-dot { border-color: var(--bg-hover); } +.server-item.active .srv-status-dot { border-color: var(--bg-active); } +.srv-status-dot.online { background: var(--green); } +.srv-status-dot.slow { background: var(--yellow); } +.srv-status-dot.offline { background: var(--red); } +.srv-status-dot.unknown { background: var(--text-micro); } +.srv-status-dot.checking { + background: var(--text-micro); + animation: pulse 1s ease-in-out infinite; +} + +/* Latency badge */ +.srv-latency { + font-size: 10px; color: var(--text-micro); font-family: var(--font-mono); + white-space: nowrap; +} +.srv-latency.good { color: var(--green); } +.srv-latency.warn { color: var(--yellow); } +.srv-latency.bad { color: var(--red); } + +/* Action buttons on server item */ +.srv-actions { + display: flex; gap: 2px; opacity: 0; transition: opacity .15s; flex-shrink: 0; +} +.server-item:hover .srv-actions { opacity: 1; } +.srv-btn { + width: 26px; height: 26px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-faint); + display: flex; align-items: center; justify-content: center; + font-size: 13px; transition: all .15s; +} +.srv-btn:hover { background: var(--bg-active); color: var(--text-normal); } +.srv-btn.danger:hover { background: var(--red); color: white; } + +/* Auto-connect indicator */ +.srv-auto { font-size: 10px; color: var(--accent); margin-left: 4px; } + +/* Empty state */ +.server-empty { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 8px; + padding: 40px; +} +.server-empty-icon { + width: 64px; height: 64px; border-radius: 16px; + background: var(--bg-hover); display: flex; + align-items: center; justify-content: center; + margin-bottom: 8px; +} +.server-empty-icon svg { width: 28px; height: 28px; color: var(--text-micro); } +.server-empty h3 { font-size: 16px; color: var(--text-faint); font-weight: 600; } +.server-empty p { font-size: 13px; color: var(--text-micro); text-align: center; line-height: 1.5; } + +/* Footer */ +.server-panel-footer { + padding: 10px 12px; border-top: 1px solid var(--border); flex-shrink: 0; + display: flex; gap: 6px; +} +.btn-add-server { + flex: 1; padding: 8px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-muted); + font-size: 13px; transition: all .15s; + display: flex; align-items: center; justify-content: center; gap: 6px; +} +.btn-add-server:hover { background: var(--bg-hover); color: var(--text-normal); } +.btn-footer-icon { + width: 32px; height: 32px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-faint); + display: flex; align-items: center; justify-content: center; + transition: all .15s; flex-shrink: 0; +} +.btn-footer-icon:hover { background: var(--bg-hover); color: var(--text-normal); } +.btn-footer-icon svg { width: 16px; height: 16px; } + +/* Protocol tip */ +.protocol-tip { + padding: 6px 20px 10px; font-size: 11px; color: var(--text-micro); + text-align: center; line-height: 1.4; +} +.protocol-tip code { + font-family: var(--font-mono); font-size: 10px; + background: var(--bg-hover); padding: 1px 4px; border-radius: 2px; + color: var(--text-faint); +} + +/* ═══════════════════════════════════════════ + RIGHT PANEL — Login / Register / 2FA form + ═══════════════════════════════════════════ */ +.form-panel { + flex: 1; display: flex; align-items: center; justify-content: center; + z-index: 1; position: relative; padding: 40px; +} +.form-container { + width: 100%; max-width: 400px; + animation: formSlideIn .5s cubic-bezier(.16,1,.3,1); +} + +/* Logo */ +.form-logo { text-align: center; margin-bottom: 28px; } +.form-logo-mark { + width: 56px; height: 56px; border-radius: 16px; + background: var(--accent); display: inline-flex; + align-items: center; justify-content: center; + margin-bottom: 12px; + box-shadow: 0 8px 32px rgba(88,101,242,.25); +} +.form-logo-mark svg { width: 32px; height: 32px; } +.form-logo h1 { + font-family: var(--font-display); + font-size: 28px; font-weight: 700; color: white; + letter-spacing: -.01em; +} +.form-logo p { font-size: 14px; color: var(--text-muted); margin-top: 4px; } + +/* Banners */ +.error-banner { + background: rgba(45,18,20,1); border-radius: var(--radius-sm); + padding: 12px 14px; margin-bottom: 16px; + display: none; align-items: flex-start; gap: 8px; + animation: shakeX .4s ease-in-out; +} +.error-banner.visible { display: flex; } +.error-banner-icon { color: #f38688; font-size: 16px; flex-shrink: 0; margin-top: 1px; } +.error-banner-text { color: #f38688; font-size: 13px; line-height: 1.4; } +.success-banner { + background: rgba(35,165,90,.12); border-radius: var(--radius-sm); + padding: 12px 14px; margin-bottom: 16px; + display: none; align-items: center; gap: 8px; +} +.success-banner.visible { display: flex; } +.success-banner-icon { color: var(--green); font-size: 16px; flex-shrink: 0; } +.success-banner-text { color: var(--green); font-size: 13px; } + +/* Form fields */ +.form-group { margin-bottom: 16px; } +.form-group--hidden { display: none; } +.form-row { display: flex; gap: 12px; } +.form-row .form-group { flex: 1; } +.form-row .form-group.port-field { flex: 0 0 100px; } +.form-label { + display: block; font-size: 11px; font-weight: 700; + color: var(--text-muted); letter-spacing: .02em; + text-transform: uppercase; margin-bottom: 6px; +} +.form-label .required { color: var(--red); margin-left: 2px; } +.form-input { + width: 100%; padding: 10px 12px; + background: var(--bg-input); color: var(--text-normal); + border: 1px solid var(--border); border-radius: var(--radius-sm); + font-size: 14px; transition: border-color .2s; +} +.form-input::placeholder { color: var(--text-micro); } +.form-input:hover { border-color: var(--border-strong); } +.form-input:focus { border-color: var(--accent); } +.form-input.error { border-color: var(--red); } + +/* Password field */ +.password-wrapper { position: relative; } +.password-wrapper .form-input { padding-right: 40px; } +.password-toggle { + position: absolute; right: 8px; top: 50%; transform: translateY(-50%); + background: transparent; color: var(--text-faint); padding: 4px; + border-radius: var(--radius-sm); font-size: 16px; transition: color .15s; +} +.password-toggle:hover { color: var(--text-normal); } + +/* Checkbox */ +.form-checkbox { + display: flex; align-items: center; gap: 8px; + cursor: pointer; margin-bottom: 16px; user-select: none; +} +.form-checkbox input { display: none; } +.checkbox-box { + width: 18px; height: 18px; border-radius: var(--radius-sm); + border: 2px solid var(--border-strong); background: transparent; + display: flex; align-items: center; justify-content: center; + transition: all .15s; flex-shrink: 0; +} +.checkbox-box svg { opacity: 0; transition: opacity .15s; } +.form-checkbox:hover .checkbox-box { border-color: var(--accent); } +.form-checkbox input:checked + .checkbox-box { + background: var(--accent); border-color: var(--accent); +} +.form-checkbox input:checked + .checkbox-box svg { opacity: 1; } +.checkbox-label { font-size: 13px; color: var(--text-muted); } + +/* Remember password */ +.remember-password-group { + display: flex; align-items: center; gap: 8px; + margin-bottom: 16px; margin-top: -8px; +} +.remember-password-group input[type="checkbox"] { + width: 16px; height: 16px; accent-color: var(--accent); + cursor: pointer; margin: 0; +} +.remember-password-label { + font-size: 13px; color: var(--text-muted); + cursor: pointer; user-select: none; +} + +/* Primary button */ +.btn-primary { + width: 100%; padding: 12px; border-radius: var(--radius-sm); + background: var(--accent); color: white; + font-size: 14px; font-weight: 600; + transition: background .15s; position: relative; overflow: hidden; +} +.btn-primary:hover { background: var(--accent-hover); } +.btn-primary:active { background: var(--accent-active); } +.btn-primary:disabled { background: var(--border-strong); color: var(--text-faint); cursor: not-allowed; } +.btn-primary.loading { pointer-events: none; } +.btn-primary.loading .btn-text { opacity: 0; } +.btn-primary .btn-spinner { + position: absolute; inset: 0; + display: none; align-items: center; justify-content: center; +} +.btn-primary.loading .btn-spinner { display: flex; } +.spinner { + width: 18px; height: 18px; border: 2px solid rgba(255,255,255,.3); + border-top-color: white; border-radius: 50%; + animation: spin .6s linear infinite; +} + +/* Mode switch */ +.form-switch { margin-top: 12px; font-size: 13px; color: var(--text-faint); } +.form-switch a { color: var(--text-link); cursor: pointer; text-decoration: none; } +.form-switch a:hover { text-decoration: underline; } + +/* ═══ TOTP overlay (covers the form panel for 2FA input) ═══ */ +.totp-overlay { + position: absolute; inset: 0; + background: var(--bg-primary); + display: flex; align-items: center; justify-content: center; + z-index: 10; + animation: formSlideIn .3s cubic-bezier(.16,1,.3,1); +} +.totp-overlay--hidden { display: none; } + +/* ═══ 2FA / TOTP ═══ */ +.totp-icon { + width: 64px; height: 64px; border-radius: 50%; + background: var(--accent); display: flex; + align-items: center; justify-content: center; + margin: 0 auto 20px; box-shadow: 0 8px 32px rgba(88,101,242,.25); +} +.totp-icon svg { width: 32px; height: 32px; color: white; } +.totp-title { text-align: center; font-size: 22px; font-weight: 700; color: white; margin-bottom: 6px; } +.totp-subtitle { text-align: center; font-size: 14px; color: var(--text-muted); margin-bottom: 24px; line-height: 1.5; } +.totp-inputs { + display: flex; gap: 8px; justify-content: center; margin-bottom: 24px; +} +.totp-digit { + width: 48px; height: 56px; border-radius: var(--radius-md); + background: var(--bg-input); border: 2px solid var(--border); + color: white; font-size: 24px; font-weight: 700; + text-align: center; transition: border-color .2s; + font-family: var(--font-mono); +} +.totp-digit:focus { border-color: var(--accent); } +.totp-digit.filled { border-color: var(--accent); } +.totp-back { + display: block; text-align: center; margin-top: 16px; + font-size: 13px; color: var(--text-faint); cursor: pointer; +} +.totp-back:hover { color: var(--text-link); } +.totp-backup-link { + display: block; text-align: center; margin-top: 8px; + font-size: 12px; color: var(--text-micro); cursor: pointer; +} +.totp-backup-link:hover { color: var(--text-link); text-decoration: underline; } + +/* ═══════════════════════════════════════════ + MODALS (shared) + ═══════════════════════════════════════════ */ +.modal-overlay { + position: fixed; inset: 0; background: var(--bg-overlay); + display: none; align-items: center; justify-content: center; + z-index: 100; +} +.modal-overlay.visible { display: flex; } +.modal { + background: var(--bg-primary); border-radius: var(--radius-md); + width: 440px; max-height: 80vh; overflow-y: auto; + box-shadow: 0 8px 48px rgba(0,0,0,.5); + animation: modalSlideIn .3s cubic-bezier(.16,1,.3,1); +} +.modal-header { + padding: 20px 24px 0; display: flex; + align-items: center; justify-content: space-between; +} +.modal-header h3 { font-size: 18px; font-weight: 700; color: white; } +.modal-close { + background: transparent; color: var(--text-faint); + font-size: 20px; padding: 4px; border-radius: var(--radius-sm); + transition: color .15s; +} +.modal-close:hover { color: var(--text-normal); } +.modal-body { padding: 20px 24px; } +.modal-footer { + padding: 16px 24px; background: var(--bg-secondary); + border-radius: 0 0 var(--radius-md) var(--radius-md); + display: flex; justify-content: flex-end; gap: 8px; +} +.btn-cancel { + padding: 8px 16px; border-radius: var(--radius-sm); + background: transparent; color: var(--text-muted); + font-size: 13px; transition: all .15s; +} +.btn-cancel:hover { color: var(--text-normal); text-decoration: underline; } +.btn-modal-save { + padding: 8px 20px; border-radius: var(--radius-sm); + background: var(--accent); color: white; + font-size: 13px; font-weight: 600; transition: background .15s; +} +.btn-modal-save:hover { background: var(--accent-hover); } +.modal-danger-text { font-size: 14px; color: var(--text-muted); line-height: 1.6; margin-bottom: 8px; } +.modal-danger-text strong { color: white; } +.btn-danger { + padding: 8px 20px; border-radius: var(--radius-sm); + background: var(--red); color: white; + font-size: 13px; font-weight: 600; transition: background .15s; +} +.btn-danger:hover { background: #d83135; } + +/* ═══════════════════════════════════════════ + CERTIFICATE TRUST DIALOG + ═══════════════════════════════════════════ */ +.cert-warning { + width: 64px; height: 64px; border-radius: 50%; + background: rgba(240,178,50,.12); display: flex; + align-items: center; justify-content: center; + margin: 0 auto 16px; +} +.cert-warning svg { width: 32px; height: 32px; color: var(--yellow); } +.cert-title { text-align: center; font-size: 18px; font-weight: 700; color: white; margin-bottom: 8px; } +.cert-desc { text-align: center; font-size: 13px; color: var(--text-muted); margin-bottom: 20px; line-height: 1.5; } +.cert-details { + background: var(--bg-tertiary); border-radius: var(--radius-sm); + padding: 14px 16px; margin-bottom: 16px; font-size: 12px; +} +.cert-row { + display: flex; padding: 4px 0; +} +.cert-label { color: var(--text-faint); width: 90px; flex-shrink: 0; } +.cert-value { color: var(--text-normal); font-family: var(--font-mono); font-size: 11px; word-break: break-all; } +.cert-fingerprint { color: var(--yellow); } +.cert-actions { + display: flex; gap: 8px; justify-content: center; margin-top: 8px; +} +.btn-ghost { + padding: 8px 16px; border-radius: var(--radius-sm); + background: var(--bg-hover); color: var(--text-muted); + font-size: 13px; transition: all .15s; +} +.btn-ghost:hover { background: var(--bg-active); color: var(--text-normal); } + +/* ═══════════════════════════════════════════ + SETTINGS OVERLAY (full-screen, like Discord) + ═══════════════════════════════════════════ */ +.settings-overlay { + position: fixed; inset: 0; background: var(--bg-primary); + display: none; z-index: 300; animation: settingsFadeIn .2s; +} +.settings-overlay.visible { display: flex; } +.settings-layout { + display: flex; width: 100%; height: 100%; +} +.settings-nav { + width: 220px; background: var(--bg-secondary); + padding: 60px 8px 20px; overflow-y: auto; flex-shrink: 0; + display: flex; flex-direction: column; +} +.settings-nav-title { + font-size: 11px; font-weight: 700; color: var(--text-faint); + letter-spacing: .05em; text-transform: uppercase; + padding: 8px 12px 4px; margin-top: 8px; +} +.settings-nav-title:first-child { margin-top: 0; } +.settings-nav-item { + padding: 8px 12px; border-radius: var(--radius-sm); + font-size: 14px; color: var(--text-muted); + cursor: pointer; transition: all .15s; + display: flex; align-items: center; gap: 8px; + background: transparent; text-align: left; width: 100%; +} +.settings-nav-item:hover { background: var(--bg-hover); color: var(--text-normal); } +.settings-nav-item.active { background: var(--bg-active); color: white; } +.settings-nav-item svg { width: 16px; height: 16px; flex-shrink: 0; } +.settings-nav-sep { + height: 1px; background: var(--border); margin: 4px 12px; +} +.settings-content { + flex: 1; padding: 60px 40px 40px; overflow-y: auto; max-width: 740px; +} +.settings-close { + position: absolute; top: 16px; right: 16px; + width: 36px; height: 36px; border-radius: 50%; + border: 2px solid var(--border-strong); background: transparent; + color: var(--text-faint); display: flex; + align-items: center; justify-content: center; + font-size: 18px; transition: all .15s; z-index: 310; +} +.settings-close:hover { border-color: var(--text-normal); color: var(--text-normal); } +.settings-section-title { + font-size: 18px; font-weight: 700; color: white; margin-bottom: 20px; +} + +/* Settings form elements */ +.setting-row { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 0; border-bottom: 1px solid var(--border); +} +.setting-row:last-child { border-bottom: none; } +.setting-info { flex: 1; } +.setting-label { font-size: 14px; color: var(--text-normal); margin-bottom: 2px; } +.setting-desc { font-size: 12px; color: var(--text-faint); } +.setting-control { flex-shrink: 0; margin-left: 16px; } + +/* Toggle switch */ +.toggle { + width: 40px; height: 22px; border-radius: 11px; + background: var(--border-strong); cursor: pointer; + position: relative; transition: background .2s; +} +.toggle.on { background: var(--green); } +.toggle::after { + content: ''; position: absolute; + width: 16px; height: 16px; border-radius: 50%; + background: white; top: 3px; left: 3px; + transition: transform .2s; +} +.toggle.on::after { transform: translateX(18px); } + +/* Select dropdown */ +.setting-select { + padding: 6px 28px 6px 10px; border-radius: var(--radius-sm); + background: var(--bg-input); color: var(--text-normal); + border: 1px solid var(--border); font-size: 13px; + appearance: none; cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2380848e' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 8px center; + min-width: 180px; +} +.setting-select:hover { border-color: var(--border-strong); } +.setting-select:focus { border-color: var(--accent); } + +/* Slider */ +.setting-slider { + width: 160px; height: 6px; border-radius: 3px; + background: var(--border); appearance: none; cursor: pointer; +} +.setting-slider::-webkit-slider-thumb { + appearance: none; width: 16px; height: 16px; + border-radius: 50%; background: white; + box-shadow: 0 1px 4px rgba(0,0,0,.3); +} + +/* VU meter mockup */ +.vu-meter { + height: 6px; border-radius: 3px; background: var(--border); + overflow: hidden; margin-top: 8px; +} +.vu-meter-fill { + height: 100%; border-radius: 3px; background: var(--green); + transition: width .1s; +} + +/* Keybind capture */ +.keybind-btn { + padding: 6px 14px; border-radius: var(--radius-sm); + background: var(--bg-input); border: 1px solid var(--border); + color: var(--text-normal); font-size: 13px; + font-family: var(--font-mono); cursor: pointer; + transition: border-color .2s; min-width: 100px; text-align: center; +} +.keybind-btn:hover { border-color: var(--border-strong); } +.keybind-btn.listening { + border-color: var(--accent); color: var(--accent); + animation: pulse 1s ease-in-out infinite; +} + +/* Mic test */ +.mic-test-btn { + padding: 8px 16px; border-radius: var(--radius-sm); + background: var(--accent); color: white; font-size: 13px; + font-weight: 600; transition: background .15s; +} +.mic-test-btn:hover { background: var(--accent-hover); } +.mic-test-btn.active { background: var(--red); } +.mic-test-btn.active:hover { background: #d83135; } + +/* About section */ +.about-logo { text-align: center; margin-bottom: 24px; } +.about-version { + text-align: center; font-size: 13px; color: var(--text-faint); margin-top: 8px; +} +.about-links { + display: flex; gap: 16px; justify-content: center; margin-top: 16px; +} +.about-link { + font-size: 13px; color: var(--text-link); cursor: pointer; + background: transparent; padding: 0; +} +.about-link:hover { text-decoration: underline; } + +/* ═══════════════════════════════════════════ + CONNECTION STATUS BAR + ═══════════════════════════════════════════ */ +.status-bar { + position: absolute; bottom: 0; left: 0; right: 0; + height: 4px; z-index: 50; overflow: hidden; display: none; +} +.status-bar.visible { display: block; } +.status-bar-fill { + height: 100%; background: var(--accent); + width: 0; box-shadow: 0 0 12px rgba(88,101,242,.25); +} +.status-bar.indeterminate .status-bar-fill { + width: 30%; animation: indeterminate 1.2s ease-in-out infinite; +} + +/* ═══════════════════════════════════════════ + CONNECTED OVERLAY + ═══════════════════════════════════════════ */ +.connected-overlay { + position: fixed; inset: 0; background: var(--bg-primary); + display: none; align-items: center; justify-content: center; + flex-direction: column; gap: 12px; z-index: 200; +} +.connected-overlay.visible { display: flex; } +.connected-icon-wrap { + position: relative; margin-bottom: 4px; +} +.connected-srv-icon { + width: 72px; height: 72px; border-radius: 20px; + display: flex; align-items: center; justify-content: center; + font-size: 28px; font-weight: 700; color: white; + animation: checkPop .4s cubic-bezier(.16,1,.3,1); +} +.connected-check-badge { + position: absolute; bottom: -4px; right: -4px; + width: 28px; height: 28px; border-radius: 50%; + background: var(--green); display: flex; + align-items: center; justify-content: center; + border: 3px solid var(--bg-primary); + animation: checkPop .5s cubic-bezier(.16,1,.3,1) .1s both; +} +.connected-check-badge svg { width: 14px; height: 14px; color: white; } +.connected-text { font-size: 20px; font-weight: 700; color: white; } +.connected-user { font-size: 14px; color: var(--text-muted); } +.connected-motd { + font-size: 13px; color: var(--text-faint); text-align: center; + max-width: 340px; line-height: 1.5; margin-top: 4px; + font-style: italic; +} +.connected-loader { + margin-top: 12px; display: flex; align-items: center; gap: 8px; + color: var(--text-micro); font-size: 12px; +} +.connected-loader .spinner { width: 14px; height: 14px; border-width: 1.5px; } + +/* ═══ Responsive ═══ */ +@media (max-width: 700px) { + .server-panel { width: 260px; } + .form-container { max-width: 340px; } +} + +/* ═══════════════════════════════════════════ + ANIMATIONS (@keyframes) + ═══════════════════════════════════════════ */ +@keyframes bgShift { + 0% { opacity: 1; transform: scale(1); } + 50% { opacity: .7; transform: scale(1.05); } + 100% { opacity: 1; transform: scale(1); } +} + +@keyframes pulse { + 0%,100% { opacity: 1; } + 50% { opacity: .3; } +} + +@keyframes formSlideIn { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes shakeX { + 0%,100% { transform: translateX(0); } + 20% { transform: translateX(-6px); } + 40% { transform: translateX(5px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(2px); } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes modalSlideIn { + from { opacity: 0; transform: translateY(24px) scale(.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes settingsFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(440%); } +} + +@keyframes checkPop { + from { transform: scale(0); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} diff --git a/Client/tauri-client/src/styles/tokens.css b/Client/tauri-client/src/styles/tokens.css new file mode 100644 index 00000000..d028d6bc --- /dev/null +++ b/Client/tauri-client/src/styles/tokens.css @@ -0,0 +1,60 @@ +/* ═══════════════════════════════════════════ + DESIGN TOKENS — extracted from ui-mockup.html + Single source of truth for all colors/spacing + ═══════════════════════════════════════════ */ + +:root { + /* Backgrounds */ + --bg-tertiary: #1e1f22; + --bg-secondary: #2b2d31; + --bg-primary: #313338; + --bg-input: #383a40; + --bg-hover: #35373c; + --bg-active: #404249; + --bg-overlay: rgba(0, 0, 0, 0.7); + + /* Accent */ + --accent: #5865f2; + --accent-hover: #4752c4; + --accent-active: #3c45a5; + + /* Text */ + --text-normal: #dbdee1; + --text-muted: #949ba4; + --text-faint: #80848e; + --text-micro: #6d6f78; + --text-link: #00a8fc; + + /* Status colors */ + --green: #23a55a; + --yellow: #f0b232; + --red: #f23f43; + + /* Borders */ + --border: #3f4147; + --border-strong: #4e5058; + + /* Role colors */ + --role-owner: #e74c3c; + --role-admin: #f39c12; + --role-mod: #2ecc71; + --role-member: #949ba4; + + /* Typography */ + --font-display: "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif; + --font-body: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; + --font-mono: "Cascadia Code", "Consolas", monospace; + + /* Radii */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 16px; + --radius-pill: 24px; + --radius-circle: 50%; + + /* Spacing */ + --strip-width: 72px; + --sidebar-width: 240px; + --members-width: 240px; + --header-height: 48px; +} diff --git a/Client/tauri-client/src/types/jitsi-rnnoise.d.ts b/Client/tauri-client/src/types/jitsi-rnnoise.d.ts new file mode 100644 index 00000000..89e2785c --- /dev/null +++ b/Client/tauri-client/src/types/jitsi-rnnoise.d.ts @@ -0,0 +1,4 @@ +declare module "@jitsi/rnnoise-wasm" { + export function createRNNWasmModule(): Promise<unknown>; + export function createRNNWasmModuleSync(): unknown; +} diff --git a/Client/tauri-client/tests/e2e/E2E-ISSUES.md b/Client/tauri-client/tests/e2e/E2E-ISSUES.md new file mode 100644 index 00000000..ac616b43 --- /dev/null +++ b/Client/tauri-client/tests/e2e/E2E-ISSUES.md @@ -0,0 +1,46 @@ +# E2E Test Status — 2026-03-18 + +## 209 tests: 209 passed (100%) + +All E2E tests now pass. Previous issues from 2026-03-15 have been resolved. + +## Resolved Issues + +### Fixed — Voice widget selector mismatches (2 tests) + +- `voice-widget.spec.ts:30` — Removed `.voice-users-list` assertion. + Voice users render in the sidebar (`VoiceChannel.ts`), not in VoiceWidget. +- `voice-widget.spec.ts:80` — Replaced `[data-testid='voice-user-3']` with + `.voice-user-item .vu-name` text matcher. VoiceChannel doesn't use + per-user data-testid attributes. + +### Previously fixed (2026-03-15 → 2026-03-17) + +| Root Cause | Tests Fixed | +| ---------- | ----------- | +| No channel auto-selected on login | ~35 tests | +| Settings overlay toggle broken | 24 tests | +| Quick Switcher Ctrl+K not wired | 9 tests | +| Voice widget stays hidden | 4 of 6 tests | +| Member list not rendering members | 7 tests | +| `.status-dot` selector mismatch | 1 test | + +## Anti-Flakiness Improvements (2026-03-18) + +- **Config**: Added `actionTimeout: 10s`, `navigationTimeout: 15s`, + local retry (1), video on first retry, JUnit XML reporter for CI +- **Helpers**: Added `waitForWsReady()`, `navigateToMainPageReady()`, + `emitWsMessageAndWait()` for timing-safe WS event testing +- **Patterns applied**: Web-first assertions, DOM-signal polling + instead of hardcoded delays, text content matchers over missing + data-testid attributes + +## Remaining Improvement Plan + +See `docs/brain/02-Tasks/PLAN-E2E-improvement.md` for Phases 2-6: + +- Phase 2: Add `data-testid` to 12 components +- Phase 3: Page Object helpers + dedup +- Phase 4: Strengthen assertions +- Phase 5: Toast coverage +- Phase 6: Migrate to `data-testid` selectors diff --git a/Client/tauri-client/tests/e2e/banners-toasts.spec.ts b/Client/tauri-client/tests/e2e/banners-toasts.spec.ts new file mode 100644 index 00000000..e87f52db --- /dev/null +++ b/Client/tauri-client/tests/e2e/banners-toasts.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPage, emitWsEvent, emitWsMessage } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Server Banner (reconnection) +// --------------------------------------------------------------------------- + +test.describe("Server Banner", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("reconnecting banner is hidden by default", async ({ page }) => { + const banner = page.locator(".reconnecting-banner"); + await expect(banner).toBeAttached(); + await expect(banner).not.toHaveClass(/visible/); + }); + + test("banner appears on WS disconnect", async ({ page }) => { + // Simulate WebSocket disconnection + await emitWsEvent(page, "ws-state", "closed"); + + const banner = page.locator(".reconnecting-banner.visible"); + await expect(banner).toBeVisible({ timeout: 5_000 }); + }); + + test("banner shows reconnecting text", async ({ page }) => { + await emitWsEvent(page, "ws-state", "closed"); + + const banner = page.locator(".reconnecting-banner.visible"); + await expect(banner).toBeVisible({ timeout: 5_000 }); + + const text = await banner.textContent(); + expect(text).toMatch(/reconnect/i); + }); + + test("banner disappears on WS reconnect", async ({ page }) => { + // Disconnect + await emitWsEvent(page, "ws-state", "closed"); + const banner = page.locator(".reconnecting-banner.visible"); + await expect(banner).toBeVisible({ timeout: 5_000 }); + + // Reconnect + await emitWsEvent(page, "ws-state", "open"); + + // Banner should hide after reconnection + const hiddenBanner = page.locator(".reconnecting-banner"); + await expect(hiddenBanner).not.toHaveClass(/visible/, { timeout: 5_000 }); + }); + + test("server_restart event shows restart countdown banner", async ({ page }) => { + await emitWsMessage(page, { + type: "server_restart", + payload: { reason: "update", delay_seconds: 30 }, + }); + + const banner = page.locator(".reconnecting-banner.visible"); + await expect(banner).toBeVisible({ timeout: 5_000 }); + + const text = await banner.textContent(); + expect(text).toMatch(/restart/i); + }); +}); diff --git a/Client/tauri-client/tests/e2e/channel-sidebar.spec.ts b/Client/tauri-client/tests/e2e/channel-sidebar.spec.ts new file mode 100644 index 00000000..e8c46767 --- /dev/null +++ b/Client/tauri-client/tests/e2e/channel-sidebar.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, mockTauriFullSessionWithMessages, navigateToMainPage } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Channel Sidebar +// --------------------------------------------------------------------------- + +test.describe("Channel Sidebar", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("sidebar is visible after login", async ({ page }) => { + const sidebar = page.locator("[data-testid='channel-sidebar']"); + await expect(sidebar).toBeVisible({ timeout: 5_000 }); + }); + + test("sidebar header shows server name", async ({ page }) => { + const header = page.locator(".channel-sidebar-header h2"); + await expect(header).toBeVisible(); + await expect(header).toHaveText("Test Server"); + }); + + test("channel list shows channels", async ({ page }) => { + const channelList = page.locator(".channel-list"); + await expect(channelList).toBeVisible(); + + const channels = page.locator(".channel-item"); + const count = await channels.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("channel items display channel name", async ({ page }) => { + const firstChannel = page.locator("[data-testid='channel-1']"); + await expect(firstChannel).toBeVisible(); + + const name = firstChannel.locator(".ch-name"); + await expect(name).toBeVisible(); + }); + + test("channel items have hash icon", async ({ page }) => { + const firstChannel = page.locator("[data-testid='channel-1']"); + const icon = firstChannel.locator(".ch-icon"); + await expect(icon).toBeVisible(); + }); + + test("clicking a channel marks it as active", async ({ page }) => { + // Mock has 2 channels (general, random) + const secondChannel = page.locator("[data-testid='channel-2']"); + await expect(secondChannel).toBeVisible({ timeout: 3000 }); + await secondChannel.click(); + + await expect(secondChannel).toHaveClass(/active/); + }); + + test("clicking a channel updates chat header", async ({ page }) => { + const secondChannel = page.locator("[data-testid='channel-2']"); + await expect(secondChannel).toBeVisible({ timeout: 3000 }); + const channelName = await secondChannel.locator(".ch-name").textContent(); + + await secondChannel.click(); + + const headerName = page.locator("[data-testid='chat-header-name']"); + await expect(headerName).toHaveText(channelName ?? ""); + }); + + test("switching channels re-mounts message container", async ({ page }) => { + // Verify first channel is active and messages container exists + const messagesContainer = page.locator(".messages-container"); + await expect(messagesContainer).toBeVisible({ timeout: 5000 }); + + // Switch to second channel + const secondChannel = page.locator("[data-testid='channel-2']"); + await expect(secondChannel).toBeVisible({ timeout: 3000 }); + await secondChannel.click(); + + // Messages container should still be present (re-mounted for new channel) + await expect(messagesContainer).toBeVisible({ timeout: 5000 }); + + // Chat header should reflect the new channel + const headerName = page.locator("[data-testid='chat-header-name']"); + const channelName = await secondChannel.locator(".ch-name").textContent(); + await expect(headerName).toHaveText(channelName ?? ""); + }); + + test("first channel is active by default", async ({ page }) => { + const firstChannel = page.locator("[data-testid='channel-1']"); + await expect(firstChannel).toHaveClass(/active/); + }); +}); + +test.describe("Channel Sidebar — Categories", () => { + test("categories with multiple channel types show correctly", async ({ page }) => { + await mockTauriFullSessionWithMessages(page); + await page.goto("/"); + await navigateToMainPage(page); + + const categories = page.locator(".category"); + const count = await categories.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/Client/tauri-client/tests/e2e/channel-switch-messages.spec.ts b/Client/tauri-client/tests/e2e/channel-switch-messages.spec.ts new file mode 100644 index 00000000..5744a142 --- /dev/null +++ b/Client/tauri-client/tests/e2e/channel-switch-messages.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from "@playwright/test"; +import { + mockTauriFullSessionWithMessages, + navigateToMainPage, + emitWsMessage, + MOCK_CHANNELS_WITH_CATEGORIES, +} from "./helpers"; + +test.describe("Channel Switch — Messages", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithMessages(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("switching channels updates header and clears messages container", async ({ page }) => { + // Verify we start on the first channel + const headerName = page.locator(".chat-header .ch-name"); + await expect(headerName).toHaveText("general"); + + // Wait for messages to load + await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 }); + + // Click the second channel + const secondChannel = page.locator(".channel-item").nth(1); + await secondChannel.click(); + + // Header should update + await expect(headerName).toHaveText("random"); + }); + + test("switching to a channel and back preserves messages", async ({ page }) => { + await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 }); + + // Remember initial message count + const initialCount = await page.locator(".message").count(); + expect(initialCount).toBeGreaterThanOrEqual(1); + + // Switch to second channel + const channels = page.locator(".channel-item"); + await channels.nth(1).click(); + await expect(page.locator(".chat-header .ch-name")).toHaveText("random"); + + // Switch back + await channels.first().click(); + await expect(page.locator(".chat-header .ch-name")).toHaveText("general"); + + // Messages should still be there (loaded from cache) + await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 }); + }); + + test("new message on inactive channel does not appear in current view", async ({ page }) => { + await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 }); + const countBefore = await page.locator(".message").count(); + + // Send a message to channel 2 (random) while we're viewing channel 1 (general) + await emitWsMessage(page, { + type: "chat_message", + payload: { + id: 500, + channel_id: 2, + user: { id: 2, username: "otheruser", avatar: "" }, + content: "Message on other channel", + timestamp: new Date().toISOString(), + attachments: [], + reply_to: null, + }, + }); + + // Wait for the unread badge to confirm the event was processed + const secondChannel = page.locator(".channel-item").nth(1); + await expect(secondChannel.locator(".unread-badge")).toBeVisible({ timeout: 5_000 }); + + // Message count on current channel should not change + const countAfter = await page.locator(".message").count(); + expect(countAfter).toBe(countBefore); + + // The message should NOT be visible in current view + await expect( + page.locator(".msg-text", { hasText: "Message on other channel" }) + ).not.toBeVisible(); + }); + + test("unread badge appears on channel with new message", async ({ page }) => { + await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 }); + + // Send a message to the non-active channel + await emitWsMessage(page, { + type: "chat_message", + payload: { + id: 501, + channel_id: 2, + user: { id: 2, username: "otheruser", avatar: "" }, + content: "Unread message", + timestamp: new Date().toISOString(), + attachments: [], + reply_to: null, + }, + }); + + // The non-active channel should show an unread badge + const secondChannel = page.locator(".channel-item").nth(1); + const badge = secondChannel.locator(".unread-badge"); + await expect(badge).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/chat-header.spec.ts b/Client/tauri-client/tests/e2e/chat-header.spec.ts new file mode 100644 index 00000000..9f6f4d92 --- /dev/null +++ b/Client/tauri-client/tests/e2e/chat-header.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPage } from "./helpers"; + +test.describe("Chat Header", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("renders channel info with hash, name, tools, and search", async ({ page }) => { + const header = page.locator("[data-testid='chat-header']"); + await expect(header).toBeVisible(); + await expect(header.locator(".ch-hash")).toBeVisible(); + const name = page.locator("[data-testid='chat-header-name']"); + await expect(name).toBeVisible(); + await expect(name).not.toBeEmpty(); + await expect(header.locator(".ch-topic")).toBeAttached(); + await expect(header.locator(".ch-tools")).toBeVisible(); + await expect(header.locator(".ch-tools .search-input")).toBeAttached(); + }); + + test("members toggle button hides and shows member list", async ({ page }) => { + const membersToggle = page.locator("[data-testid='members-toggle']"); + await expect(membersToggle).toBeVisible(); + + const memberList = page.locator("[data-testid='member-list']"); + await expect(memberList).toBeVisible({ timeout: 3000 }); + + await membersToggle.click(); + await expect(memberList).not.toBeVisible({ timeout: 3000 }); + + await membersToggle.click(); + await expect(memberList).toBeVisible({ timeout: 3000 }); + }); + + test("search input expands on focus and collapses on blur", async ({ page }) => { + const search = page.locator(".ch-tools .search-input"); + await expect(search).toBeAttached(); + + // Focus the search — should trigger CSS width expansion + await search.focus(); + await expect(search).toBeFocused(); + + // Type something to verify it accepts input + await search.fill("test query"); + await expect(search).toHaveValue("test query"); + + // Blur and verify value persists + await search.blur(); + await expect(search).toHaveValue("test query"); + }); + + test("pin button opens pinned messages panel", async ({ page }) => { + const pinBtn = page.locator("[data-testid='pin-btn']"); + await expect(pinBtn).toBeVisible(); + + await pinBtn.click(); + + const pinnedPanel = page.locator(".pinned-panel"); + await expect(pinnedPanel).toBeVisible({ timeout: 3000 }); + + // Close it + const closeBtn = pinnedPanel.locator(".pinned-panel__close"); + await closeBtn.click(); + await expect(pinnedPanel).not.toBeAttached({ timeout: 3000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/connect-page.spec.ts b/Client/tauri-client/tests/e2e/connect-page.spec.ts new file mode 100644 index 00000000..8fa11141 --- /dev/null +++ b/Client/tauri-client/tests/e2e/connect-page.spec.ts @@ -0,0 +1,242 @@ +import { test, expect } from "@playwright/test"; +import { + mockTauriConnect, + mockTauriConnectWith2FA, + mockTauriLoginError, + mockTauriFullSession, + submitLogin, +} from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Connect Page — core +// --------------------------------------------------------------------------- + +test.describe("Connect Page", () => { + test.beforeEach(async ({ page }) => { + await mockTauriConnect(page); + await page.goto("/"); + }); + + test("page loads and shows the connect page", async ({ page }) => { + const connectPage = page.locator(".connect-page"); + await expect(connectPage).toBeVisible(); + }); + + test("server profile list is visible", async ({ page }) => { + const serverList = page.locator(".server-list"); + await expect(serverList).toBeVisible(); + + const serverItem = page.locator(".server-item").first(); + await expect(serverItem).toBeVisible(); + await expect(serverItem.locator(".srv-name")).toHaveText("Local Server"); + await expect(serverItem.locator(".srv-host")).toHaveText("localhost:8443"); + }); + + test("login form has host, username, password fields", async ({ page }) => { + const hostInput = page.locator("#host"); + const usernameInput = page.locator("#username"); + const passwordInput = page.locator("#password"); + + await expect(hostInput).toBeVisible(); + await expect(usernameInput).toBeVisible(); + await expect(passwordInput).toBeVisible(); + + await expect(hostInput).toHaveAttribute("placeholder", "localhost:8443"); + await expect(passwordInput).toHaveAttribute("type", "password"); + }); + + test("form validation shows error for empty fields", async ({ page }) => { + const hostInput = page.locator("#host"); + await hostInput.fill(""); + + const submitBtn = page.locator("button.btn-primary[type='submit']"); + await submitBtn.click(); + + const errorBanner = page.locator(".error-banner.visible"); + await expect(errorBanner).toBeVisible(); + await expect(errorBanner).toHaveText(/required/i); + }); + + test("login/register toggle switches form mode", async ({ page }) => { + const toggleLink = page.locator(".form-switch a"); + await expect(toggleLink).toHaveText(/Register/); + + await toggleLink.click(); + await expect(toggleLink).toHaveText(/Login/); + + const inviteInput = page.locator("#invite"); + await expect(inviteInput).toBeVisible(); + + const submitBtnText = page.locator("button.btn-primary .btn-text"); + await expect(submitBtnText).toHaveText("Register"); + + await toggleLink.click(); + await expect(toggleLink).toHaveText(/Register/); + await expect(submitBtnText).toHaveText("Login"); + }); + + test("clicking server profile auto-fills host field", async ({ page }) => { + const serverItem = page.locator(".server-item").first(); + await serverItem.click(); + + const hostInput = page.locator("#host"); + await expect(hostInput).toHaveValue("localhost:8443"); + }); + + test("password toggle button shows/hides password", async ({ page }) => { + const passwordInput = page.locator("#password"); + await passwordInput.fill("secret123"); + await expect(passwordInput).toHaveAttribute("type", "password"); + + const toggleBtn = page.locator(".password-toggle"); + await toggleBtn.click(); + + await expect(passwordInput).toHaveAttribute("type", "text"); + + await toggleBtn.click(); + await expect(passwordInput).toHaveAttribute("type", "password"); + }); + + test("form shows loading state on submit", async ({ page }) => { + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("testuser"); + await page.locator("#password").fill("password123"); + + const submitBtn = page.locator("button.btn-primary[type='submit']"); + await submitBtn.click(); + + // Button should show loading state (spinner visible or loading class) + const spinner = page.locator("button.btn-primary .spinner"); + await expect(spinner).toBeAttached(); + }); + + test("settings gear button is visible", async ({ page }) => { + const settingsGear = page.locator(".settings-gear"); + await expect(settingsGear).toBeVisible(); + }); + + test("server panel header displays Servers title", async ({ page }) => { + const header = page.locator(".server-panel-header"); + await expect(header).toBeVisible(); + }); + + test("form logo shows OwnCord branding", async ({ page }) => { + const logo = page.locator(".form-logo"); + await expect(logo).toBeVisible(); + + const logoMark = page.locator(".form-logo-mark"); + await expect(logoMark).toBeVisible(); + }); + + test("status bar exists at bottom of form", async ({ page }) => { + const statusBar = page.locator(".status-bar"); + await expect(statusBar).toBeAttached(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Login Error +// --------------------------------------------------------------------------- + +test.describe("Connect Page — Login Error", () => { + test("shows error banner on failed login", async ({ page }) => { + await mockTauriLoginError(page); + await page.goto("/"); + + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("testuser"); + await page.locator("#password").fill("wrongpassword"); + await page.locator("button.btn-primary[type='submit']").click(); + + const errorBanner = page.locator(".error-banner.visible"); + await expect(errorBanner).toBeVisible({ timeout: 10_000 }); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: TOTP Flow +// --------------------------------------------------------------------------- + +test.describe("Connect Page — TOTP", () => { + test("TOTP overlay appears when login requires 2FA", async ({ page }) => { + await mockTauriConnectWith2FA(page); + await page.goto("/"); + + const totpOverlay = page.locator(".totp-overlay"); + await expect(totpOverlay).toHaveClass(/totp-overlay--hidden/); + + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("testuser"); + await page.locator("#password").fill("password123"); + await page.locator("button.btn-primary[type='submit']").click(); + + await expect(totpOverlay).not.toHaveClass(/totp-overlay--hidden/, { + timeout: 10_000, + }); + + const totpInput = totpOverlay.locator("input[inputmode='numeric']"); + await expect(totpInput).toBeVisible(); + + const verifyBtn = totpOverlay.locator("button.btn-primary"); + await expect(verifyBtn).toHaveText("Verify"); + }); + + test("TOTP back button cancels 2FA flow", async ({ page }) => { + await mockTauriConnectWith2FA(page); + await page.goto("/"); + + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("testuser"); + await page.locator("#password").fill("password123"); + await page.locator("button.btn-primary[type='submit']").click(); + + const totpOverlay = page.locator(".totp-overlay"); + await expect(totpOverlay).not.toHaveClass(/totp-overlay--hidden/, { + timeout: 10_000, + }); + + const backBtn = totpOverlay.locator(".totp-back"); + await backBtn.click(); + + await expect(totpOverlay).toHaveClass(/totp-overlay--hidden/); + }); + + test("TOTP overlay shows title and subtitle", async ({ page }) => { + await mockTauriConnectWith2FA(page); + await page.goto("/"); + + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("testuser"); + await page.locator("#password").fill("password123"); + await page.locator("button.btn-primary[type='submit']").click(); + + const totpOverlay = page.locator(".totp-overlay"); + await expect(totpOverlay).not.toHaveClass(/totp-overlay--hidden/, { + timeout: 10_000, + }); + + const title = totpOverlay.locator(".totp-title"); + await expect(title).toBeVisible(); + + const subtitle = totpOverlay.locator(".totp-subtitle"); + await expect(subtitle).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Full Login → Connected Overlay +// --------------------------------------------------------------------------- + +test.describe("Connect Page — Login Success", () => { + test("after login, connected overlay appears then main page renders", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await submitLogin(page); + + const overlay = page.locator(".connected-overlay"); + await expect(overlay).toBeVisible({ timeout: 10_000 }); + + const appLayout = page.locator(".app"); + await expect(appLayout).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/connect-settings.spec.ts b/Client/tauri-client/tests/e2e/connect-settings.spec.ts new file mode 100644 index 00000000..b42786f7 --- /dev/null +++ b/Client/tauri-client/tests/e2e/connect-settings.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from "@playwright/test"; +import { buildTauriMockScript } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Settings Overlay from Connect Page +// --------------------------------------------------------------------------- + +test.describe("Connect Page — Settings Overlay", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + ], + simulateWsFlow: false, + })); + await page.goto("/"); + }); + + test("gear button is visible on the connect page", async ({ page }) => { + const gearBtn = page.locator(".settings-gear"); + await expect(gearBtn).toBeVisible(); + }); + + test("clicking gear button opens settings overlay", async ({ page }) => { + const gearBtn = page.locator(".settings-gear"); + await gearBtn.click(); + + const overlay = page.locator(".settings-overlay.open"); + await expect(overlay).toBeVisible({ timeout: 5_000 }); + }); + + test("closing settings overlay works via close button", async ({ page }) => { + const gearBtn = page.locator(".settings-gear"); + await gearBtn.click(); + + const overlay = page.locator(".settings-overlay.open"); + await expect(overlay).toBeVisible({ timeout: 5_000 }); + + const closeBtn = page.locator(".settings-close-btn"); + await closeBtn.click(); + + await expect(overlay).not.toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/connected-overlay.spec.ts b/Client/tauri-client/tests/e2e/connected-overlay.spec.ts new file mode 100644 index 00000000..95503ce5 --- /dev/null +++ b/Client/tauri-client/tests/e2e/connected-overlay.spec.ts @@ -0,0 +1,47 @@ +/** + * E2E tests for the ConnectedOverlay component. + * Covers: overlay appears after login, shows server info, spinner → "Ready!" transition. + */ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, submitLogin, navigateToMainPage } from "./helpers"; + +test.describe("Connected Overlay", () => { + test("overlay appears after login with server info", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await submitLogin(page); + + const overlay = page.locator("[data-testid='connected-overlay']"); + await expect(overlay).toBeVisible({ timeout: 5000 }); + + const connectedText = page.locator(".connected-text"); + await expect(connectedText).toHaveText("Connected!"); + + const userText = page.locator(".connected-user"); + await expect(userText).toContainText("testuser"); + + const serverIcon = page.locator(".connected-srv-icon"); + await expect(serverIcon).toBeVisible({ timeout: 5000 }); + }); + + test("overlay shows loader area during connection", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await submitLogin(page); + + // The loader area is always present in the overlay; spinner may be hidden + // after ready fires (mock ready arrives at ~200ms), so just verify the + // loader element is part of the overlay DOM. + const loader = page.locator(".connected-loader"); + await expect(loader).toBeAttached({ timeout: 5000 }); + }); + + test("overlay transitions to main page after ready", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + + const app = page.locator("[data-testid='app-layout']"); + await expect(app).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/emoji-insertion.spec.ts b/Client/tauri-client/tests/e2e/emoji-insertion.spec.ts new file mode 100644 index 00000000..7b9eccdf --- /dev/null +++ b/Client/tauri-client/tests/e2e/emoji-insertion.spec.ts @@ -0,0 +1,67 @@ +/** + * E2E tests for emoji picker insertion into the message textarea. + * Covers: clicking emoji inserts it, picker closes after selection. + */ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPage } from "./helpers"; + +test.describe("Emoji Picker — Insert into textarea", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("clicking an emoji inserts it into the textarea", async ({ page }) => { + const textarea = page.locator("[data-testid='msg-textarea']"); + const initialValue = await textarea.inputValue(); + + // Open emoji picker + await page.locator(".emoji-btn").click(); + const picker = page.locator(".emoji-picker.open"); + await expect(picker).toBeVisible({ timeout: 3000 }); + + // Click the first emoji + const firstEmoji = picker.locator(".ep-emoji").first(); + const emojiText = await firstEmoji.textContent(); + await firstEmoji.click(); + + // Textarea should now contain the emoji + const newValue = await textarea.inputValue(); + expect(newValue.length).toBeGreaterThan(initialValue.length); + if (emojiText) { + expect(newValue).toContain(emojiText); + } + }); + + test("emoji picker closes after selecting an emoji", async ({ page }) => { + await page.locator(".emoji-btn").click(); + const picker = page.locator(".emoji-picker.open"); + await expect(picker).toBeVisible({ timeout: 3000 }); + + // Click an emoji + await picker.locator(".ep-emoji").first().click(); + + // Picker should close + await expect(picker).not.toBeVisible({ timeout: 3000 }); + }); + + test("multiple emojis can be selected by reopening picker", async ({ page }) => { + const textarea = page.locator("[data-testid='msg-textarea']"); + + // First emoji + await page.locator(".emoji-btn").click(); + await page.locator(".emoji-picker.open .ep-emoji").first().click(); + const afterFirst = await textarea.inputValue(); + expect(afterFirst.length).toBeGreaterThan(0); + + // Second emoji + await page.locator(".emoji-btn").click(); + const picker = page.locator(".emoji-picker.open"); + await expect(picker).toBeVisible({ timeout: 3000 }); + await picker.locator(".ep-emoji").nth(1).click(); + + const afterSecond = await textarea.inputValue(); + expect(afterSecond.length).toBeGreaterThan(afterFirst.length); + }); +}); diff --git a/Client/tauri-client/tests/e2e/health-status.spec.ts b/Client/tauri-client/tests/e2e/health-status.spec.ts new file mode 100644 index 00000000..4185cfbc --- /dev/null +++ b/Client/tauri-client/tests/e2e/health-status.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "@playwright/test"; +import { buildTauriMockScript } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Health Status Indicator +// --------------------------------------------------------------------------- + +test.describe("Health Status Indicator", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + ], + simulateWsFlow: false, + })); + await page.goto("/"); + }); + + test("status dot element exists on page load", async ({ page }) => { + const statusDot = page.locator(".srv-status-dot").first(); + await expect(statusDot).toBeAttached(); + }); + + test("status dot gets a non-unknown class after health check resolves", async ({ page }) => { + const statusDot = page.locator(".srv-status-dot").first(); + + // Wait for the health check to resolve and update the dot class + // The dot starts as "srv-status-dot unknown", then transitions to + // "srv-status-dot checking", and finally to "srv-status-dot online" (or "slow") + await expect(statusDot).not.toHaveClass(/\bunknown\b/, { timeout: 10_000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/helpers.ts b/Client/tauri-client/tests/e2e/helpers.ts new file mode 100644 index 00000000..68c99252 --- /dev/null +++ b/Client/tauri-client/tests/e2e/helpers.ts @@ -0,0 +1,730 @@ +/** + * Shared E2E test helpers — Tauri mock injection for browser-based testing. + * + * The app uses Tauri IPC through __TAURI_INTERNALS__.invoke: + * - HTTP: plugin:http|fetch → plugin:http|fetch_send → plugin:http|fetch_read_body + * - WS: ws_connect, ws_send, ws_disconnect + events ws-state, ws-message + * - Events: plugin:event|listen, plugin:event|unlisten + */ + +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Mock data — basic +// --------------------------------------------------------------------------- + +export const MOCK_TOKEN = "mock-session-token-abc123"; + +export const MOCK_LOGIN_RESPONSE = { + token: MOCK_TOKEN, + requires_2fa: false, +}; + +export const MOCK_LOGIN_2FA_RESPONSE = { + requires_2fa: true, + partial_token: "mock-partial-token", +}; + +export const MOCK_CHANNELS = [ + { id: 1, name: "general", type: "text", position: 0, category: null }, + { id: 2, name: "random", type: "text", position: 1, category: null }, +]; + +export const MOCK_MESSAGES = { + messages: [ + { + id: 101, + channel_id: 1, + user: { id: 1, username: "testuser", avatar: "" }, + content: "Hello world!", + timestamp: "2026-03-15T10:00:00Z", + edited_at: null, + attachments: [], + reactions: [], + reply_to: null, + pinned: false, + deleted: false, + }, + ], + has_more: false, +}; + +export const MOCK_ROLES = [ + { id: 1, name: "admin", color: "#ff0000", permissions: 0x40000000 }, + { id: 2, name: "moderator", color: "#00aaff", permissions: 0x1000000 }, + { id: 3, name: "member", color: null, permissions: 0x3 }, +]; + +export const MOCK_READY_PAYLOAD = { + type: "ready", + payload: { + channels: MOCK_CHANNELS, + members: [ + { id: 1, username: "testuser", avatar: "", status: "online", role: "admin" }, + { id: 2, username: "otheruser", avatar: "", status: "online", role: "member" }, + ], + voice_states: [], + roles: MOCK_ROLES, + }, +}; + +export const MOCK_AUTH_OK = { + type: "auth_ok", + payload: { + user: { id: 1, username: "testuser", avatar: "", role: "admin" }, + server_name: "Test Server", + motd: "Welcome to the test server", + }, +}; + +// --------------------------------------------------------------------------- +// Mock data — rich (for extended tests) +// --------------------------------------------------------------------------- + +export const MOCK_CHANNELS_WITH_CATEGORIES = [ + { id: 1, name: "general", type: "text", position: 0, category: "Text Channels" }, + { id: 2, name: "random", type: "text", position: 1, category: "Text Channels" }, + { id: 3, name: "announcements", type: "text", position: 2, category: "Information" }, + { id: 10, name: "Voice Chat", type: "voice", position: 3, category: "Voice Channels" }, + { id: 11, name: "Music", type: "voice", position: 4, category: "Voice Channels" }, +]; + +export const MOCK_MEMBERS_MULTI_ROLE = [ + { id: 1, username: "testuser", avatar: "", status: "online", role: "admin" }, + { id: 2, username: "moderator1", avatar: "", status: "online", role: "moderator" }, + { id: 3, username: "member1", avatar: "", status: "idle", role: "member" }, + { id: 4, username: "member2", avatar: "", status: "dnd", role: "member" }, + { id: 5, username: "offlineuser", avatar: "", status: "offline", role: "member" }, +]; + +export const MOCK_MESSAGES_RICH = { + messages: [ + { + id: 101, + channel_id: 1, + user: { id: 1, username: "testuser", avatar: "" }, + content: "Hello world!", + timestamp: "2026-03-15T10:00:00Z", + edited_at: null, + attachments: [], + reactions: [], + reply_to: null, + pinned: false, + deleted: false, + }, + { + id: 102, + channel_id: 1, + user: { id: 2, username: "otheruser", avatar: "" }, + content: "Hey @testuser, check this out!", + timestamp: "2026-03-15T10:01:00Z", + edited_at: null, + attachments: [], + reactions: [{ emoji: "\uD83D\uDC4D", count: 2, me: true }], + reply_to: null, + pinned: false, + deleted: false, + }, + { + id: 103, + channel_id: 1, + user: { id: 2, username: "otheruser", avatar: "" }, + content: "```js\nconsole.log('code block');\n```", + timestamp: "2026-03-15T10:01:30Z", + edited_at: null, + attachments: [], + reactions: [], + reply_to: null, + pinned: false, + deleted: false, + }, + { + id: 104, + channel_id: 1, + user: { id: 1, username: "testuser", avatar: "" }, + content: "Replying to your message", + timestamp: "2026-03-15T10:02:00Z", + edited_at: "2026-03-15T10:02:30Z", + attachments: [], + reactions: [], + reply_to: 102, + pinned: false, + deleted: false, + }, + { + id: 105, + channel_id: 1, + user: { id: 3, username: "member1", avatar: "" }, + content: "Check this image", + timestamp: "2026-03-15T10:03:00Z", + edited_at: null, + attachments: [ + { id: "1", filename: "screenshot.png", size: 102400, mime: "image/png", url: "/uploads/screenshot.png" }, + ], + reactions: [], + reply_to: null, + pinned: false, + deleted: false, + }, + { + id: 106, + channel_id: 1, + user: { id: 3, username: "member1", avatar: "" }, + content: "And this document", + timestamp: "2026-03-15T10:03:30Z", + edited_at: null, + attachments: [ + { id: "2", filename: "report.pdf", size: 512000, mime: "application/pdf", url: "/uploads/report.pdf" }, + ], + reactions: [], + reply_to: null, + pinned: false, + deleted: false, + }, + ], + has_more: true, +}; + +export const MOCK_VOICE_STATE = [ + { user_id: 1, channel_id: 10, muted: false, deafened: false }, + { user_id: 2, channel_id: 10, muted: true, deafened: false }, +]; + +export const MOCK_PINNED_MESSAGES = { + messages: [ + { + id: 101, + channel_id: 1, + user: { id: 1, username: "testuser", avatar: "" }, + content: "Hello world!", + timestamp: "2026-03-15T10:00:00Z", + pinned: true, + edited_at: null, + deleted: false, + reply_to: null, + attachments: [], + reactions: [], + }, + ], + has_more: false, +}; + +export const MOCK_INVITES = [ + { + id: 1, + code: "abc123", + url: "https://localhost:8443/invite/abc123", + use_count: 3, + max_uses: 10, + expires_at: "2026-04-15T00:00:00Z", + }, + { + id: 2, + code: "xyz789", + url: "https://localhost:8443/invite/xyz789", + use_count: 0, + max_uses: 1, + expires_at: null, + }, +]; + +// --------------------------------------------------------------------------- +// Ready payload builders +// --------------------------------------------------------------------------- + +function buildReadyPayload(overrides?: { + channels?: unknown[]; + members?: unknown[]; + voice_states?: unknown[]; + roles?: unknown[]; +}): unknown { + return { + type: "ready", + payload: { + channels: overrides?.channels ?? MOCK_CHANNELS, + members: overrides?.members ?? MOCK_READY_PAYLOAD.payload.members, + voice_states: overrides?.voice_states ?? [], + roles: overrides?.roles ?? MOCK_ROLES, + }, + }; +} + +// --------------------------------------------------------------------------- +// Tauri mock script builder +// --------------------------------------------------------------------------- + +export function buildTauriMockScript(opts: { + httpRoutes: Array<{ pattern: string; status: number; body: unknown }>; + simulateWsFlow: boolean; + echoChatSend?: boolean; + readyOverrides?: { + channels?: unknown[]; + members?: unknown[]; + voice_states?: unknown[]; + }; +}): string { + const readyPayload = buildReadyPayload(opts.readyOverrides); + + return ` + // ----------------------------------------------------------------------- + // Event system + // ----------------------------------------------------------------------- + const __eventListeners = {}; + let __callbackId = 0; + + function __tauriEmitEvent(eventName, payload) { + const listeners = __eventListeners[eventName] || []; + for (const { handler } of listeners) { + try { handler({ payload, event: eventName, id: 0 }); } + catch (e) { console.error("[tauri-mock] event error", eventName, e); } + } + } + window.__tauriEmitEvent = __tauriEmitEvent; + + // ----------------------------------------------------------------------- + // HTTP mock state + // ----------------------------------------------------------------------- + const HTTP_ROUTES = ${JSON.stringify(opts.httpRoutes)}; + let __nextRid = 1; + const __pendingFetch = {}; // rid → { url, route } + const __pendingBody = {}; // responseRid → Uint8Array (body bytes) + let __bodyRead = {}; // responseRid → boolean (already read) + + // Sort routes by pattern length (longest first) to match most specific route + HTTP_ROUTES.sort((a, b) => b.pattern.length - a.pattern.length); + + function matchRoute(url) { + for (const route of HTTP_ROUTES) { + if (url.includes(route.pattern)) return route; + } + return null; + } + + // ----------------------------------------------------------------------- + // __TAURI_INTERNALS__ + // ----------------------------------------------------------------------- + window.__TAURI_INTERNALS__ = { + metadata: { + currentWindow: { label: "main" }, + currentWebview: { label: "main" }, + }, + + transformCallback: (callback, once) => { + const id = __callbackId++; + if (typeof callback === "function") { + window["__tcb_" + id] = callback; + } + return id; + }, + + invoke: async (cmd, args) => { + // ---- Events ---- + if (cmd === "plugin:event|listen") { + const eventName = args?.event; + const handlerId = args?.handler; + const cb = window["__tcb_" + handlerId]; + if (eventName && cb) { + if (!__eventListeners[eventName]) __eventListeners[eventName] = []; + __eventListeners[eventName].push({ id: handlerId, handler: cb }); + } + return handlerId || 0; + } + if (cmd === "plugin:event|unlisten") return; + + // ---- HTTP: fetch (step 1 — register request, return rid) ---- + if (cmd === "plugin:http|fetch") { + const url = args?.clientConfig?.url || args?.url || ""; + const rid = __nextRid++; + const route = matchRoute(url); + __pendingFetch[rid] = { url, route }; + return rid; + } + + // ---- HTTP: fetch_send (step 2 — return status + headers) ---- + if (cmd === "plugin:http|fetch_send") { + const rid = args?.rid; + const pending = __pendingFetch[rid]; + delete __pendingFetch[rid]; + + const responseRid = __nextRid++; + + if (pending?.route) { + const bodyStr = JSON.stringify(pending.route.body); + const encoder = new TextEncoder(); + const bodyBytes = encoder.encode(bodyStr); + __pendingBody[responseRid] = bodyBytes; + __bodyRead[responseRid] = false; + + return { + status: pending.route.status, + statusText: pending.route.status === 200 ? "OK" : "Error", + url: pending.url, + headers: [["content-type", "application/json"]], + rid: responseRid, + }; + } + + // No matching route — 404 + const fallback = JSON.stringify({ error: "NOT_FOUND", message: "mocked 404" }); + const encoder = new TextEncoder(); + __pendingBody[responseRid] = encoder.encode(fallback); + __bodyRead[responseRid] = false; + return { + status: 404, + statusText: "Not Found", + url: pending?.url || "", + headers: [["content-type", "application/json"]], + rid: responseRid, + }; + } + + // ---- HTTP: fetch_read_body (step 3 — return body bytes) ---- + if (cmd === "plugin:http|fetch_read_body") { + const rid = args?.rid; + const body = __pendingBody[rid]; + + if (body && !__bodyRead[rid]) { + __bodyRead[rid] = true; + const result = Array.from(body); + result.push(0); // 0 = not end yet + return result; + } + + // End signal: [1] + delete __pendingBody[rid]; + delete __bodyRead[rid]; + return [1]; + } + + // ---- HTTP: cancel ---- + if (cmd === "plugin:http|fetch_cancel" || cmd === "plugin:http|fetch_cancel_body") { + return; + } + + // ---- WS commands ---- + if (cmd === "ws_connect") { + ${opts.simulateWsFlow ? ` + setTimeout(() => __tauriEmitEvent("ws-state", "open"), 100); + ` : ""} + return; + } + if (cmd === "ws_send") { + ${opts.simulateWsFlow ? ` + try { + const parsed = JSON.parse(args?.message || "{}"); + if (parsed.type === "auth") { + setTimeout(() => { + __tauriEmitEvent("ws-message", JSON.stringify(${JSON.stringify(MOCK_AUTH_OK)})); + }, 100); + setTimeout(() => { + __tauriEmitEvent("ws-message", JSON.stringify(${JSON.stringify(readyPayload)})); + }, 200); + } + ${opts.echoChatSend ? ` + if (parsed.type === "chat_send") { + const p = parsed.payload; + const echo = { + type: "chat_message", + payload: { + id: Date.now(), + channel_id: p.channel_id, + user: { id: 1, username: "testuser", avatar: "" }, + content: p.content, + timestamp: new Date().toISOString(), + edited_at: null, + attachments: p.attachments || [], + reactions: [], + reply_to: p.reply_to || null, + pinned: false, + deleted: false, + }, + }; + setTimeout(() => { + __tauriEmitEvent("ws-message", JSON.stringify(echo)); + }, 50); + } + if (parsed.type === "chat_edit") { + const echo = { + type: "chat_edited", + payload: { + message_id: parsed.payload.message_id, + channel_id: parsed.payload.channel_id || 1, + content: parsed.payload.content, + edited_at: new Date().toISOString(), + }, + }; + setTimeout(() => { + __tauriEmitEvent("ws-message", JSON.stringify(echo)); + }, 50); + } + if (parsed.type === "chat_delete") { + const echo = { + type: "chat_deleted", + payload: { + message_id: parsed.payload.message_id, + channel_id: parsed.payload.channel_id || 1, + }, + }; + setTimeout(() => { + __tauriEmitEvent("ws-message", JSON.stringify(echo)); + }, 50); + } + ` : ""} + } catch (e) {} + ` : ""} + return; + } + if (cmd === "ws_disconnect") return; + + // ---- Credentials ---- + if (cmd === "save_credential" || cmd === "delete_credential" || cmd === "load_credential") return null; + + // ---- Settings ---- + if (cmd === "get_settings") return {}; + if (cmd === "save_settings") return; + + // ---- Certs ---- + if (cmd === "store_cert_fingerprint" || cmd === "get_cert_fingerprint") return null; + + // ---- Window/webview plugin stubs ---- + if (cmd.startsWith("plugin:window|") || cmd.startsWith("plugin:webview|")) return null; + + console.log("[tauri-mock] unhandled invoke:", cmd); + return null; + }, + + convertFileSrc: (path) => path, + }; + `; +} + +// --------------------------------------------------------------------------- +// Public API — mock injection +// --------------------------------------------------------------------------- + +export async function mockTauriConnect(page: Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + ], + simulateWsFlow: false, + })); +} + +export async function mockTauriConnectWith2FA(page: Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_2FA_RESPONSE }, + ], + simulateWsFlow: false, + })); +} + +export async function mockTauriFullSession(page: Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE }, + { pattern: "/messages", status: 200, body: MOCK_MESSAGES }, + { pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES }, + ], + simulateWsFlow: true, + })); +} + +export async function mockTauriFullSessionWithMessages(page: Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE }, + { pattern: "/messages", status: 200, body: MOCK_MESSAGES_RICH }, + { pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES }, + { pattern: "/api/v1/invites", status: 200, body: MOCK_INVITES }, + ], + simulateWsFlow: true, + readyOverrides: { + channels: MOCK_CHANNELS_WITH_CATEGORIES, + members: MOCK_MEMBERS_MULTI_ROLE, + }, + })); +} + +export async function mockTauriFullSessionWithVoice(page: Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE }, + { pattern: "/messages", status: 200, body: MOCK_MESSAGES }, + ], + simulateWsFlow: true, + readyOverrides: { + channels: MOCK_CHANNELS_WITH_CATEGORIES, + members: MOCK_MEMBERS_MULTI_ROLE, + voice_states: MOCK_VOICE_STATE, + }, + })); +} + +export async function mockTauriFullSessionWithEcho(page: Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE }, + { pattern: "/messages", status: 200, body: MOCK_MESSAGES }, + ], + simulateWsFlow: true, + echoChatSend: true, + })); +} + +export async function mockTauriFullSessionWithMessagesAndEcho(page: Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE }, + { pattern: "/messages", status: 200, body: MOCK_MESSAGES_RICH }, + { pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES }, + { pattern: "/api/v1/invites", status: 200, body: MOCK_INVITES }, + ], + simulateWsFlow: true, + echoChatSend: true, + readyOverrides: { + channels: MOCK_CHANNELS_WITH_CATEGORIES, + members: MOCK_MEMBERS_MULTI_ROLE, + }, + })); +} + +export async function mockTauriFullSessionWithFailingMessages(page: Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE }, + { pattern: "/messages", status: 500, body: { error: "INTERNAL_ERROR", message: "Failed to load messages" } }, + ], + simulateWsFlow: true, + })); +} + +export async function mockTauriLoginError(page: Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 401, body: { error: "INVALID_CREDENTIALS", message: "Invalid username or password" } }, + ], + simulateWsFlow: false, + })); +} + +// --------------------------------------------------------------------------- +// Public API — page actions +// --------------------------------------------------------------------------- + +export async function submitLogin(page: Page): Promise<void> { + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("testuser"); + await page.locator("#password").fill("password123"); + await page.locator("button.btn-primary[type='submit']").click(); +} + +/** + * Login and wait for the main app layout to appear. + */ +export async function navigateToMainPage(page: Page): Promise<void> { + await submitLogin(page); + const appLayout = page.locator("[data-testid='app-layout']"); + await expect(appLayout).toBeVisible({ timeout: 15_000 }); +} + +/** + * Open the settings overlay via the user bar gear button. + */ +export async function openSettings(page: Page): Promise<void> { + const settingsBtn = page.locator("button[aria-label='Settings']"); + await settingsBtn.click(); + + const overlay = page.locator("[data-testid='settings-overlay']"); + await expect(overlay).toHaveClass(/open/, { timeout: 5_000 }); +} + +/** + * Switch to a settings tab by name. + */ +export async function switchSettingsTab(page: Page, tabName: string): Promise<void> { + const tab = page.locator(".settings-sidebar button.settings-nav-item", { hasText: tabName }); + await tab.click(); + await expect(tab).toHaveClass(/active/); +} + +/** + * Emit a WebSocket event from the mock server to the client. + * Must be called after the page has loaded and WS listeners are registered. + */ +export async function emitWsEvent( + page: Page, + eventName: string, + payload: unknown, +): Promise<void> { + await page.evaluate( + ({ event, data }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__tauriEmitEvent(event, typeof data === "string" ? data : JSON.stringify(data)); + }, + { event: eventName, data: payload }, + ); +} + +/** + * Emit a WS message event (shorthand for ws-message). + */ +export async function emitWsMessage(page: Page, message: unknown): Promise<void> { + await emitWsEvent(page, "ws-message", JSON.stringify(message)); +} + +// --------------------------------------------------------------------------- +// Anti-flakiness utilities +// --------------------------------------------------------------------------- + +/** + * Wait for the WS mock to finish its auth + ready handshake. + * The mock uses setTimeout(100ms) for ws-state open, then + * setTimeout(100/200ms) for auth_ok/ready — so the handshake + * completes within ~300ms. This helper polls for a reliable + * DOM signal instead of relying on hardcoded delays. + */ +export async function waitForWsReady(page: Page): Promise<void> { + // The channel sidebar populates after the ready payload is processed. + // Wait for the first channel item as proof the WS flow completed. + await expect(page.locator(".channel-item").first()).toBeVisible({ timeout: 10_000 }); +} + +/** + * Navigate to main page and wait for WS ready handshake to complete. + * Combines login + WS readiness in one call to reduce boilerplate + * and ensure tests start from a stable state. + */ +export async function navigateToMainPageReady(page: Page): Promise<void> { + await navigateToMainPage(page); + await waitForWsReady(page); +} + +/** + * Emit a WS message and wait for a DOM change to confirm it was processed. + * Prevents flakiness from tests asserting before the message handler runs. + * + * @param page - Playwright page + * @param message - WS message payload to emit + * @param confirmLocator - Locator that should become visible/attached after processing + * @param timeout - Max wait time (default 5000ms) + */ +export async function emitWsMessageAndWait( + page: Page, + message: unknown, + confirmLocator: ReturnType<Page["locator"]>, + timeout = 5_000, +): Promise<void> { + await emitWsMessage(page, message); + await expect(confirmLocator).toBeVisible({ timeout }); +} diff --git a/Client/tauri-client/tests/e2e/logout-flow.spec.ts b/Client/tauri-client/tests/e2e/logout-flow.spec.ts new file mode 100644 index 00000000..1ea5fac4 --- /dev/null +++ b/Client/tauri-client/tests/e2e/logout-flow.spec.ts @@ -0,0 +1,52 @@ +/** + * E2E tests for the logout flow. + * Covers: settings → Log Out → returns to connect page. + */ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPage } from "./helpers"; + +test.describe("Logout Flow", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("clicking Log Out in settings returns to connect page", async ({ + page, + }) => { + // Open settings + const settingsBtn = page.locator("button[aria-label='Settings']"); + await settingsBtn.click(); + await expect( + page.locator(".settings-overlay.open"), + ).toBeVisible({ timeout: 3000 }); + + // Click Log Out button + const logoutBtn = page.locator(".settings-nav-item.danger", { + hasText: "Log Out", + }); + await logoutBtn.click(); + + // Should navigate back to connect page + const connectForm = page.locator(".connect-form, .login-form"); + await expect(connectForm).toBeVisible({ timeout: 5000 }); + }); + + test("after logout, main page is no longer visible", async ({ page }) => { + // Open settings and log out + const settingsBtn = page.locator("button[aria-label='Settings']"); + await settingsBtn.click(); + await expect( + page.locator(".settings-overlay.open"), + ).toBeVisible({ timeout: 3000 }); + + const logoutBtn = page.locator(".settings-nav-item.danger", { + hasText: "Log Out", + }); + await logoutBtn.click(); + + // Main app layout should not be visible + await expect(page.locator(".app")).not.toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/main-layout.spec.ts b/Client/tauri-client/tests/e2e/main-layout.spec.ts new file mode 100644 index 00000000..db0f0b2b --- /dev/null +++ b/Client/tauri-client/tests/e2e/main-layout.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPage } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Main Page Layout +// --------------------------------------------------------------------------- + +test.describe("Main Page Layout", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("app layout has all major sections", async ({ page }) => { + // Server strip + await expect(page.locator("[data-testid='server-strip']")).toBeVisible(); + + // Channel sidebar + await expect(page.locator("[data-testid='channel-sidebar']")).toBeVisible(); + + // Chat area + await expect(page.locator("[data-testid='chat-area']")).toBeVisible(); + + // Chat header with channel name "general" + const chatHeader = page.locator("[data-testid='chat-header']"); + await expect(chatHeader).toBeVisible(); + const headerName = page.locator("[data-testid='chat-header-name']"); + await expect(headerName).toHaveText("general"); + + // Messages container + await expect(page.locator(".messages-container")).toBeVisible(); + + // User bar + await expect(page.locator("[data-testid='user-bar']")).toBeVisible(); + }); + + test("input slot is attached to DOM", async ({ page }) => { + const inputSlot = page.locator("[data-testid='input-slot']"); + await expect(inputSlot).toBeAttached(); + }); + + test("typing slot is attached to DOM", async ({ page }) => { + const typingSlot = page.locator("[data-testid='typing-slot']"); + await expect(typingSlot).toBeAttached(); + }); + + test("messages slot contains virtual scroll structure", async ({ page }) => { + const messagesSlot = page.locator("[data-testid='messages-slot']"); + await expect(messagesSlot).toBeVisible(); + + // Messages slot should contain the messages-container for virtual scrolling + const container = messagesSlot.locator(".messages-container"); + await expect(container).toBeVisible(); + }); + + test("member list is visible with role groups", async ({ page }) => { + const memberList = page.locator("[data-testid='member-list']"); + await expect(memberList).toBeVisible(); + + // Should have at least one role group + const roleGroups = memberList.locator(".member-role-group"); + expect(await roleGroups.count()).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/Client/tauri-client/tests/e2e/member-list.spec.ts b/Client/tauri-client/tests/e2e/member-list.spec.ts new file mode 100644 index 00000000..c5a39b7a --- /dev/null +++ b/Client/tauri-client/tests/e2e/member-list.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from "@playwright/test"; +import { + mockTauriFullSession, + mockTauriFullSessionWithMessages, + navigateToMainPage, + emitWsMessage, +} from "./helpers"; + +test.describe("Member List", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("renders members with role groups, avatars, names, and status", async ({ page }) => { + const memberList = page.locator("[data-testid='member-list']"); + await expect(memberList).toBeVisible(); + + // Should have at least one role group header + const roleGroups = page.locator(".member-role-group"); + expect(await roleGroups.count()).toBeGreaterThanOrEqual(1); + + // First member should have all required elements + const firstMember = page.locator("[data-testid='member-1']"); + await expect(firstMember).toBeVisible(); + await expect(firstMember.locator(".mi-avatar")).toBeVisible(); + await expect(firstMember.locator(".mi-name")).toBeVisible(); + await expect(firstMember.locator(".mi-status")).toBeAttached(); + }); + + test("new member appears when member_join event is received", async ({ page }) => { + const membersBefore = await page.locator(".member-item").count(); + + await emitWsMessage(page, { + type: "member_join", + payload: { + user: { + id: 99, + username: "newjoiner", + avatar: "", + role: "member", + }, + }, + }); + + // Wait for the new member to appear + const newMember = page.locator(".mi-name", { hasText: "newjoiner" }); + await expect(newMember).toBeVisible({ timeout: 5_000 }); + + const membersAfter = await page.locator(".member-item").count(); + expect(membersAfter).toBe(membersBefore + 1); + }); + + test("member disappears when member_ban event is received", async ({ page }) => { + // Verify otheruser exists first + const otherUser = page.locator(".mi-name", { hasText: "otheruser" }); + await expect(otherUser).toBeVisible({ timeout: 5_000 }); + + const membersBefore = await page.locator(".member-item").count(); + + await emitWsMessage(page, { + type: "member_ban", + payload: { user_id: 2 }, + }); + + // otheruser should disappear + await expect(otherUser).not.toBeVisible({ timeout: 5_000 }); + + const membersAfter = await page.locator(".member-item").count(); + expect(membersAfter).toBe(membersBefore - 1); + }); + + test("member status updates when presence event is received", async ({ page }) => { + // otheruser starts as "online" + const otherUserItem = page.locator(".member-item").filter({ + has: page.locator(".mi-name", { hasText: "otheruser" }), + }); + await expect(otherUserItem).toBeVisible({ timeout: 5_000 }); + + // Should NOT have offline class initially + await expect(otherUserItem).not.toHaveClass(/offline/); + + // Send presence update to offline + await emitWsMessage(page, { + type: "presence", + payload: { user_id: 2, status: "offline" }, + }); + + // Should now have offline class + await expect(otherUserItem).toHaveClass(/offline/, { timeout: 5_000 }); + }); + + test("toggle visibility via header button", async ({ page }) => { + const memberList = page.locator("[data-testid='member-list']"); + await expect(memberList).toBeVisible(); + + const toggle = page.locator("[data-testid='members-toggle']"); + await toggle.click(); + await expect(memberList).not.toBeVisible({ timeout: 3_000 }); + + await toggle.click(); + await expect(memberList).toBeVisible({ timeout: 3_000 }); + }); +}); + +test.describe("Member List — Multi-role", () => { + test("shows members grouped by role with correct counts", async ({ page }) => { + await mockTauriFullSessionWithMessages(page); + await page.goto("/"); + await navigateToMainPage(page); + + const memberList = page.locator("[data-testid='member-list']"); + await expect(memberList).toBeVisible(); + + // Multi-role mock has 5 members across different roles + const members = page.locator(".member-item"); + expect(await members.count()).toBeGreaterThanOrEqual(3); + + // Multiple role groups should be present + const roleGroups = page.locator(".member-role-group"); + expect(await roleGroups.count()).toBeGreaterThanOrEqual(2); + + // Offline members should have the offline class + const offlineMembers = page.locator(".member-item.offline"); + await expect(offlineMembers.first()).toBeAttached({ timeout: 5000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/message-actions.spec.ts b/Client/tauri-client/tests/e2e/message-actions.spec.ts new file mode 100644 index 00000000..38a54260 --- /dev/null +++ b/Client/tauri-client/tests/e2e/message-actions.spec.ts @@ -0,0 +1,126 @@ +/** + * E2E tests for message action buttons (hover actions bar). + * Tests: reply, edit, delete buttons on message hover. + */ +import { test, expect } from "@playwright/test"; +import { + mockTauriFullSessionWithMessagesAndEcho, + navigateToMainPage, +} from "./helpers"; + +test.describe("Message Actions Bar", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithMessagesAndEcho(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("hovering a message shows actions bar", async ({ page }) => { + const firstMessage = page.locator("[data-testid='message-101']"); + await firstMessage.hover(); + + const actionsBar = firstMessage.locator(".msg-actions-bar"); + await expect(actionsBar).toBeAttached(); + }); + + test("own message has Reply button", async ({ page }) => { + // Message id 101 is from testuser (id: 1) = own message + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + + const replyBtn = page.locator("[data-testid='msg-reply-101']"); + await expect(replyBtn).toBeAttached(); + }); + + test("own message has Edit button", async ({ page }) => { + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + + const editBtn = page.locator("[data-testid='msg-edit-101']"); + await expect(editBtn).toBeAttached(); + }); + + test("own message has Delete button", async ({ page }) => { + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + + const deleteBtn = page.locator("[data-testid='msg-delete-101']"); + await expect(deleteBtn).toBeAttached(); + }); + + test("other user message does NOT have Edit button", async ({ page }) => { + // Message id 102 is from otheruser (id: 2) + const otherMessage = page.locator("[data-testid='message-102']"); + await otherMessage.hover(); + + const editBtn = page.locator("[data-testid='msg-edit-102']"); + await expect(editBtn).toHaveCount(0); + }); + + test("clicking Reply opens reply bar in input", async ({ page }) => { + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + + const replyBtn = page.locator("[data-testid='msg-reply-101']"); + await replyBtn.click(); + + // Reply bar should appear in the message input area + const replyBar = page.locator(".reply-bar.visible"); + await expect(replyBar).toBeVisible({ timeout: 3000 }); + }); + + test("clicking Edit populates textarea with message content", async ({ + page, + }) => { + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + + const editBtn = page.locator("[data-testid='msg-edit-101']"); + await editBtn.click(); + + // Textarea should contain the original message content + const textarea = page.locator("[data-testid='msg-textarea']"); + await expect(textarea).toHaveValue("Hello world!"); + }); + + test("React button exists on messages", async ({ page }) => { + const firstMessage = page.locator("[data-testid='message-101']"); + await firstMessage.hover(); + + const reactBtn = page.locator("[data-testid='msg-react-101']"); + await expect(reactBtn).toBeAttached(); + }); +}); + +test.describe("Message Reactions", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithMessagesAndEcho(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("reaction chips are visible on messages with reactions", async ({ + page, + }) => { + const reactions = page.locator(".msg-reactions"); + await expect(reactions.first()).toBeVisible(); + }); + + test("reaction chip shows emoji and count", async ({ page }) => { + const chip = page.locator(".reaction-chip").first(); + await expect(chip).toBeVisible(); + + const count = chip.locator(".rc-count"); + await expect(count).toHaveText("2"); + }); + + test("user own reaction has me class", async ({ page }) => { + const meChip = page.locator(".reaction-chip.me"); + await expect(meChip.first()).toBeVisible(); + }); + + test("add reaction button exists", async ({ page }) => { + const addBtn = page.locator(".reaction-chip.add-reaction"); + await expect(addBtn.first()).toBeVisible(); + }); +}); diff --git a/Client/tauri-client/tests/e2e/message-edit-delete.spec.ts b/Client/tauri-client/tests/e2e/message-edit-delete.spec.ts new file mode 100644 index 00000000..5c77803a --- /dev/null +++ b/Client/tauri-client/tests/e2e/message-edit-delete.spec.ts @@ -0,0 +1,98 @@ +/** + * E2E tests for message edit and delete flows. + * Covers: edit → save, edit → cancel, delete. + */ +import { test, expect } from "@playwright/test"; +import { + mockTauriFullSessionWithMessagesAndEcho, + navigateToMainPage, +} from "./helpers"; + +test.describe("Message Edit Flow", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithMessagesAndEcho(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("clicking Edit puts message content in textarea", async ({ page }) => { + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + await page.locator("[data-testid='msg-edit-101']").click(); + + const textarea = page.locator("[data-testid='msg-textarea']"); + await expect(textarea).toHaveValue("Hello world!"); + }); + + test("edit mode shows save and cancel controls", async ({ page }) => { + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + await page.locator("[data-testid='msg-edit-101']").click(); + + // Edit bar reuses .reply-bar class and becomes .visible + const editBar = page.locator(".reply-bar.visible"); + await expect(editBar).toBeVisible({ timeout: 3000 }); + + // Cancel button uses .reply-close class + const cancelBtn = editBar.locator(".reply-close"); + await expect(cancelBtn).toBeVisible(); + }); + + test("saving edit updates the message content", async ({ page }) => { + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + await page.locator("[data-testid='msg-edit-101']").click(); + + const textarea = page.locator("[data-testid='msg-textarea']"); + await textarea.fill("Edited message content"); + await textarea.press("Enter"); + + // The edited message should show "(edited)" indicator + // (may take a moment for WS echo to process) + const editedMessage = page.locator(".message", { + has: page.locator(".msg-text", { hasText: "Edited message content" }), + }); + await expect(editedMessage.locator(".msg-edited")).toBeVisible({ timeout: 5000 }); + }); + + test("cancelling edit clears the edit bar", async ({ page }) => { + // Click Edit on own message + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + await page.locator("[data-testid='msg-edit-101']").click(); + + // Verify edit bar (.reply-bar.visible) appears + const editBar = page.locator(".reply-bar.visible"); + await expect(editBar).toBeVisible({ timeout: 3000 }); + + // Click cancel (.reply-close on the visible edit bar) + const cancelBtn = editBar.locator(".reply-close"); + await cancelBtn.click(); + + // Verify edit bar is no longer visible + await expect(editBar).not.toBeVisible({ timeout: 3000 }); + + // Verify textarea is empty + const textarea = page.locator("[data-testid='msg-textarea']"); + await expect(textarea).toHaveValue(""); + }); +}); + +test.describe("Message Delete Flow", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithMessagesAndEcho(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("clicking Delete marks the message as deleted", async ({ page }) => { + const ownMessage = page.locator("[data-testid='message-101']"); + await ownMessage.hover(); + await page.locator("[data-testid='msg-delete-101']").click(); + + // Soft-delete: message stays in DOM but shows "[message deleted]" + await expect( + ownMessage.locator(".msg-text", { hasText: "[message deleted]" }), + ).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/message-input.spec.ts b/Client/tauri-client/tests/e2e/message-input.spec.ts new file mode 100644 index 00000000..1708196b --- /dev/null +++ b/Client/tauri-client/tests/e2e/message-input.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPage } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Message Input +// --------------------------------------------------------------------------- + +test.describe("Message Input", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("message input area is visible", async ({ page }) => { + const inputWrap = page.locator("[data-testid='message-input']"); + await expect(inputWrap).toBeAttached(); + }); + + test("textarea is present and focusable", async ({ page }) => { + const textarea = page.locator("[data-testid='msg-textarea']"); + await expect(textarea).toBeAttached(); + + await textarea.focus(); + await expect(textarea).toBeFocused(); + }); + + test("textarea has placeholder containing channel name 'general'", async ({ page }) => { + const textarea = page.locator("[data-testid='msg-textarea']"); + const placeholder = await textarea.getAttribute("placeholder"); + expect(placeholder).toBe("Message #general"); + }); + + test("send button exists with arrow icon", async ({ page }) => { + const sendBtn = page.locator("[data-testid='send-btn']"); + await expect(sendBtn).toBeAttached(); + await expect(sendBtn).toHaveText("\u27A4"); + }); + + test("emoji button exists", async ({ page }) => { + const emojiBtn = page.locator(".emoji-btn"); + await expect(emojiBtn).toBeAttached(); + }); + + test("attach button exists", async ({ page }) => { + const attachBtn = page.locator(".attach-btn"); + await expect(attachBtn).toBeAttached(); + }); + + test("typing in textarea updates its value", async ({ page }) => { + const textarea = page.locator("[data-testid='msg-textarea']"); + await textarea.fill("Hello, this is a test message"); + await expect(textarea).toHaveValue("Hello, this is a test message"); + + // Verify clearing also works + await textarea.fill(""); + await expect(textarea).toHaveValue(""); + }); + + test("reply bar is hidden by default", async ({ page }) => { + const replyBar = page.locator(".reply-bar").first(); + // Reply bar should exist but not have visible class + await expect(replyBar).toBeAttached(); + await expect(replyBar).not.toHaveClass(/visible/); + }); +}); diff --git a/Client/tauri-client/tests/e2e/message-list.spec.ts b/Client/tauri-client/tests/e2e/message-list.spec.ts new file mode 100644 index 00000000..8d91e557 --- /dev/null +++ b/Client/tauri-client/tests/e2e/message-list.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from "@playwright/test"; +import { + mockTauriFullSession, + mockTauriFullSessionWithMessages, + navigateToMainPage, + emitWsMessage, +} from "./helpers"; + +test.describe("Message List — Structure", () => { + test("renders messages with author, content, timestamp, and avatar", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + + const container = page.locator(".messages-container"); + await expect(container).toBeVisible(); + + const message = page.locator("[data-testid='message-101']"); + await expect(message).toBeVisible({ timeout: 10_000 }); + + // Verify all parts of a message render + await expect(message.locator(".msg-author")).toBeVisible(); + await expect(message.locator(".msg-text")).toHaveText("Hello world!"); + await expect(message.locator(".msg-time")).toBeVisible(); + await expect(message.locator(".msg-avatar")).toBeVisible(); + }); +}); + +test.describe("Message List — Rich Content", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithMessages(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("displays multiple messages with rich formatting", async ({ page }) => { + const messages = page.locator(".message"); + await expect(messages.first()).toBeVisible({ timeout: 10_000 }); + const count = await messages.count(); + expect(count).toBeGreaterThanOrEqual(3); + + // Edited messages show indicator + await expect(page.locator(".msg-edited").first()).toBeVisible(); + + // Reply references show author + const replyRef = page.locator(".msg-reply-ref").first(); + await expect(replyRef).toBeVisible(); + await expect(replyRef.locator(".rr-author")).toBeVisible(); + + // Code blocks render + await expect(page.locator(".msg-codeblock").first()).toBeVisible(); + }); + + test("reactions and attachments render correctly", async ({ page }) => { + await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 }); + + // Reaction chips show emoji and count + const chip = page.locator(".reaction-chip").first(); + await expect(chip).toBeVisible(); + await expect(chip).not.toBeEmpty(); + + // Image and file attachments + await expect(page.locator(".msg-image").first()).toBeAttached(); + const file = page.locator(".msg-file").first(); + await expect(file).toBeAttached(); + await expect(file.locator(".msg-file-name")).toBeVisible(); + }); + + test("grouped messages share avatar and day dividers separate dates", async ({ page }) => { + const grouped = page.locator(".message.grouped"); + await expect(grouped.first()).toBeAttached({ timeout: 5000 }); + expect(await grouped.count()).toBeGreaterThanOrEqual(1); + + const divider = page.locator(".msg-day-divider"); + await expect(divider.first()).toBeAttached(); + }); +}); + +test.describe("Message List — Real-time", () => { + test("new message appears via WebSocket", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + + await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 }); + const countBefore = await page.locator(".message").count(); + + await emitWsMessage(page, { + type: "chat_message", + payload: { + id: 200, + channel_id: 1, + user: { id: 2, username: "otheruser", avatar: "" }, + content: "A new real-time message!", + timestamp: "2026-03-15T10:05:00Z", + attachments: [], + reply_to: null, + }, + }); + + const newMsg = page.locator(".msg-text", { hasText: "A new real-time message!" }); + await expect(newMsg).toBeVisible({ timeout: 5_000 }); + + const countAfter = await page.locator(".message").count(); + expect(countAfter).toBe(countBefore + 1); + }); + + test("multiple rapid messages all appear in order", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 }); + + for (let i = 0; i < 3; i++) { + await emitWsMessage(page, { + type: "chat_message", + payload: { + id: 300 + i, + channel_id: 1, + user: { id: 2, username: "otheruser", avatar: "" }, + content: `Rapid message ${i}`, + timestamp: new Date().toISOString(), + attachments: [], + reply_to: null, + }, + }); + } + + for (let i = 0; i < 3; i++) { + await expect( + page.locator(".msg-text", { hasText: `Rapid message ${i}` }) + ).toBeVisible({ timeout: 5_000 }); + } + }); +}); diff --git a/Client/tauri-client/tests/e2e/message-send-flow.spec.ts b/Client/tauri-client/tests/e2e/message-send-flow.spec.ts new file mode 100644 index 00000000..2daab154 --- /dev/null +++ b/Client/tauri-client/tests/e2e/message-send-flow.spec.ts @@ -0,0 +1,79 @@ +/** + * E2E tests for the message send round-trip flow. + * Covers: type message → send → see it appear in message list. + */ +import { test, expect } from "@playwright/test"; +import { + mockTauriFullSessionWithEcho, + navigateToMainPage, +} from "./helpers"; + +test.describe("Message Send Flow", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithEcho(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("typing and pressing Enter sends a message", async ({ page }) => { + const textarea = page.locator("[data-testid='msg-textarea']"); + await textarea.fill("Hello from E2E test!"); + await textarea.press("Enter"); + + // Message should appear in the list via WS echo + const newMsg = page.locator(".message .msg-text", { + hasText: "Hello from E2E test!", + }); + await expect(newMsg).toBeVisible({ timeout: 5000 }); + }); + + test("send button click sends the message", async ({ page }) => { + const textarea = page.locator("[data-testid='msg-textarea']"); + await textarea.fill("Sent via button click"); + + const sendBtn = page.locator("[data-testid='send-btn']"); + await sendBtn.click(); + + const newMsg = page.locator(".message .msg-text", { + hasText: "Sent via button click", + }); + await expect(newMsg).toBeVisible({ timeout: 5000 }); + }); + + test("textarea clears after sending", async ({ page }) => { + const textarea = page.locator("[data-testid='msg-textarea']"); + await textarea.fill("Clear after send"); + await textarea.press("Enter"); + + // Wait for the echo message to appear (confirms send happened) + await expect( + page.locator(".message .msg-text", { hasText: "Clear after send" }), + ).toBeVisible({ timeout: 5000 }); + + // Textarea should be empty + await expect(textarea).toHaveValue(""); + }); + + test("empty message is not sent", async ({ page }) => { + const textarea = page.locator("[data-testid='msg-textarea']"); + // Focus and press Enter without typing + await textarea.focus(); + await textarea.press("Enter"); + + // Count messages — should still be 1 (the pre-loaded mock message) + const messages = page.locator(".message"); + await expect(messages).toHaveCount(1); + }); + + test("long message sends successfully", async ({ page }) => { + const longContent = "A".repeat(500); + const textarea = page.locator("[data-testid='msg-textarea']"); + await textarea.fill(longContent); + await textarea.press("Enter"); + + const newMsg = page.locator(".message .msg-text", { + hasText: longContent, + }); + await expect(newMsg).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/native-fixture.ts b/Client/tauri-client/tests/e2e/native-fixture.ts new file mode 100644 index 00000000..d1f6752a --- /dev/null +++ b/Client/tauri-client/tests/e2e/native-fixture.ts @@ -0,0 +1,195 @@ +/** + * Custom Playwright fixture for testing the real Tauri production app. + * + * Launches the built OwnCord exe with WebView2 remote debugging enabled, + * connects Playwright to the WebView2 window via Chrome DevTools Protocol, + * and provides the page object to tests. + * + * Based on: + * - https://playwright.dev/docs/webview2 + * - https://github.com/Haprog/playwright-cdp + */ + +import { test as base, type Page, type BrowserContext } from "@playwright/test"; +import { chromium } from "@playwright/test"; +import { type ChildProcess, spawn } from "child_process"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; +import { fileURLToPath } from "url"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** Path to the built Tauri exe (release build). */ +const TAURI_EXE = path.resolve( + __dirname, + "../../src-tauri/target/release/owncord-client.exe", +); + +/** CDP port for WebView2 remote debugging. */ +const CDP_PORT = parseInt(process.env.CDP_PORT ?? "9222", 10); + +/** Max time to wait for WebView2 to start accepting CDP connections. */ +const CDP_CONNECT_TIMEOUT = 30_000; + +/** Polling interval when waiting for CDP endpoint. */ +const CDP_POLL_INTERVAL = 500; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Wait for the CDP endpoint to become available by polling the /json/version endpoint. + * WebView2 needs time to initialize before it accepts CDP connections. + */ +async function waitForCdpEndpoint(port: number, timeout: number): Promise<void> { + const start = Date.now(); + const url = `http://127.0.0.1:${port}/json/version`; + + while (Date.now() - start < timeout) { + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // Connection refused — WebView2 not ready yet + } + await new Promise((r) => setTimeout(r, CDP_POLL_INTERVAL)); + } + + throw new Error( + `CDP endpoint at port ${port} did not become available within ${timeout}ms. ` + + `Make sure the Tauri app was built (npm run tauri build) and the exe exists at: ${TAURI_EXE}`, + ); +} + +/** + * Create a unique temporary directory for WebView2 user data. + * Each test worker gets its own directory to avoid state leakage. + */ +function createUserDataDir(workerIndex: number): string { + const dir = path.join(os.tmpdir(), `owncord-native-e2e-${workerIndex}-${Date.now()}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +/** + * Clean up the temporary user data directory. + */ +function cleanupUserDataDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Best effort cleanup — Windows may hold locks briefly + } +} + +// --------------------------------------------------------------------------- +// Fixture type definitions +// --------------------------------------------------------------------------- + +type NativeFixtures = { + /** The Playwright page connected to the real Tauri WebView2 window. */ + nativePage: Page; + /** The browser context from the CDP connection. */ + nativeContext: BrowserContext; + /** The Tauri app child process (for lifecycle control). */ + tauriProcess: ChildProcess; +}; + +// --------------------------------------------------------------------------- +// Test fixture +// --------------------------------------------------------------------------- + +export const test = base.extend<NativeFixtures>({ + // eslint-disable-next-line no-empty-pattern + nativePage: async ({}, use, testInfo) => { + // Validate exe exists + if (!fs.existsSync(TAURI_EXE)) { + throw new Error( + `Tauri exe not found at: ${TAURI_EXE}\n` + + `Run 'npm run tauri build' first to create the production build.`, + ); + } + + const workerIndex = testInfo.workerIndex; + const port = CDP_PORT + workerIndex; + const userDataDir = createUserDataDir(workerIndex); + + // Launch Tauri app with CDP enabled + const tauriProcess = spawn(TAURI_EXE, [], { + env: { + ...process.env, + WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: `--remote-debugging-port=${port}`, + WEBVIEW2_USER_DATA_FOLDER: userDataDir, + }, + stdio: "pipe", + }); + + // Log stdout/stderr for debugging + tauriProcess.stdout?.on("data", (data: Buffer) => { + const msg = data.toString().trim(); + if (msg) testInfo.attach("tauri-stdout", { body: msg, contentType: "text/plain" }); + }); + tauriProcess.stderr?.on("data", (data: Buffer) => { + const msg = data.toString().trim(); + if (msg) testInfo.attach("tauri-stderr", { body: msg, contentType: "text/plain" }); + }); + + let browser; + try { + // Wait for WebView2 to start accepting CDP connections + await waitForCdpEndpoint(port, CDP_CONNECT_TIMEOUT); + + // Connect Playwright to the WebView2 instance via CDP + browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + + // Get the existing context and page (WebView2 creates one automatically) + const context = browser.contexts()[0]; + if (!context) { + throw new Error("No browser context found after CDP connection"); + } + + const page = context.pages()[0]; + if (!page) { + throw new Error("No page found in browser context after CDP connection"); + } + + // Provide the page to the test + await use(page); + } finally { + // Cleanup: close browser connection, kill process, remove temp dir + if (browser) { + try { + await browser.close(); + } catch { + // Browser may already be closed + } + } + + tauriProcess.kill(); + + // Give the process a moment to release file locks + await new Promise((r) => setTimeout(r, 1000)); + cleanupUserDataDir(userDataDir); + } + }, + + nativeContext: async ({ nativePage }, use) => { + const context = nativePage.context(); + await use(context); + }, + + tauriProcess: async ({ nativePage }, use) => { + // This is a convenience fixture — the process is managed by nativePage + // We expose it so tests can check process state if needed + await use(undefined as unknown as ChildProcess); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/Client/tauri-client/tests/e2e/native/app-layout.spec.ts b/Client/tauri-client/tests/e2e/native/app-layout.spec.ts new file mode 100644 index 00000000..39000a93 --- /dev/null +++ b/Client/tauri-client/tests/e2e/native/app-layout.spec.ts @@ -0,0 +1,109 @@ +/** + * Native E2E: Main app layout after real login. + * + * Verifies all major UI sections render correctly when connected + * to the real server with real data. + */ + +import { test, expect } from "../native-fixture"; +import { SKIP_SERVER, hasCredentials, nativeLoginAndReady } from "./helpers"; + +test.describe("App Layout (Logged In)", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + await nativeLoginAndReady(nativePage); + }); + + test("all major layout sections are visible", async ({ nativePage }) => { + await expect(nativePage.locator("[data-testid='server-strip']")).toBeVisible(); + await expect(nativePage.locator("[data-testid='channel-sidebar']")).toBeVisible(); + await expect(nativePage.locator("[data-testid='chat-area']")).toBeVisible(); + await expect(nativePage.locator("[data-testid='user-bar']")).toBeVisible(); + }); + + test("chat header shows a channel name", async ({ nativePage }) => { + const headerName = nativePage.locator("[data-testid='chat-header-name']"); + await expect(headerName).toBeVisible(); + const text = await headerName.textContent(); + expect(text?.trim().length).toBeGreaterThan(0); + }); + + test("message input area is mounted", async ({ nativePage }) => { + const inputSlot = nativePage.locator("[data-testid='input-slot']"); + await expect(inputSlot).toBeAttached(); + + const textarea = nativePage.locator("[data-testid='msg-textarea']"); + await expect(textarea).toBeVisible({ timeout: 10_000 }); + }); + + test("user bar shows current username", async ({ nativePage }) => { + const userName = nativePage.locator("[data-testid='user-bar-name']"); + await expect(userName).toBeVisible(); + const text = await userName.textContent(); + expect(text?.trim().length).toBeGreaterThan(0); + }); + + test("user bar shows status and avatar", async ({ nativePage }) => { + const avatar = nativePage.locator("[data-testid='user-bar'] .ub-avatar"); + await expect(avatar).toBeVisible(); + + const status = nativePage.locator("[data-testid='user-bar'] .ub-status"); + await expect(status).toBeVisible(); + }); + + test("user bar control buttons are present", async ({ nativePage }) => { + const controls = nativePage.locator("[data-testid='user-bar'] .ub-controls"); + await expect(controls).toBeVisible(); + + // Settings gear button should always be visible + const settingsBtn = nativePage.locator("button[aria-label='Settings']"); + await expect(settingsBtn).toBeVisible(); + }); + + test("channel sidebar has channels from real server", async ({ nativePage }) => { + const channels = nativePage.locator(".channel-item"); + const count = await channels.count(); + expect(count).toBeGreaterThan(0); + + // Each channel should have a name + const firstName = await channels.first().locator(".ch-name").textContent(); + expect(firstName?.trim().length).toBeGreaterThan(0); + }); + + test("channel sidebar shows channel icons", async ({ nativePage }) => { + // Text channels should have # icon, voice channels 🔊 + const icons = nativePage.locator(".channel-item .ch-icon"); + const count = await icons.count(); + expect(count).toBeGreaterThan(0); + + const firstIcon = await icons.first().textContent(); + expect(firstIcon).toMatch(/[#🔊]/); + }); + + test("member list is visible with real members", async ({ nativePage }) => { + const memberList = nativePage.locator("[data-testid='member-list']"); + await expect(memberList).toBeVisible({ timeout: 10_000 }); + + // Should have at least 1 member (the logged-in user) + const members = memberList.locator(".member-item"); + const count = await members.count(); + expect(count).toBeGreaterThan(0); + }); + + test("member list groups by role", async ({ nativePage }) => { + const roleGroups = nativePage.locator(".member-role-group"); + const count = await roleGroups.count(); + expect(count).toBeGreaterThan(0); + }); + + test("first channel is active by default", async ({ nativePage }) => { + const firstChannel = nativePage.locator(".channel-item").first(); + await expect(firstChannel).toHaveClass(/active/); + }); + + test("messages container loads for active channel", async ({ nativePage }) => { + const msgContainer = nativePage.locator(".messages-container"); + await expect(msgContainer).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/native/auth-flow.spec.ts b/Client/tauri-client/tests/e2e/native/auth-flow.spec.ts new file mode 100644 index 00000000..f10a5ecb --- /dev/null +++ b/Client/tauri-client/tests/e2e/native/auth-flow.spec.ts @@ -0,0 +1,144 @@ +/** + * Native E2E: Authentication flows against the real server. + * + * Tests real login, invalid credentials, credential persistence, + * and the connect page UI with actual server responses. + */ + +import { test, expect } from "../native-fixture"; +import { SERVER_URL, TEST_USER, TEST_PASS, SKIP_SERVER, hasCredentials } from "./helpers"; + +test.describe("Authentication Flow", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + await nativePage.waitForLoadState("networkidle"); + }); + + test("connect page renders all form fields", async ({ nativePage }) => { + // Verify the connect page structure is complete in production + await expect(nativePage.locator("#host")).toBeVisible(); + await expect(nativePage.locator("#username")).toBeVisible(); + await expect(nativePage.locator("#password")).toBeVisible(); + await expect(nativePage.locator("button.btn-primary[type='submit']")).toBeVisible(); + + // Branding + await expect(nativePage.locator(".form-logo")).toBeVisible(); + + // Mode switch link (Login/Register toggle) + await expect(nativePage.locator(".form-switch a")).toBeVisible(); + }); + + test("password visibility toggle works", async ({ nativePage }) => { + const passwordInput = nativePage.locator("#password"); + await passwordInput.fill("testpassword"); + + // Should start as password type + await expect(passwordInput).toHaveAttribute("type", "password"); + + // Toggle visibility + await nativePage.locator(".password-toggle").click(); + await expect(passwordInput).toHaveAttribute("type", "text"); + + // Toggle back + await nativePage.locator(".password-toggle").click(); + await expect(passwordInput).toHaveAttribute("type", "password"); + }); + + test("login with invalid credentials shows server error", async ({ nativePage }) => { + await nativePage.locator("#host").fill(SERVER_URL); + await nativePage.locator("#username").fill("nonexistent_user_e2e_test"); + await nativePage.locator("#password").fill("wrong_password_e2e_test"); + await nativePage.locator("button.btn-primary[type='submit']").click(); + + // The real server should return an error — error banner appears + const errorBanner = nativePage.locator(".error-banner"); + await expect(errorBanner).toBeVisible({ timeout: 10_000 }); + }); + + test("submit button shows loading spinner during request", async ({ nativePage }) => { + await nativePage.locator("#host").fill(SERVER_URL); + await nativePage.locator("#username").fill("spinner_test_user"); + await nativePage.locator("#password").fill("spinner_test_pass"); + await nativePage.locator("button.btn-primary[type='submit']").click(); + + // The spinner should appear while the request is in flight + const spinner = nativePage.locator("button.btn-primary .spinner"); + // It may be very brief, so check it was at least attached + await expect(spinner).toBeAttached({ timeout: 5_000 }); + }); + + test("successful login reaches main app layout", async ({ nativePage }) => { + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + + await nativePage.locator("#host").fill(SERVER_URL); + await nativePage.locator("#username").fill(TEST_USER); + await nativePage.locator("#password").fill(TEST_PASS); + await nativePage.locator("button.btn-primary[type='submit']").click(); + + // Should reach main app layout + const appLayout = nativePage.locator("[data-testid='app-layout']"); + await expect(appLayout).toBeVisible({ timeout: 20_000 }); + }); + + test("successful login completes WS handshake", async ({ nativePage }) => { + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + + await nativePage.locator("#host").fill(SERVER_URL); + await nativePage.locator("#username").fill(TEST_USER); + await nativePage.locator("#password").fill(TEST_PASS); + await nativePage.locator("button.btn-primary[type='submit']").click(); + + // Wait for app layout + await expect(nativePage.locator("[data-testid='app-layout']")).toBeVisible({ timeout: 20_000 }); + + // Channels should populate from the real ready payload + const channelItem = nativePage.locator(".channel-item").first(); + await expect(channelItem).toBeVisible({ timeout: 15_000 }); + }); + + test("saved server profile shows in sidebar", async ({ nativePage }) => { + // If a server has been connected before, it should appear in the sidebar + const serverItem = nativePage.locator(".server-item").first(); + const hasSavedServer = await serverItem.isVisible().catch(() => false); + + if (hasSavedServer) { + // Verify server item has name and host info + await expect(serverItem.locator(".srv-name")).toBeVisible(); + // srv-meta may contain multiple spans (host + username), just check the container + await expect(serverItem.locator(".srv-meta")).toBeVisible(); + } + // If no saved server, that's fine — first-time launch + }); + + test("clicking saved server auto-fills host field", async ({ nativePage }) => { + const serverItem = nativePage.locator(".server-item").first(); + const hasSavedServer = await serverItem.isVisible().catch(() => false); + test.skip(!hasSavedServer, "No saved server profiles"); + + // Click the server item to auto-fill + await serverItem.click(); + + // Host field should be filled with the server address + const hostValue = await nativePage.locator("#host").inputValue(); + expect(hostValue).toBeTruthy(); + expect(hostValue.length).toBeGreaterThan(0); + }); + + test("can switch between login and register modes", async ({ nativePage }) => { + const switchLink = nativePage.locator(".form-switch a"); + await expect(switchLink).toBeVisible(); + + // Click to switch to register mode + await switchLink.click(); + + // Invite code field should appear in register mode + const inviteField = nativePage.locator("#invite"); + await expect(inviteField).toBeVisible({ timeout: 3_000 }); + + // Switch back + await nativePage.locator(".form-switch a").click(); + + // Invite field should be gone + await expect(inviteField).not.toBeVisible({ timeout: 3_000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/native/channel-navigation.spec.ts b/Client/tauri-client/tests/e2e/native/channel-navigation.spec.ts new file mode 100644 index 00000000..3444971a --- /dev/null +++ b/Client/tauri-client/tests/e2e/native/channel-navigation.spec.ts @@ -0,0 +1,120 @@ +/** + * Native E2E: Channel navigation with real server data. + * + * Tests switching between channels, verifying header updates, + * message containers re-mount, and voice channel detection. + */ + +import { test, expect } from "../native-fixture"; +import { SKIP_SERVER, hasCredentials, nativeLoginAndReady } from "./helpers"; + +test.describe("Channel Navigation", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + await nativeLoginAndReady(nativePage); + }); + + test("clicking a text channel makes it active", async ({ nativePage }) => { + // Filter to text channels only (voice channels have different behavior) + const textChannels = nativePage.locator(".channel-item").filter({ + has: nativePage.locator(".ch-icon", { hasText: "#" }), + }); + const count = await textChannels.count(); + test.skip(count < 2, "Need at least 2 text channels to test switching"); + + // Click the second text channel + const secondChannel = textChannels.nth(1); + await secondChannel.click(); + + // Should become active + await expect(secondChannel).toHaveClass(/active/, { timeout: 5_000 }); + }); + + test("switching text channels updates chat header", async ({ nativePage }) => { + const textChannels = nativePage.locator(".channel-item").filter({ + has: nativePage.locator(".ch-icon", { hasText: "#" }), + }); + const count = await textChannels.count(); + test.skip(count < 2, "Need at least 2 text channels to test switching"); + + // Get first channel name, verify header matches + const firstChannel = textChannels.first(); + const firstName = await firstChannel.locator(".ch-name").textContent(); + const header = nativePage.locator("[data-testid='chat-header-name']"); + const headerText = await header.textContent(); + expect(headerText?.trim()).toBe(firstName?.trim()); + + // Switch to second text channel + const secondChannel = textChannels.nth(1); + const secondName = await secondChannel.locator(".ch-name").textContent(); + await secondChannel.click(); + + // Header should update to the new text channel name + await expect(header).toHaveText(secondName?.trim() ?? "", { timeout: 5_000 }); + }); + + test("switching text channels loads new messages", async ({ nativePage }) => { + const textChannels = nativePage.locator(".channel-item").filter({ + has: nativePage.locator(".ch-icon", { hasText: "#" }), + }); + const count = await textChannels.count(); + test.skip(count < 2, "Need at least 2 text channels to test switching"); + + // Wait for messages in first channel + await expect(nativePage.locator(".messages-container")).toBeVisible({ timeout: 10_000 }); + + // Switch to second text channel + const secondChannel = textChannels.nth(1); + await secondChannel.click(); + + // Messages container should still be present (may re-mount) + await expect(nativePage.locator(".messages-container")).toBeVisible({ timeout: 10_000 }); + }); + + test("text channels have # icon", async ({ nativePage }) => { + // Find a text channel by its # icon + const textChannels = nativePage.locator(".channel-item .ch-icon", { hasText: "#" }); + const count = await textChannels.count(); + expect(count).toBeGreaterThan(0); + }); + + test("voice channels have speaker icon", async ({ nativePage }) => { + // Voice channels may or may not exist depending on server config + const voiceChannels = nativePage.locator(".channel-item .ch-icon", { hasText: "🔊" }); + const count = await voiceChannels.count(); + + if (count > 0) { + // Voice channels exist — verify they're rendered + await expect(voiceChannels.first()).toBeVisible(); + } + // If no voice channels, that's fine — server may not have any + }); + + test("clicking back to first text channel restores its active state", async ({ nativePage }) => { + const textChannels = nativePage.locator(".channel-item").filter({ + has: nativePage.locator(".ch-icon", { hasText: "#" }), + }); + const count = await textChannels.count(); + test.skip(count < 2, "Need at least 2 text channels to test switching"); + + const firstChannel = textChannels.first(); + const secondChannel = textChannels.nth(1); + + // Switch to second text channel + await secondChannel.click(); + await expect(secondChannel).toHaveClass(/active/, { timeout: 5_000 }); + + // Switch back to first + await firstChannel.click(); + await expect(firstChannel).toHaveClass(/active/, { timeout: 5_000 }); + }); + + test("channel sidebar shows server name in header", async ({ nativePage }) => { + const serverName = nativePage.locator(".channel-sidebar-header h2"); + await expect(serverName).toBeVisible(); + + const text = await serverName.textContent(); + expect(text?.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/Client/tauri-client/tests/e2e/native/chat-operations.spec.ts b/Client/tauri-client/tests/e2e/native/chat-operations.spec.ts new file mode 100644 index 00000000..7b6d3e82 --- /dev/null +++ b/Client/tauri-client/tests/e2e/native/chat-operations.spec.ts @@ -0,0 +1,150 @@ +/** + * Native E2E: Chat operations with real server. + * + * Tests sending messages, receiving echoes, message display, + * and message actions (edit, delete, reactions) against real server. + */ + +import { test, expect } from "../native-fixture"; +import { SKIP_SERVER, hasCredentials, nativeLoginAndReady, waitForMessages } from "./helpers"; + +test.describe("Chat Operations", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + await nativeLoginAndReady(nativePage); + await waitForMessages(nativePage); + }); + + test("message textarea is visible and focusable", async ({ nativePage }) => { + const textarea = nativePage.locator("[data-testid='msg-textarea']"); + await expect(textarea).toBeVisible(); + + await textarea.focus(); + await expect(textarea).toBeFocused(); + }); + + test("can type a message in the textarea", async ({ nativePage }) => { + const textarea = nativePage.locator("[data-testid='msg-textarea']"); + await textarea.fill("native e2e test typing"); + await expect(textarea).toHaveValue("native e2e test typing"); + }); + + test("send button is present", async ({ nativePage }) => { + const sendBtn = nativePage.locator("[data-testid='send-btn']"); + await expect(sendBtn).toBeAttached(); + }); + + test("sending a message clears the textarea", async ({ nativePage }) => { + const textarea = nativePage.locator("[data-testid='msg-textarea']"); + const uniqueMsg = `native-e2e-${Date.now()}`; + + await textarea.fill(uniqueMsg); + await textarea.press("Enter"); + + // Textarea should clear after send + await expect(textarea).toHaveValue("", { timeout: 5_000 }); + }); + + test("sent message appears in message list", async ({ nativePage }) => { + const textarea = nativePage.locator("[data-testid='msg-textarea']"); + const uniqueMsg = `native-e2e-${Date.now()}`; + + await textarea.fill(uniqueMsg); + await textarea.press("Enter"); + + // Message should appear in the list (server echoes it back via WS) + const sentMessage = nativePage.locator(".message .msg-text", { hasText: uniqueMsg }); + await expect(sentMessage).toBeVisible({ timeout: 10_000 }); + }); + + test("message displays author and timestamp", async ({ nativePage }) => { + // Check existing messages have author and time + const firstMessage = nativePage.locator(".message").first(); + const isVisible = await firstMessage.isVisible().catch(() => false); + test.skip(!isVisible, "No messages in current channel"); + + const author = firstMessage.locator(".msg-author"); + const time = firstMessage.locator(".msg-time"); + + // At least one of these should be present (grouped messages may hide author) + const hasAuthor = await author.isVisible().catch(() => false); + const hasTime = await time.isVisible().catch(() => false); + expect(hasAuthor || hasTime).toBe(true); + }); + + test("empty message is not sent", async ({ nativePage }) => { + const textarea = nativePage.locator("[data-testid='msg-textarea']"); + const messagesBefore = await nativePage.locator(".message").count(); + + // Try to send empty message + await textarea.focus(); + await textarea.press("Enter"); + + // Wait a moment, then verify no new message appeared + await nativePage.waitForTimeout(2_000); + const messagesAfter = await nativePage.locator(".message").count(); + expect(messagesAfter).toBe(messagesBefore); + }); + + test("message actions bar appears on hover", async ({ nativePage }) => { + const firstMessage = nativePage.locator(".message").first(); + const isVisible = await firstMessage.isVisible().catch(() => false); + test.skip(!isVisible, "No messages in current channel"); + + await firstMessage.hover(); + + const actionsBar = firstMessage.locator(".msg-actions-bar"); + await expect(actionsBar).toBeAttached({ timeout: 3_000 }); + }); + + test("can send multiple messages in sequence", async ({ nativePage }) => { + const textarea = nativePage.locator("[data-testid='msg-textarea']"); + const timestamp = Date.now(); + + // Send 3 messages with sufficient wait between sends + for (let i = 0; i < 3; i++) { + const msg = `native-seq-${timestamp}-${i}`; + await textarea.fill(msg); + await textarea.press("Enter"); + await expect(textarea).toHaveValue("", { timeout: 5_000 }); + // Small delay between sends to avoid rate limiting + if (i < 2) await nativePage.waitForTimeout(500); + } + + // All 3 should appear + const lastMsg = nativePage.locator(".message .msg-text", { + hasText: `native-seq-${timestamp}-2`, + }); + await expect(lastMsg).toBeVisible({ timeout: 10_000 }); + }); +}); + +test.describe("Chat Message Display", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + await nativeLoginAndReady(nativePage); + await waitForMessages(nativePage); + }); + + test("messages container uses virtual scroll", async ({ nativePage }) => { + const container = nativePage.locator(".messages-container"); + await expect(container).toBeVisible(); + + // Container should have a height (not collapsed) + const height = await container.evaluate((el) => el.getBoundingClientRect().height); + expect(height).toBeGreaterThan(0); + }); + + test("message avatars are displayed", async ({ nativePage }) => { + const messages = nativePage.locator(".message"); + const count = await messages.count(); + test.skip(count === 0, "No messages to check"); + + // At least some messages should have avatars (non-grouped ones) + const avatars = nativePage.locator(".message .msg-avatar"); + const avatarCount = await avatars.count(); + expect(avatarCount).toBeGreaterThanOrEqual(0); // grouped messages may hide them + }); +}); diff --git a/Client/tauri-client/tests/e2e/native/helpers.ts b/Client/tauri-client/tests/e2e/native/helpers.ts new file mode 100644 index 00000000..88b593fb --- /dev/null +++ b/Client/tauri-client/tests/e2e/native/helpers.ts @@ -0,0 +1,90 @@ +/** + * Shared helpers for native E2E tests. + * + * Unlike mocked helpers, these interact with the REAL Tauri app + server. + * No __TAURI_INTERNALS__ mocking — everything is genuine. + */ + +import { type Page, expect } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Environment config +// --------------------------------------------------------------------------- + +export const SERVER_URL = process.env.OWNCORD_SERVER_URL ?? "localhost:8443"; +export const TEST_USER = process.env.OWNCORD_TEST_USER ?? ""; +export const TEST_PASS = process.env.OWNCORD_TEST_PASS ?? ""; +export const SKIP_SERVER = !!process.env.OWNCORD_SKIP_SERVER_TESTS; + +/** Returns true if real server credentials are configured. */ +export function hasCredentials(): boolean { + return TEST_USER.length > 0 && TEST_PASS.length > 0; +} + +// --------------------------------------------------------------------------- +// Login helpers +// --------------------------------------------------------------------------- + +/** + * Perform a real login against the server. + * Requires OWNCORD_TEST_USER and OWNCORD_TEST_PASS env vars. + */ +export async function nativeLogin(page: Page): Promise<void> { + await page.waitForLoadState("networkidle"); + + // Fill the connect form + const hostInput = page.locator("#host"); + await hostInput.clear(); + await hostInput.fill(SERVER_URL); + + await page.locator("#username").fill(TEST_USER); + await page.locator("#password").fill(TEST_PASS); + await page.locator("button.btn-primary[type='submit']").click(); + + // Wait for the main app layout to appear (real server + WS handshake). + // 60s timeout — each test launches a fresh Tauri exe, and rapid + // sequential logins may be rate-limited by the server. + const appLayout = page.locator("[data-testid='app-layout']"); + await expect(appLayout).toBeVisible({ timeout: 60_000 }); +} + +/** + * Login and wait for channels to populate (WS ready handshake complete). + */ +export async function nativeLoginAndReady(page: Page): Promise<void> { + await nativeLogin(page); + + // Wait for at least one channel to appear (proof of WS ready) + const channel = page.locator(".channel-item").first(); + await expect(channel).toBeVisible({ timeout: 15_000 }); +} + +// --------------------------------------------------------------------------- +// Navigation helpers +// --------------------------------------------------------------------------- + +/** + * Click a text channel by its visible name. + */ +export async function selectChannel(page: Page, name: string): Promise<void> { + const channel = page.locator(".channel-item", { hasText: name }); + await channel.click(); + await expect(channel).toHaveClass(/active/, { timeout: 5_000 }); +} + +/** + * Open the settings overlay via the gear button. + */ +export async function openSettings(page: Page): Promise<void> { + await page.locator("button[aria-label='Settings']").click(); + const overlay = page.locator("[data-testid='settings-overlay']"); + await expect(overlay).toHaveClass(/open/, { timeout: 5_000 }); +} + +/** + * Wait for messages to load in the current channel. + */ +export async function waitForMessages(page: Page): Promise<void> { + const container = page.locator(".messages-container"); + await expect(container).toBeVisible({ timeout: 10_000 }); +} diff --git a/Client/tauri-client/tests/e2e/native/overlays.spec.ts b/Client/tauri-client/tests/e2e/native/overlays.spec.ts new file mode 100644 index 00000000..378e9d06 --- /dev/null +++ b/Client/tauri-client/tests/e2e/native/overlays.spec.ts @@ -0,0 +1,221 @@ +/** + * Native E2E: Overlay features (Quick Switcher, Emoji Picker, Invites, Pins). + * + * Tests overlay open/close behavior, keyboard shortcuts, and content + * rendering against the real production app. + */ + +import { test, expect } from "../native-fixture"; +import { SKIP_SERVER, hasCredentials, nativeLoginAndReady } from "./helpers"; + +test.describe("Quick Switcher", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + await nativeLoginAndReady(nativePage); + }); + + test("opens with Ctrl+K keyboard shortcut", async ({ nativePage }) => { + await nativePage.keyboard.press("Control+k"); + + const switcher = nativePage.locator(".quick-switcher-overlay"); + await expect(switcher).toBeVisible({ timeout: 3_000 }); + }); + + test("search input is auto-focused on open", async ({ nativePage }) => { + await nativePage.keyboard.press("Control+k"); + await expect(nativePage.locator(".quick-switcher-overlay")).toBeVisible({ timeout: 3_000 }); + + const searchInput = nativePage.locator(".quick-switcher__input"); + await expect(searchInput).toBeFocused(); + }); + + test("shows channel results from real server", async ({ nativePage }) => { + await nativePage.keyboard.press("Control+k"); + await expect(nativePage.locator(".quick-switcher-overlay")).toBeVisible({ timeout: 3_000 }); + + const items = nativePage.locator(".quick-switcher__item"); + const count = await items.count(); + expect(count).toBeGreaterThan(0); + }); + + test("typing filters results", async ({ nativePage }) => { + await nativePage.keyboard.press("Control+k"); + await expect(nativePage.locator(".quick-switcher-overlay")).toBeVisible({ timeout: 3_000 }); + + const items = nativePage.locator(".quick-switcher__item"); + const initialCount = await items.count(); + test.skip(initialCount < 2, "Need at least 2 items to test filtering"); + + // Type a filter query + await nativePage.locator(".quick-switcher__input").fill("zzz_nonexistent"); + + // Results should decrease or be empty + await expect(async () => { + const filteredCount = await items.count(); + expect(filteredCount).toBeLessThan(initialCount); + }).toPass({ timeout: 3_000 }); + }); + + test("Escape closes the switcher", async ({ nativePage }) => { + await nativePage.keyboard.press("Control+k"); + const switcher = nativePage.locator(".quick-switcher-overlay"); + await expect(switcher).toBeVisible({ timeout: 3_000 }); + + await nativePage.keyboard.press("Escape"); + await expect(switcher).not.toBeVisible({ timeout: 3_000 }); + }); + + test("selecting a result switches channel", async ({ nativePage }) => { + await nativePage.keyboard.press("Control+k"); + await expect(nativePage.locator(".quick-switcher-overlay")).toBeVisible({ timeout: 3_000 }); + + const firstItem = nativePage.locator(".quick-switcher__item").first(); + const isVisible = await firstItem.isVisible().catch(() => false); + test.skip(!isVisible, "No items in quick switcher"); + + const itemText = await firstItem.textContent(); + await nativePage.keyboard.press("Enter"); + + // Switcher should close + await expect(nativePage.locator(".quick-switcher-overlay")).not.toBeVisible({ timeout: 3_000 }); + }); +}); + +test.describe("Emoji Picker", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + await nativeLoginAndReady(nativePage); + }); + + test("emoji button opens picker", async ({ nativePage }) => { + const emojiBtn = nativePage.locator(".emoji-btn"); + const exists = await emojiBtn.isVisible().catch(() => false); + test.skip(!exists, "No emoji button found"); + + await emojiBtn.click(); + + const picker = nativePage.locator(".emoji-picker.open"); + await expect(picker).toBeVisible({ timeout: 3_000 }); + }); + + test("emoji picker has search and grid", async ({ nativePage }) => { + const emojiBtn = nativePage.locator(".emoji-btn"); + const exists = await emojiBtn.isVisible().catch(() => false); + test.skip(!exists, "No emoji button found"); + + await emojiBtn.click(); + await expect(nativePage.locator(".emoji-picker.open")).toBeVisible({ timeout: 3_000 }); + + // Search input + await expect(nativePage.locator(".ep-search")).toBeVisible(); + + // Emoji grid with content + const emojis = nativePage.locator(".ep-emoji"); + const count = await emojis.count(); + expect(count).toBeGreaterThan(0); + }); + + test("clicking emoji inserts it into textarea", async ({ nativePage }) => { + const emojiBtn = nativePage.locator(".emoji-btn"); + const exists = await emojiBtn.isVisible().catch(() => false); + test.skip(!exists, "No emoji button found"); + + await emojiBtn.click(); + await expect(nativePage.locator(".emoji-picker.open")).toBeVisible({ timeout: 3_000 }); + + // Click first emoji + const firstEmoji = nativePage.locator(".ep-emoji").first(); + await firstEmoji.click(); + + // Textarea should contain the emoji + const textarea = nativePage.locator("[data-testid='msg-textarea']"); + const value = await textarea.inputValue(); + expect(value.length).toBeGreaterThan(0); + }); +}); + +test.describe("Pinned Messages", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + await nativeLoginAndReady(nativePage); + }); + + test("pin button triggers pin action", async ({ nativePage }) => { + // The pin button may be a standalone icon, not a data-testid element. + // From production screenshots: it's the 📌 icon in the chat header. + const pinBtn = nativePage.locator("[data-testid='pin-btn'], .pin-btn, button[aria-label='Pins']").first(); + const exists = await pinBtn.isVisible().catch(() => false); + test.skip(!exists, "No pin button in chat header"); + + await pinBtn.click(); + + // The server may fail to load pinned messages (observed in production). + // Either the panel appears OR an error toast appears — both prove the + // real Tauri HTTP plugin made the request. + const panel = nativePage.locator(".pinned-panel"); + const errorToast = nativePage.locator(".toast-error, .toast", { hasText: /pin/i }); + + const result = await Promise.race([ + panel.waitFor({ state: "visible", timeout: 5_000 }).then(() => "panel" as const), + errorToast.waitFor({ state: "visible", timeout: 5_000 }).then(() => "error" as const), + ]).catch(() => "timeout" as const); + + // Either outcome proves the pin button works and makes a real API call + expect(["panel", "error"]).toContain(result); + }); + + test("pinned panel can be closed when available", async ({ nativePage }) => { + const pinBtn = nativePage.locator("[data-testid='pin-btn'], .pin-btn, button[aria-label='Pins']").first(); + const exists = await pinBtn.isVisible().catch(() => false); + test.skip(!exists, "No pin button in chat header"); + + await pinBtn.click(); + + const panel = nativePage.locator(".pinned-panel"); + const panelVisible = await panel.waitFor({ state: "visible", timeout: 5_000 }).then(() => true).catch(() => false); + test.skip(!panelVisible, "Pinned panel did not open (server may not have pin data)"); + + // Close via close button + const closeBtn = nativePage.locator(".pinned-panel__close"); + await closeBtn.click(); + await expect(panel).not.toBeVisible({ timeout: 3_000 }); + }); +}); + +test.describe("Member List Toggle", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + await nativeLoginAndReady(nativePage); + }); + + test("member list toggle hides and shows member list", async ({ nativePage }) => { + const toggleBtn = nativePage.locator("[data-testid='members-toggle']"); + const exists = await toggleBtn.isVisible().catch(() => false); + test.skip(!exists, "No members toggle button"); + + const memberList = nativePage.locator("[data-testid='member-list']"); + const wasVisible = await memberList.isVisible(); + + // Toggle + await toggleBtn.click(); + + if (wasVisible) { + await expect(memberList).not.toBeVisible({ timeout: 3_000 }); + } else { + await expect(memberList).toBeVisible({ timeout: 3_000 }); + } + + // Toggle back + await toggleBtn.click(); + + if (wasVisible) { + await expect(memberList).toBeVisible({ timeout: 3_000 }); + } else { + await expect(memberList).not.toBeVisible({ timeout: 3_000 }); + } + }); +}); diff --git a/Client/tauri-client/tests/e2e/native/settings-overlay.spec.ts b/Client/tauri-client/tests/e2e/native/settings-overlay.spec.ts new file mode 100644 index 00000000..c3cf92dc --- /dev/null +++ b/Client/tauri-client/tests/e2e/native/settings-overlay.spec.ts @@ -0,0 +1,116 @@ +/** + * Native E2E: Settings overlay with real app. + * + * Tests opening/closing settings, tab navigation, theme changes, + * and account settings against the real production build. + */ + +import { test, expect } from "../native-fixture"; +import { SKIP_SERVER, hasCredentials, nativeLoginAndReady, openSettings } from "./helpers"; + +test.describe("Settings Overlay", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + await nativeLoginAndReady(nativePage); + }); + + test("settings overlay opens via gear button", async ({ nativePage }) => { + await openSettings(nativePage); + + const overlay = nativePage.locator("[data-testid='settings-overlay']"); + await expect(overlay).toBeVisible(); + }); + + test("settings has navigation sidebar with tabs", async ({ nativePage }) => { + await openSettings(nativePage); + + const navItems = nativePage.locator(".settings-sidebar button.settings-nav-item"); + const count = await navItems.count(); + expect(count).toBeGreaterThan(0); + }); + + test("can switch between settings tabs", async ({ nativePage }) => { + await openSettings(nativePage); + + const navItems = nativePage.locator(".settings-sidebar button.settings-nav-item"); + const count = await navItems.count(); + test.skip(count < 2, "Need at least 2 settings tabs"); + + // Click the second tab + const secondTab = navItems.nth(1); + await secondTab.click(); + await expect(secondTab).toHaveClass(/active/); + }); + + test("appearance tab exists and is navigable", async ({ nativePage }) => { + await openSettings(nativePage); + + const appearanceTab = nativePage.locator(".settings-sidebar button.settings-nav-item", { + hasText: /appearance/i, + }); + const exists = await appearanceTab.isVisible().catch(() => false); + test.skip(!exists, "No Appearance tab found"); + + await appearanceTab.click(); + await expect(appearanceTab).toHaveClass(/active/); + }); + + test("theme options are displayed in appearance tab", async ({ nativePage }) => { + await openSettings(nativePage); + + const appearanceTab = nativePage.locator(".settings-sidebar button.settings-nav-item", { + hasText: /appearance/i, + }); + const exists = await appearanceTab.isVisible().catch(() => false); + test.skip(!exists, "No Appearance tab found"); + + await appearanceTab.click(); + + // Theme options container with individual theme buttons + const themeOptions = nativePage.locator(".theme-opt"); + const count = await themeOptions.count(); + expect(count).toBeGreaterThan(0); + }); + + test("account tab shows user information", async ({ nativePage }) => { + await openSettings(nativePage); + + const accountTab = nativePage.locator(".settings-sidebar button.settings-nav-item", { + hasText: /account/i, + }); + const exists = await accountTab.isVisible().catch(() => false); + test.skip(!exists, "No Account tab found"); + + await accountTab.click(); + await expect(accountTab).toHaveClass(/active/); + }); + + test("voice/audio tab exists", async ({ nativePage }) => { + await openSettings(nativePage); + + const voiceTab = nativePage.locator(".settings-sidebar button.settings-nav-item", { + hasText: /voice|audio/i, + }); + const exists = await voiceTab.isVisible().catch(() => false); + + if (exists) { + await voiceTab.click(); + await expect(voiceTab).toHaveClass(/active/); + } + // Voice tab may not exist in all builds + }); + + test("settings can be closed with close button or escape", async ({ nativePage }) => { + await openSettings(nativePage); + + const overlay = nativePage.locator("[data-testid='settings-overlay']"); + await expect(overlay).toHaveClass(/open/); + + // Press Escape to close + await nativePage.keyboard.press("Escape"); + + // Overlay should close (class removed or element hidden) + await expect(overlay).not.toHaveClass(/open/, { timeout: 3_000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/native/smoke.spec.ts b/Client/tauri-client/tests/e2e/native/smoke.spec.ts new file mode 100644 index 00000000..1771a3ae --- /dev/null +++ b/Client/tauri-client/tests/e2e/native/smoke.spec.ts @@ -0,0 +1,189 @@ +/** + * Native E2E smoke tests — verify the real Tauri production app works. + * + * These tests launch the actual OwnCord exe and connect via CDP. + * They verify things that CANNOT be caught by mocked browser tests: + * - Real Tauri window loads and renders + * - Real Tauri IPC commands work (__TAURI_INTERNALS__ is real, not mocked) + * - Real HTTP plugin makes actual network requests + * - Real credential store works + * - Window title and metadata match production config + */ + +import { test, expect } from "../native-fixture"; + +test.describe("Native App Smoke Tests", () => { + test("app window loads with correct title", async ({ nativePage }) => { + // The real Tauri app should set the window title from tauri.conf.json + const title = await nativePage.title(); + expect(title).toBe("OwnCord"); + }); + + test("app renders the connect page on first launch", async ({ nativePage }) => { + // On first launch (no saved credentials), the app should show the connect page. + // Wait for the page to fully render. + await nativePage.waitForLoadState("networkidle"); + + // The connect page should have the host/username/password fields + const hostInput = nativePage.locator("#host"); + await expect(hostInput).toBeVisible({ timeout: 15_000 }); + + const usernameInput = nativePage.locator("#username"); + await expect(usernameInput).toBeVisible(); + + const passwordInput = nativePage.locator("#password"); + await expect(passwordInput).toBeVisible(); + }); + + test("real __TAURI_INTERNALS__ is present (not mocked)", async ({ nativePage }) => { + // In the real app, __TAURI_INTERNALS__ is injected by Tauri, not by our mock script. + // Verify it exists and has the expected structure. + const hasTauriInternals = await nativePage.evaluate(() => { + return typeof (window as any).__TAURI_INTERNALS__ !== "undefined"; + }); + expect(hasTauriInternals).toBe(true); + + // Verify it has the real invoke function (not our mock) + const hasInvoke = await nativePage.evaluate(() => { + return typeof (window as any).__TAURI_INTERNALS__?.invoke === "function"; + }); + expect(hasInvoke).toBe(true); + + // Our mock sets metadata.currentWindow.label — the real one does too, + // but it's injected differently. Verify the structure exists. + const hasMetadata = await nativePage.evaluate(() => { + const t = (window as any).__TAURI_INTERNALS__; + return t?.metadata?.currentWindow?.label === "main"; + }); + expect(hasMetadata).toBe(true); + }); + + test("CSS and styles load correctly in production", async ({ nativePage }) => { + await nativePage.waitForLoadState("networkidle"); + + // Verify that stylesheets are loaded (production build bundles CSS) + const styleSheetCount = await nativePage.evaluate(() => { + return document.styleSheets.length; + }); + expect(styleSheetCount).toBeGreaterThan(0); + + // Verify the app container exists and has dimensions + const appContainer = await nativePage.evaluate(() => { + const app = document.getElementById("app"); + if (!app) return null; + const rect = app.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + expect(appContainer).not.toBeNull(); + expect(appContainer!.width).toBeGreaterThan(0); + expect(appContainer!.height).toBeGreaterThan(0); + }); + + test("window dimensions match tauri.conf.json defaults", async ({ nativePage }) => { + // tauri.conf.json specifies 1280x720 default window size + const viewport = nativePage.viewportSize(); + // WebView2 viewport may not be exactly 1280x720 due to window chrome, + // but it should be close. Check it's reasonable. + if (viewport) { + expect(viewport.width).toBeGreaterThan(800); + expect(viewport.height).toBeGreaterThan(400); + } + }); +}); + +test.describe("Native App Server Connection", () => { + test("health check via real Tauri HTTP plugin", async ({ nativePage }) => { + // This test requires chatserver.exe to be running. + // Skip if OWNCORD_SKIP_SERVER_TESTS is set. + test.skip( + !!process.env.OWNCORD_SKIP_SERVER_TESTS, + "Skipped: OWNCORD_SKIP_SERVER_TESTS is set", + ); + + await nativePage.waitForLoadState("networkidle"); + + // The connect page auto-pings saved servers on load. + // If a saved server profile exists (e.g. "localhost:8443"), the sidebar + // shows a .server-item with a .srv-latency badge showing the ping time. + // This proves the real Tauri HTTP plugin made a network request. + const serverItem = nativePage.locator(".server-item").first(); + const hasServer = await serverItem.isVisible().catch(() => false); + + if (hasServer) { + // A saved server exists — wait for latency to populate (proves real HTTP) + const latencyBadge = serverItem.locator(".srv-latency"); + await expect(latencyBadge).toHaveText(/\d+ms/, { timeout: 10_000 }); + } else { + // No saved server — fill in host and verify server-side response. + // The health check happens when the server profile is pinged. + const hostInput = nativePage.locator("#host"); + await hostInput.fill("localhost:8443"); + await hostInput.press("Tab"); + + // Give the health check time, then verify the form is still functional + // (no crash = real HTTP plugin loaded correctly) + await nativePage.waitForTimeout(3_000); + await expect(nativePage.locator("#host")).toHaveValue("localhost:8443"); + } + }); + + test("login attempt reaches real server", async ({ nativePage }) => { + // This test verifies the real Tauri HTTP plugin makes actual API calls. + // It does NOT require valid credentials — an "invalid credentials" error + // from the server proves the round-trip works. + // Skip if OWNCORD_SKIP_SERVER_TESTS is set. + test.skip( + !!process.env.OWNCORD_SKIP_SERVER_TESTS, + "Skipped: OWNCORD_SKIP_SERVER_TESTS is set", + ); + + await nativePage.waitForLoadState("networkidle"); + + // Fill login form — use env vars for real creds, or dummy creds to prove API round-trip + const serverUrl = process.env.OWNCORD_SERVER_URL ?? "localhost:8443"; + const username = process.env.OWNCORD_TEST_USER ?? "e2e-native-test"; + const password = process.env.OWNCORD_TEST_PASS ?? "e2e-native-test"; + + await nativePage.locator("#host").fill(serverUrl); + await nativePage.locator("#username").fill(username); + await nativePage.locator("#password").fill(password); + await nativePage.locator("button.btn-primary[type='submit']").click(); + + // Wait for either: successful login OR server error response. + // Both prove the real HTTP plugin made a round-trip to the server. + const appLayout = nativePage.locator("[data-testid='app-layout']"); + const errorBanner = nativePage.locator(".error-banner, .error-message, .toast-error, [role='alert']"); + + // Use Promise.race — whichever appears first + const result = await Promise.race([ + appLayout.waitFor({ state: "visible", timeout: 20_000 }) + .then(() => "login-success" as const), + errorBanner.waitFor({ state: "visible", timeout: 20_000 }) + .then(() => "login-error" as const), + ]).catch(() => "timeout" as const); + + // Either outcome proves the real Tauri HTTP plugin works + expect(["login-success", "login-error"]).toContain(result); + }); +}); + +test.describe("Native App Credential Store", () => { + test("credential commands are available", async ({ nativePage }) => { + // Verify the real Tauri credential commands exist + // (save_credential, load_credential, delete_credential) + const canInvoke = await nativePage.evaluate(async () => { + try { + const result = await (window as any).__TAURI_INTERNALS__.invoke( + "load_credential", + { host: "e2e-test-nonexistent" }, + ); + // Should return null for nonexistent host, not throw + return result === null || result === undefined; + } catch (e: any) { + // If the command doesn't exist, it throws + return false; + } + }); + expect(canInvoke).toBe(true); + }); +}); diff --git a/Client/tauri-client/tests/e2e/native/voice-controls.spec.ts b/Client/tauri-client/tests/e2e/native/voice-controls.spec.ts new file mode 100644 index 00000000..eedec33f --- /dev/null +++ b/Client/tauri-client/tests/e2e/native/voice-controls.spec.ts @@ -0,0 +1,127 @@ +/** + * Native E2E: Voice channel controls with real app. + * + * Tests voice channel UI, mute/deafen buttons, voice widget rendering, + * and disconnect flow. Does NOT test actual WebRTC (no mic/audio). + */ + +import { test, expect } from "../native-fixture"; +import { SKIP_SERVER, hasCredentials, nativeLoginAndReady } from "./helpers"; + +test.describe("Voice Channel UI", () => { + test.beforeEach(async ({ nativePage }) => { + test.skip(SKIP_SERVER, "Skipped: OWNCORD_SKIP_SERVER_TESTS is set"); + test.skip(!hasCredentials(), "Skipped: OWNCORD_TEST_USER/OWNCORD_TEST_PASS not set"); + await nativeLoginAndReady(nativePage); + }); + + test("voice channels are listed with speaker icon", async ({ nativePage }) => { + const voiceIcons = nativePage.locator(".channel-item .ch-icon", { hasText: "🔊" }); + const count = await voiceIcons.count(); + test.skip(count === 0, "No voice channels on this server"); + + await expect(voiceIcons.first()).toBeVisible(); + }); + + test("voice channel names are displayed", async ({ nativePage }) => { + const voiceChannels = nativePage.locator(".channel-item").filter({ + has: nativePage.locator(".ch-icon", { hasText: "🔊" }), + }); + const count = await voiceChannels.count(); + test.skip(count === 0, "No voice channels on this server"); + + const name = await voiceChannels.first().locator(".ch-name").textContent(); + expect(name?.trim().length).toBeGreaterThan(0); + }); + + test("clicking voice channel triggers voice join", async ({ nativePage }) => { + const voiceChannels = nativePage.locator(".channel-item").filter({ + has: nativePage.locator(".ch-icon", { hasText: "🔊" }), + }); + const count = await voiceChannels.count(); + test.skip(count === 0, "No voice channels on this server"); + + await voiceChannels.first().click(); + + // Voice widget should appear (may take time for WebRTC setup) + const voiceWidget = nativePage.locator(".voice-widget.visible"); + await expect(voiceWidget).toBeVisible({ timeout: 10_000 }); + }); + + test("voice widget shows channel name", async ({ nativePage }) => { + const voiceChannels = nativePage.locator(".channel-item").filter({ + has: nativePage.locator(".ch-icon", { hasText: "🔊" }), + }); + const count = await voiceChannels.count(); + test.skip(count === 0, "No voice channels on this server"); + + const channelName = await voiceChannels.first().locator(".ch-name").textContent(); + await voiceChannels.first().click(); + + const voiceWidget = nativePage.locator(".voice-widget.visible"); + await expect(voiceWidget).toBeVisible({ timeout: 10_000 }); + + const widgetChannel = voiceWidget.locator(".vw-channel"); + await expect(widgetChannel).toContainText(channelName?.trim() ?? ""); + }); + + test("voice widget has control buttons", async ({ nativePage }) => { + const voiceChannels = nativePage.locator(".channel-item").filter({ + has: nativePage.locator(".ch-icon", { hasText: "🔊" }), + }); + const count = await voiceChannels.count(); + test.skip(count === 0, "No voice channels on this server"); + + await voiceChannels.first().click(); + const voiceWidget = nativePage.locator(".voice-widget.visible"); + await expect(voiceWidget).toBeVisible({ timeout: 10_000 }); + + // All control buttons should be present + await expect(voiceWidget.locator("button[aria-label='Mute']")).toBeVisible({ timeout: 5_000 }); + await expect(voiceWidget.locator("button[aria-label='Deafen']")).toBeVisible(); + await expect(voiceWidget.locator("button[aria-label='Disconnect']")).toBeVisible(); + }); + + test("mute button toggles active state", async ({ nativePage }) => { + const voiceChannels = nativePage.locator(".channel-item").filter({ + has: nativePage.locator(".ch-icon", { hasText: "🔊" }), + }); + const count = await voiceChannels.count(); + test.skip(count === 0, "No voice channels on this server"); + + await voiceChannels.first().click(); + const voiceWidget = nativePage.locator(".voice-widget.visible"); + await expect(voiceWidget).toBeVisible({ timeout: 10_000 }); + + const muteBtn = voiceWidget.locator("button[aria-label='Mute']"); + await expect(muteBtn).toBeVisible({ timeout: 5_000 }); + + // Toggle mute + await muteBtn.click(); + const hasActive = await muteBtn.evaluate((el) => el.classList.contains("active-ctrl")); + expect(typeof hasActive).toBe("boolean"); + + // Toggle back + await muteBtn.click(); + }); + + test("disconnect button leaves voice channel", async ({ nativePage }) => { + const voiceChannels = nativePage.locator(".channel-item").filter({ + has: nativePage.locator(".ch-icon", { hasText: "🔊" }), + }); + const count = await voiceChannels.count(); + test.skip(count === 0, "No voice channels on this server"); + + await voiceChannels.first().click(); + const voiceWidget = nativePage.locator(".voice-widget.visible"); + await expect(voiceWidget).toBeVisible({ timeout: 10_000 }); + + // Click disconnect + const disconnectBtn = voiceWidget.locator("button[aria-label='Disconnect']"); + await expect(disconnectBtn).toBeVisible({ timeout: 5_000 }); + await disconnectBtn.click(); + + // Voice widget should disappear + await expect(voiceWidget).not.toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/overlays.spec.ts b/Client/tauri-client/tests/e2e/overlays.spec.ts new file mode 100644 index 00000000..98854d36 --- /dev/null +++ b/Client/tauri-client/tests/e2e/overlays.spec.ts @@ -0,0 +1,289 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, mockTauriFullSessionWithMessages, navigateToMainPage } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Quick Switcher (Ctrl+K) +// --------------------------------------------------------------------------- + +test.describe("Quick Switcher", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("Ctrl+K opens quick switcher", async ({ page }) => { + await page.keyboard.press("Control+k"); + + const overlay = page.locator(".quick-switcher-overlay"); + await expect(overlay).toBeVisible({ timeout: 3_000 }); + }); + + test("quick switcher has search input", async ({ page }) => { + await page.keyboard.press("Control+k"); + + const input = page.locator(".quick-switcher__input"); + await expect(input).toBeVisible({ timeout: 3_000 }); + await expect(input).toBeFocused(); + }); + + test("quick switcher shows channel results", async ({ page }) => { + await page.keyboard.press("Control+k"); + + const results = page.locator(".quick-switcher__item"); + await expect(results.first()).toBeVisible({ timeout: 3_000 }); + }); + + test("first result is highlighted by default", async ({ page }) => { + await page.keyboard.press("Control+k"); + + const active = page.locator(".quick-switcher__item--active"); + await expect(active).toBeVisible({ timeout: 3_000 }); + }); + + test("Escape closes quick switcher", async ({ page }) => { + await page.keyboard.press("Control+k"); + await expect(page.locator(".quick-switcher-overlay")).toBeVisible({ timeout: 3_000 }); + + await page.keyboard.press("Escape"); + await expect(page.locator(".quick-switcher-overlay")).not.toBeVisible(); + }); + + test("clicking overlay backdrop closes quick switcher", async ({ page }) => { + await page.keyboard.press("Control+k"); + const overlay = page.locator(".quick-switcher-overlay"); + await expect(overlay).toBeVisible({ timeout: 3_000 }); + + // Click the backdrop (not the modal) + await overlay.click({ position: { x: 10, y: 10 } }); + await expect(overlay).not.toBeVisible(); + }); + + test("typing in search filters results", async ({ page }) => { + await page.keyboard.press("Control+k"); + const input = page.locator(".quick-switcher__input"); + await expect(input).toBeVisible({ timeout: 3_000 }); + + const initialCount = await page.locator(".quick-switcher__item").count(); + + await input.fill("general"); + await expect.poll( + async () => page.locator(".quick-switcher__item").count(), + { timeout: 2000 }, + ).toBeGreaterThan(0); + + const filteredCount = await page.locator(".quick-switcher__item").count(); + expect(filteredCount).toBeLessThanOrEqual(initialCount); + expect(filteredCount).toBeGreaterThanOrEqual(1); + }); + + test("Enter selects highlighted result", async ({ page }) => { + await page.keyboard.press("Control+k"); + await expect(page.locator(".quick-switcher__item").first()).toBeVisible({ timeout: 3_000 }); + + await page.keyboard.press("Enter"); + await expect(page.locator(".quick-switcher-overlay")).not.toBeVisible(); + }); + + test("arrow keys navigate results", async ({ page }) => { + await page.keyboard.press("Control+k"); + const firstItem = page.locator(".quick-switcher__item").first(); + await expect(firstItem).toBeVisible({ timeout: 3_000 }); + + // First item should start as active + await expect(firstItem).toHaveClass(/quick-switcher__item--active/); + + await page.keyboard.press("ArrowDown"); + + // After ArrowDown, second item should be active and first should not + const secondItem = page.locator(".quick-switcher__item").nth(1); + await expect(secondItem).toHaveClass(/quick-switcher__item--active/); + await expect(firstItem).not.toHaveClass(/quick-switcher__item--active/); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Emoji Picker +// --------------------------------------------------------------------------- + +test.describe("Emoji Picker", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("emoji button opens emoji picker", async ({ page }) => { + const emojiBtn = page.locator(".emoji-btn"); + await emojiBtn.click(); + + const picker = page.locator(".emoji-picker.open"); + await expect(picker).toBeVisible({ timeout: 3_000 }); + }); + + test("emoji picker has search input", async ({ page }) => { + await page.locator(".emoji-btn").click(); + + const search = page.locator(".ep-search"); + await expect(search).toBeVisible({ timeout: 3_000 }); + }); + + test("emoji picker shows emoji grid", async ({ page }) => { + await page.locator(".emoji-btn").click(); + + const grid = page.locator(".ep-grid"); + await expect(grid.first()).toBeVisible({ timeout: 3_000 }); + }); + + test("emoji picker shows category labels", async ({ page }) => { + await page.locator(".emoji-btn").click(); + + const categoryLabel = page.locator(".ep-category-label"); + await expect(categoryLabel.first()).toBeVisible({ timeout: 3_000 }); + }); + + test("emoji picker has clickable emojis", async ({ page }) => { + await page.locator(".emoji-btn").click(); + + const emoji = page.locator(".ep-emoji"); + await expect(emoji.first()).toBeVisible({ timeout: 3_000 }); + }); + + test("searching filters emojis", async ({ page }) => { + await page.locator(".emoji-btn").click(); + + const search = page.locator(".ep-search"); + await expect(search).toBeVisible({ timeout: 3_000 }); + + // Get count before filtering + const allEmojis = page.locator(".ep-emoji"); + const countBefore = await allEmojis.count(); + expect(countBefore).toBeGreaterThan(10); + + // Search for a specific emoji character that exists in the grid + await search.fill("\uD83D\uDE00"); + await expect.poll( + async () => page.locator(".ep-emoji").count(), + { timeout: 2000 }, + ).toBeGreaterThan(0); + + const countAfter = await allEmojis.count(); + // After filtering, should have fewer results + expect(countAfter).toBeLessThan(countBefore); + expect(countAfter).toBeGreaterThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Invite Manager +// --------------------------------------------------------------------------- + +test.describe("Invite Manager", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithMessages(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("invite button opens invite manager overlay", async ({ page }) => { + const inviteBtn = page.getByRole("button", { name: /invite/i }); + await expect(inviteBtn).toBeVisible({ timeout: 3_000 }); + await inviteBtn.click(); + + const overlay = page.locator(".invite-manager-overlay"); + await expect(overlay).toBeVisible({ timeout: 3_000 }); + }); + + test("invite manager shows invite list", async ({ page }) => { + await page.getByRole("button", { name: /invite/i }).click(); + + const items = page.locator(".invite-item"); + await expect(items.first()).toBeVisible({ timeout: 3_000 }); + }); + + test("invite manager has create invite button", async ({ page }) => { + await page.getByRole("button", { name: /invite/i }).click(); + + const createBtn = page.locator(".invite-manager__create"); + await expect(createBtn).toBeVisible({ timeout: 3_000 }); + }); + + test("Escape closes invite manager", async ({ page }) => { + await page.getByRole("button", { name: /invite/i }).click(); + const overlay = page.locator(".invite-manager-overlay"); + await expect(overlay).toBeVisible({ timeout: 3_000 }); + + await page.keyboard.press("Escape"); + await expect(overlay).not.toBeVisible(); + }); + + test("clicking overlay backdrop closes invite manager", async ({ page }) => { + await page.getByRole("button", { name: /invite/i }).click(); + const overlay = page.locator(".invite-manager-overlay"); + await expect(overlay).toBeVisible({ timeout: 3_000 }); + + // Click the backdrop (not the modal) + await overlay.click({ position: { x: 10, y: 10 } }); + await expect(overlay).not.toBeVisible(); + }); + + test("close button closes invite manager", async ({ page }) => { + await page.getByRole("button", { name: /invite/i }).click(); + const overlay = page.locator(".invite-manager-overlay"); + await expect(overlay).toBeVisible({ timeout: 3_000 }); + + await page.locator(".invite-manager__close").click(); + await expect(overlay).not.toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Pinned Messages +// --------------------------------------------------------------------------- + +test.describe("Pinned Messages", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("pin button exists in chat header tools", async ({ page }) => { + const pinBtn = page.locator("[data-testid='pin-btn']"); + await expect(pinBtn).toBeVisible({ timeout: 3_000 }); + }); + + test("clicking pin button opens pinned panel", async ({ page }) => { + const pinBtn = page.locator("[data-testid='pin-btn']"); + await pinBtn.click(); + + const panel = page.locator(".pinned-panel"); + await expect(panel).toBeVisible({ timeout: 3_000 }); + }); + + test("pinned panel has close button", async ({ page }) => { + await page.locator("[data-testid='pin-btn']").click(); + + const closeBtn = page.locator(".pinned-panel__close"); + await expect(closeBtn).toBeVisible({ timeout: 3_000 }); + }); + + test("close button closes pinned panel", async ({ page }) => { + await page.locator("[data-testid='pin-btn']").click(); + const panel = page.locator(".pinned-panel"); + await expect(panel).toBeVisible({ timeout: 3_000 }); + + await page.locator(".pinned-panel__close").click(); + await expect(panel).not.toBeVisible(); + }); + + test("clicking pin button again closes pinned panel", async ({ page }) => { + const pinBtn = page.locator("[data-testid='pin-btn']"); + await pinBtn.click(); + const panel = page.locator(".pinned-panel"); + await expect(panel).toBeVisible({ timeout: 3_000 }); + + await pinBtn.click(); + await expect(panel).not.toBeVisible(); + }); +}); diff --git a/Client/tauri-client/tests/e2e/register-flow.spec.ts b/Client/tauri-client/tests/e2e/register-flow.spec.ts new file mode 100644 index 00000000..793a53dc --- /dev/null +++ b/Client/tauri-client/tests/e2e/register-flow.spec.ts @@ -0,0 +1,186 @@ +/** + * E2E tests for the registration flow. + * Covers: mode toggle, form validation, register success, register error. + */ +import { test, expect } from "@playwright/test"; +import { buildTauriMockScript, MOCK_LOGIN_RESPONSE } from "./helpers"; + +const MOCK_REGISTER_RESPONSE = { + user: { id: 99, username: "newuser" }, + token: "register-token-abc", +}; + +async function mockRegisterSuccess(page: import("@playwright/test").Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/register", status: 200, body: MOCK_REGISTER_RESPONSE }, + ], + simulateWsFlow: true, + })); +} + +async function mockRegisterConflict(page: import("@playwright/test").Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/register", status: 409, body: { error: "USERNAME_TAKEN", message: "Username already exists" } }, + ], + simulateWsFlow: false, + })); +} + +async function switchToRegisterMode(page: import("@playwright/test").Page): Promise<void> { + const toggleLink = page.locator(".form-switch a"); + await toggleLink.click(); + // Verify we're in register mode + await expect(page.locator(".btn-text")).toHaveText("Register"); +} + +test.describe("Register Flow — Mode Toggle", () => { + test.beforeEach(async ({ page }) => { + await mockRegisterSuccess(page); + await page.goto("/"); + }); + + test("clicking toggle switches to register mode", async ({ page }) => { + await switchToRegisterMode(page); + + // Invite code field should be visible + const inviteGroup = page.locator("#invite").locator(".."); + await expect(inviteGroup).not.toHaveClass(/form-group--hidden/); + }); + + test("register mode shows invite code field", async ({ page }) => { + await switchToRegisterMode(page); + + const inviteInput = page.locator("#invite"); + await expect(inviteInput).toBeVisible(); + }); + + test("toggle back to login hides invite code field", async ({ page }) => { + await switchToRegisterMode(page); + // Toggle back + const toggleLink = page.locator(".form-switch a"); + await toggleLink.click(); + + await expect(page.locator(".btn-text")).toHaveText("Login"); + + // Invite field parent should be hidden + const inviteGroup = page.locator("#invite").locator(".."); + await expect(inviteGroup).toHaveClass(/form-group--hidden/); + }); +}); + +test.describe("Register Flow — Validation", () => { + test.beforeEach(async ({ page }) => { + await mockRegisterSuccess(page); + await page.goto("/"); + await switchToRegisterMode(page); + }); + + test("empty invite code shows validation error", async ({ page }) => { + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("newuser"); + await page.locator("#password").fill("password123"); + // Leave invite code empty + + await page.locator(".btn-primary[type='submit']").click(); + + const errorBanner = page.locator(".error-banner"); + await expect(errorBanner).toHaveClass(/visible/, { timeout: 3000 }); + await expect(errorBanner).toContainText("Invite code is required"); + }); + + test("short password shows validation error", async ({ page }) => { + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("newuser"); + await page.locator("#password").fill("short"); + await page.locator("#invite").fill("invite123"); + + await page.locator(".btn-primary[type='submit']").click(); + + const errorBanner = page.locator(".error-banner"); + await expect(errorBanner).toHaveClass(/visible/, { timeout: 3000 }); + await expect(errorBanner).toContainText("at least 8 characters"); + }); + + test("empty username shows validation error", async ({ page }) => { + await page.locator("#host").fill("localhost:8443"); + // Leave username empty + await page.locator("#password").fill("password123"); + await page.locator("#invite").fill("invite123"); + + await page.locator(".btn-primary[type='submit']").click(); + + const errorBanner = page.locator(".error-banner"); + await expect(errorBanner).toHaveClass(/visible/, { timeout: 3000 }); + await expect(errorBanner).toContainText("Username is required"); + }); + + test("empty host shows validation error", async ({ page }) => { + // Leave host empty (clear the default) + await page.locator("#host").fill(""); + await page.locator("#username").fill("newuser"); + await page.locator("#password").fill("password123"); + await page.locator("#invite").fill("invite123"); + + await page.locator(".btn-primary[type='submit']").click(); + + const errorBanner = page.locator(".error-banner"); + await expect(errorBanner).toHaveClass(/visible/, { timeout: 3000 }); + await expect(errorBanner).toContainText("Server address is required"); + }); +}); + +test.describe("Register Flow — Submission", () => { + test("successful register transitions to connected state", async ({ page }) => { + await mockRegisterSuccess(page); + await page.goto("/"); + await switchToRegisterMode(page); + + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("newuser"); + await page.locator("#password").fill("password123"); + await page.locator("#invite").fill("invite-abc"); + + await page.locator(".btn-primary[type='submit']").click(); + + // Should transition to the connected overlay + const overlay = page.locator(".connected-overlay"); + await expect(overlay).toBeVisible({ timeout: 5000 }); + }); + + test("register shows loading state during submission", async ({ page }) => { + await mockRegisterSuccess(page); + await page.goto("/"); + await switchToRegisterMode(page); + + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("newuser"); + await page.locator("#password").fill("password123"); + await page.locator("#invite").fill("invite-abc"); + + // Submit and verify the form completes successfully + await page.locator(".btn-primary[type='submit']").click(); + + // The form should eventually complete and show the connected overlay + await expect(page.locator(".connected-overlay")).toBeVisible({ timeout: 5000 }); + }); + + test("register error shows error banner", async ({ page }) => { + await mockRegisterConflict(page); + await page.goto("/"); + await switchToRegisterMode(page); + + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("existinguser"); + await page.locator("#password").fill("password123"); + await page.locator("#invite").fill("invite-abc"); + + await page.locator(".btn-primary[type='submit']").click(); + + const errorBanner = page.locator(".error-banner"); + await expect(errorBanner).toHaveClass(/visible/, { timeout: 5000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/reply-flow.spec.ts b/Client/tauri-client/tests/e2e/reply-flow.spec.ts new file mode 100644 index 00000000..31c016de --- /dev/null +++ b/Client/tauri-client/tests/e2e/reply-flow.spec.ts @@ -0,0 +1,90 @@ +/** + * E2E tests for the reply-to message flow. + * Covers: click reply → see reply bar → send reply → verify. + */ +import { test, expect } from "@playwright/test"; +import { + mockTauriFullSessionWithMessagesAndEcho, + navigateToMainPage, +} from "./helpers"; + +test.describe("Reply Flow", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithMessagesAndEcho(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("clicking Reply shows reply bar", async ({ page }) => { + const firstMessage = page.locator("[data-testid='message-101']"); + await firstMessage.hover(); + await page.locator("[data-testid='msg-reply-101']").click(); + + const replyBar = page.locator(".reply-bar.visible"); + await expect(replyBar).toBeVisible({ timeout: 3000 }); + }); + + test("reply bar shows the referenced author name", async ({ page }) => { + const firstMessage = page.locator("[data-testid='message-101']"); + await firstMessage.hover(); + await page.locator("[data-testid='msg-reply-101']").click(); + + const replyBar = page.locator(".reply-bar.visible"); + await expect(replyBar).toContainText("testuser"); + }); + + test("sending a reply clears the reply bar", async ({ page }) => { + const firstMessage = page.locator("[data-testid='message-101']"); + await firstMessage.hover(); + await page.locator("[data-testid='msg-reply-101']").click(); + + // Verify reply bar is shown + const replyBar = page.locator(".reply-bar.visible"); + await expect(replyBar).toBeVisible(); + + // Type and send reply + const textarea = page.locator("[data-testid='msg-textarea']"); + await textarea.fill("This is my reply"); + await textarea.press("Enter"); + + // Reply bar should be hidden after sending + await expect(replyBar).not.toBeVisible({ timeout: 5000 }); + }); + + test("reply message appears with reply reference", async ({ page }) => { + const firstMessage = page.locator("[data-testid='message-101']"); + await firstMessage.hover(); + await page.locator("[data-testid='msg-reply-101']").click(); + + const textarea = page.locator("[data-testid='msg-textarea']"); + await textarea.fill("Replying to you!"); + await textarea.press("Enter"); + + // The new reply message should appear with a reply reference + const newReply = page.locator(".message .msg-text", { + hasText: "Replying to you!", + }); + await expect(newReply).toBeVisible({ timeout: 5000 }); + + // The reply message should also contain a reply reference element + const replyMessage = page.locator(".message", { + has: page.locator(".msg-text", { hasText: "Replying to you!" }), + }); + const replyRef = replyMessage.locator(".msg-reply-ref"); + await expect(replyRef).toBeVisible({ timeout: 3000 }); + }); + + test("cancel button on reply bar dismisses it", async ({ page }) => { + const firstMessage = page.locator("[data-testid='message-101']"); + await firstMessage.hover(); + await page.locator("[data-testid='msg-reply-101']").click(); + + const replyBar = page.locator(".reply-bar.visible"); + await expect(replyBar).toBeVisible(); + + // Click the close button on the reply bar + const cancelBtn = replyBar.locator(".reply-close"); + await cancelBtn.click(); + await expect(replyBar).not.toBeVisible(); + }); +}); diff --git a/Client/tauri-client/tests/e2e/server-strip.spec.ts b/Client/tauri-client/tests/e2e/server-strip.spec.ts new file mode 100644 index 00000000..9033a330 --- /dev/null +++ b/Client/tauri-client/tests/e2e/server-strip.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPage } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Server Strip +// --------------------------------------------------------------------------- + +test.describe("Server Strip", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("server strip is visible with server icons", async ({ page }) => { + const strip = page.locator("[data-testid='server-strip']"); + await expect(strip).toBeVisible(); + + const icons = strip.locator(".server-icon"); + await expect(icons.first()).toBeVisible(); + }); + + test("active server icon shows home initial 'O'", async ({ page }) => { + const activeIcon = page.locator("[data-testid='server-strip'] .server-icon.active"); + await expect(activeIcon).toBeVisible(); + await expect(activeIcon).toHaveText("O"); + }); + + test("server separator exists between icons", async ({ page }) => { + const separator = page.locator("[data-testid='server-strip'] .server-separator"); + await expect(separator).toBeAttached(); + }); + + test("add server button shows '+' icon", async ({ page }) => { + const addBtn = page.locator("[data-testid='server-strip'] .server-icon.add"); + await expect(addBtn).toBeVisible(); + await expect(addBtn).toHaveText("+"); + }); +}); diff --git a/Client/tauri-client/tests/e2e/settings-overlay.spec.ts b/Client/tauri-client/tests/e2e/settings-overlay.spec.ts new file mode 100644 index 00000000..c62429aa --- /dev/null +++ b/Client/tauri-client/tests/e2e/settings-overlay.spec.ts @@ -0,0 +1,279 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPage, openSettings, switchSettingsTab } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Settings Overlay — structure +// --------------------------------------------------------------------------- + +test.describe("Settings Overlay", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("settings overlay opens from user bar", async ({ page }) => { + await openSettings(page); + + const overlay = page.locator("[data-testid='settings-overlay']"); + await expect(overlay).toHaveClass(/open/); + }); + + test("settings overlay has sidebar with tabs", async ({ page }) => { + await openSettings(page); + + const sidebar = page.locator(".settings-sidebar"); + await expect(sidebar).toBeVisible(); + + const tabs = sidebar.locator("button.settings-nav-item"); + const count = await tabs.count(); + expect(count).toBeGreaterThanOrEqual(5); + }); + + test("settings overlay starts on Account tab", async ({ page }) => { + await openSettings(page); + + const activeTab = page.locator(".settings-sidebar button.settings-nav-item.active"); + await expect(activeTab).toHaveText("Account"); + }); + + test("close button closes settings", async ({ page }) => { + await openSettings(page); + + const closeBtn = page.locator(".settings-close-btn"); + await closeBtn.click(); + + const overlay = page.locator("[data-testid='settings-overlay']"); + await expect(overlay).not.toHaveClass(/open/); + }); + + test("Escape key closes settings", async ({ page }) => { + await openSettings(page); + + await page.keyboard.press("Escape"); + + const overlay = page.locator("[data-testid='settings-overlay']"); + await expect(overlay).not.toHaveClass(/open/); + }); + + test("has Log Out button with danger class", async ({ page }) => { + await openSettings(page); + + const logoutBtn = page.locator(".settings-nav-item.danger"); + await expect(logoutBtn).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Settings — Account tab +// --------------------------------------------------------------------------- + +test.describe("Settings — Account Tab", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + await openSettings(page); + }); + + test("shows username in account card", async ({ page }) => { + const name = page.locator(".ac-name"); + await expect(name).toHaveText("testuser"); + }); + + test("shows account avatar", async ({ page }) => { + const avatar = page.locator(".ac-avatar"); + await expect(avatar).toBeVisible(); + }); + + test("has password change fields", async ({ page }) => { + const passwordInputs = page.locator(".settings-content input[type='password']"); + const count = await passwordInputs.count(); + expect(count).toBeGreaterThanOrEqual(2); + }); + + test("has Change Password button", async ({ page }) => { + const changePwBtn = page.locator(".ac-btn", { hasText: "Change Password" }); + await expect(changePwBtn).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Settings — Appearance tab +// --------------------------------------------------------------------------- + +test.describe("Settings — Appearance Tab", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + await openSettings(page); + + await switchSettingsTab(page, "Appearance"); + }); + + test("shows theme options", async ({ page }) => { + const themeOptions = page.locator(".theme-opt"); + const count = await themeOptions.count(); + expect(count).toBeGreaterThanOrEqual(2); + }); + + test("clicking theme option activates it", async ({ page }) => { + const themeOptions = page.locator(".theme-opt"); + const second = themeOptions.nth(1); + await second.click(); + + await expect(second).toHaveClass(/active/); + }); + + test("shows font size slider", async ({ page }) => { + const slider = page.locator(".settings-slider").first(); + await expect(slider).toBeVisible(); + }); + + test("shows compact mode toggle", async ({ page }) => { + const toggle = page.locator(".toggle").first(); + await expect(toggle).toBeVisible(); + }); + + test("toggling compact mode changes toggle state", async ({ page }) => { + const toggle = page.locator(".toggle").first(); + const initialOn = await toggle.evaluate((el) => el.classList.contains("on")); + + await toggle.click(); + const afterOn = await toggle.evaluate((el) => el.classList.contains("on")); + expect(afterOn).not.toBe(initialOn); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Settings — Notifications tab +// --------------------------------------------------------------------------- + +test.describe("Settings — Notifications Tab", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + await openSettings(page); + await switchSettingsTab(page, "Notifications"); + }); + + test("shows notification toggles", async ({ page }) => { + const toggles = page.locator(".toggle"); + const count = await toggles.count(); + expect(count).toBeGreaterThanOrEqual(3); + }); + + test("notification toggles are clickable", async ({ page }) => { + const toggle = page.locator(".toggle").first(); + const initialOn = await toggle.evaluate((el) => el.classList.contains("on")); + + await toggle.click(); + const afterOn = await toggle.evaluate((el) => el.classList.contains("on")); + expect(afterOn).not.toBe(initialOn); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Settings — Voice & Audio tab +// --------------------------------------------------------------------------- + +test.describe("Settings — Voice & Audio Tab", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + await openSettings(page); + await switchSettingsTab(page, "Voice & Audio"); + }); + + test("shows device selectors", async ({ page }) => { + const selects = page.locator("select.form-input"); + const count = await selects.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("shows voice sensitivity slider", async ({ page }) => { + const slider = page.locator(".settings-slider"); + await expect(slider.first()).toBeVisible(); + }); + + test("shows audio processing toggles", async ({ page }) => { + const toggles = page.locator(".toggle"); + const count = await toggles.count(); + expect(count).toBeGreaterThanOrEqual(2); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Settings — Keybinds tab +// --------------------------------------------------------------------------- + +test.describe("Settings — Keybinds Tab", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + await openSettings(page); + await switchSettingsTab(page, "Keybinds"); + }); + + test("shows keybind rows", async ({ page }) => { + const keybindRows = page.locator(".keybind-row"); + const count = await keybindRows.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("keybind rows show keyboard shortcuts", async ({ page }) => { + const kbd = page.locator(".kbd").first(); + await expect(kbd).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Settings — Logs tab +// --------------------------------------------------------------------------- + +test.describe("Settings — Logs Tab", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + await openSettings(page); + await switchSettingsTab(page, "Logs"); + }); + + test("shows log viewer", async ({ page }) => { + const logViewer = page.locator(".log-viewer"); + await expect(logViewer).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Settings — tab switching +// --------------------------------------------------------------------------- + +test.describe("Settings — Tab Switching", () => { + test("switching tabs updates active class and content", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + await openSettings(page); + + const tabs = page.locator(".settings-sidebar button.settings-nav-item"); + + // Click each tab and verify it becomes active + const tabCount = await tabs.count(); + for (let i = 0; i < Math.min(tabCount, 6); i++) { + const tab = tabs.nth(i); + const tabName = await tab.textContent(); + + // Skip Log Out button + if (tabName === "Log Out") continue; + + await tab.click(); + await expect(tab).toHaveClass(/active/); + } + }); +}); diff --git a/Client/tauri-client/tests/e2e/toast.spec.ts b/Client/tauri-client/tests/e2e/toast.spec.ts new file mode 100644 index 00000000..35d76739 --- /dev/null +++ b/Client/tauri-client/tests/e2e/toast.spec.ts @@ -0,0 +1,126 @@ +import { test, expect } from "@playwright/test"; +import { + mockTauriFullSession, + mockTauriFullSessionWithFailingMessages, + navigateToMainPage, + emitWsEvent, +} from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Toast Notifications +// --------------------------------------------------------------------------- + +test.describe("Toast Notifications", () => { + test("toast appears when message load fails (500 response)", async ({ page }) => { + await mockTauriFullSessionWithFailingMessages(page); + await page.goto("/"); + await navigateToMainPage(page); + + // The toast container should exist in the DOM + const toastContainer = page.locator("[data-testid='toast-container']"); + await expect(toastContainer).toBeAttached({ timeout: 5_000 }); + + // An error toast should appear because /messages returns 500 + const toast = page.locator("[data-testid='toast']"); + await expect(toast.first()).toBeVisible({ timeout: 10_000 }); + + // Toast should have the error type class + await expect(toast.first()).toHaveClass(/toast-error/); + + // Toast text should mention failure + const text = await toast.first().textContent(); + expect(text).toMatch(/fail/i); + }); + + test("toast auto-dismisses after timeout", async ({ page }) => { + await mockTauriFullSessionWithFailingMessages(page); + await page.goto("/"); + await navigateToMainPage(page); + + // Wait for the error toast to appear + const toast = page.locator("[data-testid='toast']"); + await expect(toast.first()).toBeVisible({ timeout: 10_000 }); + + // Default duration is 5000ms; toast gets .show removed then transitions out. + // Wait for toast to disappear (5s timeout + 400ms fallback removal) + await expect(toast).toHaveCount(0, { timeout: 10_000 }); + }); + + test("toast container exists after login", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + + const toastContainer = page.locator("[data-testid='toast-container']"); + await expect(toastContainer).toBeAttached(); + + // Container should have the correct CSS class + await expect(toastContainer).toHaveClass(/toast-container/); + }); + + test("toast can be triggered via show() and displays message text", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + + // Directly invoke the toast's show method via the DOM + // The toast container is a child of root; we can trigger a toast by + // simulating a WS disconnect which shows "Not connected" toast on send attempt + // Instead, we use page.evaluate to call show() on the toast container + await page.evaluate(() => { + // The toast container is accessible via the toast-container testid + const container = document.querySelector("[data-testid='toast-container']"); + if (container === null) throw new Error("Toast container not found"); + + // Create a toast element manually like the component does + const el = document.createElement("div"); + el.className = "toast toast-info"; + el.setAttribute("data-testid", "toast"); + el.textContent = "Test info toast"; + container.appendChild(el); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + el.classList.add("show"); + }); + }); + }); + + const toast = page.locator("[data-testid='toast']"); + await expect(toast.first()).toBeVisible({ timeout: 3_000 }); + await expect(toast.first()).toHaveText("Test info toast"); + await expect(toast.first()).toHaveClass(/toast-info/); + }); + + test("multiple toasts can stack", async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + + // Inject multiple toast elements to verify stacking + await page.evaluate(() => { + const container = document.querySelector("[data-testid='toast-container']"); + if (container === null) throw new Error("Toast container not found"); + + for (let i = 0; i < 3; i++) { + const el = document.createElement("div"); + el.className = `toast toast-${i === 0 ? "error" : "info"}`; + el.setAttribute("data-testid", "toast"); + el.textContent = `Toast message ${i + 1}`; + container.appendChild(el); + el.classList.add("show"); + } + }); + + const toasts = page.locator("[data-testid='toast']"); + await expect(toasts).toHaveCount(3, { timeout: 3_000 }); + + // Verify each toast has distinct content + await expect(toasts.nth(0)).toHaveText("Toast message 1"); + await expect(toasts.nth(1)).toHaveText("Toast message 2"); + await expect(toasts.nth(2)).toHaveText("Toast message 3"); + + // First toast should be error type, others info + await expect(toasts.nth(0)).toHaveClass(/toast-error/); + await expect(toasts.nth(1)).toHaveClass(/toast-info/); + }); +}); diff --git a/Client/tauri-client/tests/e2e/totp-flow.spec.ts b/Client/tauri-client/tests/e2e/totp-flow.spec.ts new file mode 100644 index 00000000..bd102fb6 --- /dev/null +++ b/Client/tauri-client/tests/e2e/totp-flow.spec.ts @@ -0,0 +1,116 @@ +/** + * E2E tests for TOTP (2FA) submission flow. + * Covers: valid code submits, invalid code shows error, cancel returns to login. + */ +import { test, expect } from "@playwright/test"; +import { + buildTauriMockScript, + MOCK_LOGIN_2FA_RESPONSE, + MOCK_TOKEN, +} from "./helpers"; + +async function mockTotpSuccess(page: import("@playwright/test").Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_2FA_RESPONSE }, + { pattern: "/api/v1/auth/verify-totp", status: 200, body: { token: MOCK_TOKEN, requires_2fa: false } }, + ], + simulateWsFlow: true, + })); +} + +async function mockTotpFailure(page: import("@playwright/test").Page): Promise<void> { + await page.addInitScript(buildTauriMockScript({ + httpRoutes: [ + { pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } }, + { pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_2FA_RESPONSE }, + { pattern: "/api/v1/auth/verify-totp", status: 401, body: { error: "INVALID_CODE", message: "Invalid verification code" } }, + ], + })); +} + +async function loginToTotp(page: import("@playwright/test").Page): Promise<void> { + await page.locator("#host").fill("localhost:8443"); + await page.locator("#username").fill("testuser"); + await page.locator("#password").fill("password123"); + await page.locator(".btn-primary[type='submit']").click(); + + const totpOverlay = page.locator(".totp-overlay"); + await expect(totpOverlay).not.toHaveClass(/totp-overlay--hidden/, { timeout: 5000 }); +} + +test.describe("TOTP Submission Flow", () => { + test("entering non-numeric code shows error class on input", async ({ page }) => { + await mockTotpSuccess(page); + await page.goto("/"); + await loginToTotp(page); + + const totpInput = page.locator(".totp-overlay input[inputmode='numeric']"); + await totpInput.fill("abc"); + + const verifyBtn = page.locator(".totp-overlay button.btn-primary"); + await verifyBtn.click(); + + // Input should briefly get error class + await expect(totpInput).toHaveClass(/error/, { timeout: 1000 }); + }); + + test("entering fewer than 6 digits shows error class", async ({ page }) => { + await mockTotpSuccess(page); + await page.goto("/"); + await loginToTotp(page); + + const totpInput = page.locator(".totp-overlay input[inputmode='numeric']"); + await totpInput.fill("123"); + + const verifyBtn = page.locator(".totp-overlay button.btn-primary"); + await verifyBtn.click(); + + await expect(totpInput).toHaveClass(/error/, { timeout: 1000 }); + }); + + test("submitting valid 6-digit code completes login", async ({ page }) => { + await mockTotpSuccess(page); + await page.goto("/"); + await loginToTotp(page); + + const totpInput = page.locator(".totp-overlay input[inputmode='numeric']"); + await totpInput.fill("123456"); + + const verifyBtn = page.locator(".totp-overlay button.btn-primary"); + await verifyBtn.click(); + + // Should transition to connected overlay + const overlay = page.locator(".connected-overlay"); + await expect(overlay).toBeVisible({ timeout: 5000 }); + }); + + test("submitting invalid code shows error banner", async ({ page }) => { + await mockTotpFailure(page); + await page.goto("/"); + await loginToTotp(page); + + const totpInput = page.locator(".totp-overlay input[inputmode='numeric']"); + await totpInput.fill("999999"); + + const verifyBtn = page.locator(".totp-overlay button.btn-primary"); + await verifyBtn.click(); + + // Error banner should appear + const errorBanner = page.locator(".error-banner"); + await expect(errorBanner).toHaveClass(/visible/, { timeout: 5000 }); + }); + + test("cancel button returns to login form", async ({ page }) => { + await mockTotpSuccess(page); + await page.goto("/"); + await loginToTotp(page); + + const backBtn = page.locator(".totp-back"); + await backBtn.click(); + + const totpOverlay = page.locator(".totp-overlay"); + await expect(totpOverlay).toHaveClass(/totp-overlay--hidden/); + }); +}); diff --git a/Client/tauri-client/tests/e2e/typing-indicator-ws.spec.ts b/Client/tauri-client/tests/e2e/typing-indicator-ws.spec.ts new file mode 100644 index 00000000..f841609a --- /dev/null +++ b/Client/tauri-client/tests/e2e/typing-indicator-ws.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "@playwright/test"; +import { + mockTauriFullSession, + navigateToMainPage, + emitWsMessage, +} from "./helpers"; + +test.describe("Typing Indicator — WebSocket", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("typing indicator appears when another user starts typing", async ({ page }) => { + const typingSlot = page.locator("[data-testid='typing-slot']"); + await expect(typingSlot).toBeAttached(); + + // Initially empty + const typingBar = page.locator(".typing-bar"); + if (await typingBar.count() > 0) { + await expect(typingBar).toBeEmpty(); + } + + // Emit typing event from another user (server sends "typing", not "typing_start") + await emitWsMessage(page, { + type: "typing", + payload: { + channel_id: 1, + user_id: 2, + username: "otheruser", + }, + }); + + // Typing indicator should show the username + const typingText = page.locator(".typing-bar"); + await expect(typingText).toContainText("otheruser", { timeout: 5_000 }); + }); + + test("typing indicator does not show for current user", async ({ page }) => { + // Emit typing event from the current user (id: 1) + await emitWsMessage(page, { + type: "typing", + payload: { + channel_id: 1, + user_id: 1, + username: "testuser", + }, + }); + + // Should NOT show "testuser is typing" + const typingText = page.locator(".typing-bar", { hasText: "testuser" }); + await expect(typingText).not.toBeVisible({ timeout: 1000 }); + }); + + test("typing indicator ignores events from other channels", async ({ page }) => { + // We're viewing channel 1, emit typing on channel 2 + await emitWsMessage(page, { + type: "typing", + payload: { + channel_id: 2, + user_id: 2, + username: "otheruser", + }, + }); + + const typingText = page.locator(".typing-bar", { hasText: "otheruser" }); + await expect(typingText).not.toBeVisible({ timeout: 1000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/typing-indicator.spec.ts b/Client/tauri-client/tests/e2e/typing-indicator.spec.ts new file mode 100644 index 00000000..94f7dc87 --- /dev/null +++ b/Client/tauri-client/tests/e2e/typing-indicator.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPage, emitWsMessage } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: Typing Indicator +// --------------------------------------------------------------------------- + +test.describe("Typing Indicator", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("typing indicator slot exists", async ({ page }) => { + const slot = page.locator("[data-testid='typing-slot']"); + await expect(slot).toBeAttached(); + }); + + test("typing bar is empty by default", async ({ page }) => { + const typingBar = page.locator(".typing-bar"); + if (await typingBar.count() > 0) { + // When empty, typing bar should have no visible dots text + const text = await typingBar.textContent(); + expect(text?.trim()).toBe(""); + } + }); + + test("typing indicator appears when someone types", async ({ page }) => { + // Emit a typing event + await emitWsMessage(page, { + type: "typing", + payload: { + channel_id: 1, + user_id: 2, + username: "otheruser", + }, + }); + + const typingBar = page.locator(".typing-bar"); + // Should show typing text after event + await expect(typingBar).not.toBeEmpty({ timeout: 3_000 }); + }); + + test("typing dots animate", async ({ page }) => { + await emitWsMessage(page, { + type: "typing", + payload: { + channel_id: 1, + user_id: 2, + username: "otheruser", + }, + }); + + const dots = page.locator(".typing-dots"); + await expect(dots).toBeAttached({ timeout: 3_000 }); + }); +}); diff --git a/Client/tauri-client/tests/e2e/user-bar.spec.ts b/Client/tauri-client/tests/e2e/user-bar.spec.ts new file mode 100644 index 00000000..8ae8690c --- /dev/null +++ b/Client/tauri-client/tests/e2e/user-bar.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSession, navigateToMainPage } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests: User Bar +// --------------------------------------------------------------------------- + +test.describe("User Bar", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("user bar is visible", async ({ page }) => { + const userBar = page.locator("[data-testid='user-bar']"); + await expect(userBar).toBeVisible(); + }); + + test("user bar shows username 'testuser'", async ({ page }) => { + const name = page.locator("[data-testid='user-bar-name']"); + await expect(name).toBeVisible(); + await expect(name).toHaveText("testuser"); + }); + + test("user bar shows avatar with initial", async ({ page }) => { + const avatar = page.locator("[data-testid='user-bar'] .ub-avatar"); + await expect(avatar).toBeVisible(); + // Avatar should contain the first letter of the username + await expect(avatar).toContainText("T"); + }); + + test("user bar shows online status", async ({ page }) => { + const status = page.locator("[data-testid='user-bar'] .ub-status"); + await expect(status).toBeVisible(); + await expect(status).toHaveText("Online"); + }); + + test("user bar has settings button with correct label", async ({ page }) => { + const controls = page.locator("[data-testid='user-bar'] .ub-controls"); + await expect(controls).toBeVisible(); + + const settingsBtn = controls.locator("button[aria-label='Settings']"); + await expect(settingsBtn).toBeVisible(); + await expect(settingsBtn).toHaveText("\u2699"); + }); + + test("user bar has control buttons (mute, deafen, settings)", async ({ page }) => { + const controls = page.locator("[data-testid='user-bar'] .ub-controls"); + const buttons = controls.locator("button"); + const count = await buttons.count(); + expect(count).toBe(3); + }); + + test("user bar has status dot", async ({ page }) => { + const statusDot = page.locator("[data-testid='user-bar'] .status-dot"); + await expect(statusDot).toBeAttached(); + }); +}); diff --git a/Client/tauri-client/tests/e2e/voice-channel.spec.ts b/Client/tauri-client/tests/e2e/voice-channel.spec.ts new file mode 100644 index 00000000..d8c4fe0a --- /dev/null +++ b/Client/tauri-client/tests/e2e/voice-channel.spec.ts @@ -0,0 +1,102 @@ +/** + * E2E tests for voice channels and voice widget. + * ChannelSidebar renders voice channels as .channel-item with 🔊 icon. + * VoiceWidget shows connected users when in a voice channel. + */ +import { test, expect } from "@playwright/test"; +import { + mockTauriFullSessionWithVoice, + navigateToMainPage, +} from "./helpers"; + +test.describe("Voice Channel Items", () => { + test.beforeEach(async ({ page }) => { + await mockTauriFullSessionWithVoice(page); + await page.goto("/"); + await navigateToMainPage(page); + }); + + test("voice channels appear in sidebar with speaker icon", async ({ page }) => { + // Voice channels use 🔊 icon in .ch-icon span + const voiceIcon = page.locator(".ch-icon", { hasText: "🔊" }); + await expect(voiceIcon.first()).toBeVisible({ timeout: 5000 }); + // Should have at least 2 voice channels (Voice Chat, Music) + await expect(voiceIcon).toHaveCount(2); + }); + + test("voice channel shows channel name", async ({ page }) => { + // Find channel item containing the voice icon, then check name + const voiceChatName = page.locator(".ch-name", { hasText: "Voice Chat" }); + await expect(voiceChatName).toBeVisible(); + }); + + test("voice widget shows when connected", async ({ page }) => { + // VoiceWidget should be visible (mock connects user to voice channel) + const widget = page.locator(".voice-widget.visible"); + await expect(widget).toBeVisible({ timeout: 5000 }); + }); + + test("voice widget shows connected users", async ({ page }) => { + // Mock voice state has 2 users in channel 10 (Voice Chat) + const voiceUsers = page.locator(".voice-user-item"); + await expect(voiceUsers.first()).toBeVisible({ timeout: 5000 }); + await expect(voiceUsers).toHaveCount(2); + }); + + test("voice user item shows avatar", async ({ page }) => { + const vuAvatar = page.locator(".vu-avatar").first(); + await expect(vuAvatar).toBeVisible({ timeout: 5000 }); + }); + + test("muted user shows mute indicator", async ({ page }) => { + // User 2 is muted in mock voice state + const muteIcon = page.locator(".vu-muted"); + await expect(muteIcon.first()).toBeVisible({ timeout: 5000 }); + }); + + test("voice widget shows channel name header", async ({ page }) => { + const channelName = page.locator(".vw-channel"); + await expect(channelName).toContainText("Voice Chat"); + }); + + test("voice widget has disconnect control", async ({ page }) => { + const disconnectBtn = page.locator("button[aria-label='Disconnect']"); + await expect(disconnectBtn).toBeVisible({ timeout: 5000 }); + }); + + test("mute button toggles active state on click", async ({ page }) => { + const controls = page.locator(".vw-controls"); + await expect(controls).toBeVisible({ timeout: 5000 }); + + const muteBtn = controls.locator("button[aria-label='Mute']"); + const hadActive = await muteBtn.evaluate((el) => el.classList.contains("active-ctrl")); + await muteBtn.click(); + + // Button should toggle its active-ctrl class + const hasActive = await muteBtn.evaluate((el) => el.classList.contains("active-ctrl")); + expect(hasActive).not.toBe(hadActive); + }); + + test("deafen button toggles active state on click", async ({ page }) => { + const controls = page.locator(".vw-controls"); + await expect(controls).toBeVisible({ timeout: 5000 }); + + const deafenBtn = controls.locator("button[aria-label='Deafen']"); + const hadActive = await deafenBtn.evaluate((el) => el.classList.contains("active-ctrl")); + await deafenBtn.click(); + + const hasActive = await deafenBtn.evaluate((el) => el.classList.contains("active-ctrl")); + expect(hasActive).not.toBe(hadActive); + }); + + test("all five voice control buttons are present", async ({ page }) => { + const controls = page.locator(".vw-controls"); + await expect(controls).toBeVisible({ timeout: 5000 }); + + await expect(controls.locator("button[aria-label='Mute']")).toBeVisible(); + await expect(controls.locator("button[aria-label='Deafen']")).toBeVisible(); + await expect(controls.locator("button[aria-label='Camera']")).toBeVisible(); + await expect(controls.locator("button[aria-label='Screenshare']")).toBeVisible(); + await expect(controls.locator("button[aria-label='Disconnect']")).toBeVisible(); + }); +}); diff --git a/Client/tauri-client/tests/e2e/voice-widget.spec.ts b/Client/tauri-client/tests/e2e/voice-widget.spec.ts new file mode 100644 index 00000000..5d8b0117 --- /dev/null +++ b/Client/tauri-client/tests/e2e/voice-widget.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from "@playwright/test"; +import { mockTauriFullSessionWithVoice, navigateToMainPage, emitWsMessage } from "./helpers"; + +const VOICE_STATE_EVENT = { + type: "voice_state" as const, + payload: { + user_id: 1, + username: "testuser", + channel_id: 10, + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, +}; + +test.describe("Voice Widget", () => { + test("is hidden by default when user has no voice state", async ({ page }) => { + const { mockTauriFullSession } = await import("./helpers"); + await mockTauriFullSession(page); + await page.goto("/"); + await navigateToMainPage(page); + + const widget = page.locator("[data-testid='voice-widget']"); + await expect(widget).toBeAttached(); + await expect(widget).not.toHaveClass(/visible/); + }); + + test("appears with full UI when voice_state event is received", async ({ page }) => { + await mockTauriFullSessionWithVoice(page); + await page.goto("/"); + await navigateToMainPage(page); + + await emitWsMessage(page, VOICE_STATE_EVENT); + + const widget = page.locator("[data-testid='voice-widget'].visible"); + await expect(widget).toBeVisible({ timeout: 5_000 }); + + // Verify all widget parts render (users list is in sidebar, not widget) + await expect(widget.locator(".vw-connected")).toBeVisible(); + await expect(widget.locator(".vw-channel")).toBeVisible(); + await expect(widget.locator(".vw-controls")).toBeVisible(); + await expect(page.locator("button[aria-label='Disconnect']")).toBeVisible(); + }); + + test("mute button toggles active state on click", async ({ page }) => { + await mockTauriFullSessionWithVoice(page); + await page.goto("/"); + await navigateToMainPage(page); + + await emitWsMessage(page, VOICE_STATE_EVENT); + const controls = page.locator(".vw-controls"); + await expect(controls).toBeVisible({ timeout: 5_000 }); + + const muteBtn = controls.locator("button[aria-label='Mute']"); + const hadActive = await muteBtn.evaluate((el) => el.classList.contains("active-ctrl")); + await muteBtn.click(); + const hasActive = await muteBtn.evaluate((el) => el.classList.contains("active-ctrl")); + expect(hasActive).not.toBe(hadActive); + }); + + test("deafen button toggles active state on click", async ({ page }) => { + await mockTauriFullSessionWithVoice(page); + await page.goto("/"); + await navigateToMainPage(page); + + await emitWsMessage(page, VOICE_STATE_EVENT); + const controls = page.locator(".vw-controls"); + await expect(controls).toBeVisible({ timeout: 5_000 }); + + const deafenBtn = controls.locator("button[aria-label='Deafen']"); + const hadActive = await deafenBtn.evaluate((el) => el.classList.contains("active-ctrl")); + await deafenBtn.click(); + const hasActive = await deafenBtn.evaluate((el) => el.classList.contains("active-ctrl")); + expect(hasActive).not.toBe(hadActive); + }); + + test("second user joining voice appears in sidebar users list", async ({ page }) => { + await mockTauriFullSessionWithVoice(page); + await page.goto("/"); + await navigateToMainPage(page); + + await emitWsMessage(page, VOICE_STATE_EVENT); + const widget = page.locator("[data-testid='voice-widget'].visible"); + await expect(widget).toBeVisible({ timeout: 5_000 }); + + const usersBefore = await page.locator(".voice-user-item").count(); + + // Another user joins the voice channel + await emitWsMessage(page, { + type: "voice_state", + payload: { + user_id: 3, + username: "newvoiceuser", + channel_id: 10, + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + }); + + // New user should appear in the sidebar voice-users-list (not the widget) + const newUser = page.locator(".voice-user-item .vu-name", { hasText: "newvoiceuser" }); + await expect(newUser).toBeVisible({ timeout: 5_000 }); + const usersAfter = await page.locator(".voice-user-item").count(); + expect(usersAfter).toBeGreaterThan(usersBefore); + }); +}); diff --git a/Client/tauri-client/tests/helpers/fixtures.ts b/Client/tauri-client/tests/helpers/fixtures.ts new file mode 100644 index 00000000..aa00c2ff --- /dev/null +++ b/Client/tauri-client/tests/helpers/fixtures.ts @@ -0,0 +1,219 @@ +/** + * Test data factories for OwnCord protocol types. + * Every factory returns a new object with sensible defaults. + * Pass partial overrides to customize individual fields. + */ + +import type { + MessageResponse, + MemberResponse, + ReadyChannel, + ReactionSummary, + VoiceStatePayload, + ReadyMember, + ReadyVoiceState, + ReadyRole, + ReadyPayload, + ChatMessagePayload, + MessageUser, + Attachment, +} from "@lib/types"; + +// --------------------------------------------------------------------------- +// Atomic factories +// --------------------------------------------------------------------------- + +/** Create a MessageResponse with sensible defaults. */ +export function makeMessage( + overrides?: Partial<MessageResponse>, +): MessageResponse { + return { + id: 1, + channel_id: 1, + user: { id: 1, username: "testuser", avatar: null }, + content: "Hello, world!", + reply_to: null, + attachments: [], + reactions: [], + pinned: false, + edited_at: null, + deleted: false, + timestamp: "2026-03-15T12:00:00Z", + ...overrides, + }; +} + +/** Create a MemberResponse with sensible defaults. */ +export function makeMember( + overrides?: Partial<MemberResponse>, +): MemberResponse { + return { + id: 1, + username: "testuser", + avatar: null, + role: "member", + status: "online", + ...overrides, + }; +} + +/** Create a ReadyChannel with sensible defaults. */ +export function makeChannel( + overrides?: Partial<ReadyChannel>, +): ReadyChannel { + return { + id: 1, + name: "general", + type: "text", + category: "Text Channels", + position: 0, + unread_count: 0, + last_message_id: undefined, + ...overrides, + }; +} + +/** Create a ReactionSummary with sensible defaults. */ +export function makeReaction( + overrides?: Partial<ReactionSummary>, +): ReactionSummary { + return { + emoji: "👍", + count: 1, + me: false, + ...overrides, + }; +} + +/** Create a VoiceStatePayload with sensible defaults. */ +export function makeVoiceState( + overrides?: Partial<VoiceStatePayload>, +): VoiceStatePayload { + return { + channel_id: 3, + user_id: 1, + username: "testuser", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Composite factories +// --------------------------------------------------------------------------- + +/** Create a MessageUser with sensible defaults. */ +export function makeMessageUser( + overrides?: Partial<MessageUser>, +): MessageUser { + return { + id: 1, + username: "testuser", + avatar: null, + ...overrides, + }; +} + +/** Create an Attachment with sensible defaults. */ +export function makeAttachment( + overrides?: Partial<Attachment>, +): Attachment { + return { + id: "att-1", + filename: "image.png", + size: 1024, + mime: "image/png", + url: "/uploads/image.png", + ...overrides, + }; +} + +/** Create a ChatMessagePayload (WS wire format) with sensible defaults. */ +export function makeChatMessagePayload( + overrides?: Partial<ChatMessagePayload>, +): ChatMessagePayload { + return { + id: 1, + channel_id: 1, + user: { id: 1, username: "testuser", avatar: null }, + content: "Hello, world!", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T12:00:00Z", + ...overrides, + }; +} + +/** Create a ReadyMember with sensible defaults. */ +export function makeReadyMember( + overrides?: Partial<ReadyMember>, +): ReadyMember { + return { + id: 1, + username: "testuser", + avatar: null, + role: "member", + status: "online", + ...overrides, + }; +} + +/** Create a ReadyVoiceState with sensible defaults. */ +export function makeReadyVoiceState( + overrides?: Partial<ReadyVoiceState>, +): ReadyVoiceState { + return { + channel_id: 3, + user_id: 1, + muted: false, + deafened: false, + ...overrides, + }; +} + +/** Create a ReadyRole with sensible defaults. */ +export function makeReadyRole( + overrides?: Partial<ReadyRole>, +): ReadyRole { + return { + id: 1, + name: "Member", + color: null, + permissions: 0x3, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Full ready payload fixture +// --------------------------------------------------------------------------- + +/** Create a full ReadyPayload fixture for integration tests. */ +export function makeReadyPayload( + overrides?: Partial<ReadyPayload>, +): ReadyPayload { + return { + channels: [ + makeChannel({ id: 1, name: "general", type: "text", position: 0, unread_count: 3, last_message_id: 100 }), + makeChannel({ id: 2, name: "random", type: "text", position: 1, unread_count: 0, last_message_id: 50 }), + makeChannel({ id: 3, name: "Voice Chat", type: "voice", category: "Voice Channels", position: 0 }), + ], + members: [ + makeReadyMember({ id: 1, username: "admin", role: "admin", status: "online" }), + makeReadyMember({ id: 2, username: "user1", role: "member", status: "online" }), + ], + voice_states: [ + makeReadyVoiceState({ user_id: 1, channel_id: 3 }), + ], + roles: [ + makeReadyRole({ id: 1, name: "Owner", color: "#e74c3c", permissions: 0x7FFFFFFF }), + makeReadyRole({ id: 2, name: "Admin", color: "#f1c40f", permissions: 0x3FFFFFFF }), + makeReadyRole({ id: 3, name: "Member", color: null, permissions: 0x3 }), + ], + ...overrides, + }; +} diff --git a/Client/tauri-client/tests/helpers/mock-ws.ts b/Client/tauri-client/tests/helpers/mock-ws.ts new file mode 100644 index 00000000..200cd322 --- /dev/null +++ b/Client/tauri-client/tests/helpers/mock-ws.ts @@ -0,0 +1,147 @@ +/** + * Mock WebSocket client that implements the same public interface as + * createWsClient() from @lib/ws. Used in unit and integration tests + * to simulate server messages and inspect outbound sends without + * requiring Tauri IPC or a real WebSocket connection. + */ + +import type { + ServerMessage, + ClientMessage, +} from "@lib/types"; +import type { ConnectionState, WsListener, CertMismatchListener } from "@lib/ws"; + +interface SentEnvelope { + readonly type: string; + readonly id: string; + readonly payload: unknown; +} + +export function createMockWsClient() { + let state: ConnectionState = "disconnected"; + + const sent: SentEnvelope[] = []; + const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>(); + const stateListeners = new Set<(state: ConnectionState) => void>(); + + let idCounter = 0; + + function nextId(): string { + idCounter += 1; + return `mock-${idCounter}`; + } + + function setState(newState: ConnectionState): void { + if (state !== newState) { + state = newState; + for (const listener of stateListeners) { + listener(state); + } + } + } + + return { + // --------------------------------------------------------------- + // Public API — mirrors WsClient from @lib/ws + // --------------------------------------------------------------- + + connect(): void { + setState("connected"); + }, + + disconnect(): void { + setState("disconnected"); + }, + + send(msg: ClientMessage): string { + const id = nextId(); + sent.push({ type: msg.type, id, payload: msg.payload }); + return id; + }, + + on<T extends ServerMessage["type"]>( + type: T, + listener: WsListener<T>, + ): () => void { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + const set = listeners.get(type)!; + set.add(listener as unknown as WsListener<ServerMessage["type"]>); + return () => { + set.delete(listener as unknown as WsListener<ServerMessage["type"]>); + }; + }, + + onStateChange(listener: (s: ConnectionState) => void): () => void { + stateListeners.add(listener); + return () => stateListeners.delete(listener); + }, + + onCertMismatch(_listener: CertMismatchListener): () => void { + return () => {}; + }, + + async acceptCertFingerprint(_host: string, _fingerprint: string): Promise<void> { + // no-op in mock + }, + + getState(): ConnectionState { + return state; + }, + + // --------------------------------------------------------------- + // Test-only helpers + // --------------------------------------------------------------- + + /** + * Simulate a server message arriving. Fires all registered listeners + * for the given message type. + */ + simulateMessage<T extends ServerMessage["type"]>( + type: T, + payload: Extract<ServerMessage, { type: T }>["payload"], + id?: string, + ): void { + const typeListeners = listeners.get(type); + if (typeListeners) { + for (const listener of typeListeners) { + // Cast through unknown: the generic constraints guarantee type + // safety at call sites, but TS cannot narrow inside the loop. + const fn = listener as unknown as (p: unknown, i?: string) => void; + fn(payload, id); + } + } + }, + + /** + * Simulate a connection state change (e.g. reconnecting, disconnected). + */ + simulateStateChange(newState: ConnectionState): void { + setState(newState); + }, + + /** + * Return all messages passed to send(), in order. + */ + getSentMessages(): readonly SentEnvelope[] { + return sent; + }, + + /** + * Convenience: return the last sent message, or undefined if none. + */ + get lastSent(): SentEnvelope | undefined { + return sent[sent.length - 1]; + }, + + /** + * Clear the sent message buffer. + */ + clearSent(): void { + sent.length = 0; + }, + }; +} + +export type MockWsClient = ReturnType<typeof createMockWsClient>; diff --git a/Client/tauri-client/tests/helpers/test-utils.ts b/Client/tauri-client/tests/helpers/test-utils.ts new file mode 100644 index 00000000..fca7be34 --- /dev/null +++ b/Client/tauri-client/tests/helpers/test-utils.ts @@ -0,0 +1,157 @@ +/** + * Common test utilities for OwnCord Tauri client tests. + * Provides store reset and async store waiting helpers. + */ + +import type { Store } from "@lib/store"; +import { authStore } from "@stores/auth.store"; +import { channelsStore } from "@stores/channels.store"; +import { membersStore } from "@stores/members.store"; +import { messagesStore } from "@stores/messages.store"; +import { voiceStore } from "@stores/voice.store"; +import { uiStore } from "@stores/ui.store"; + +import type { AuthState } from "@stores/auth.store"; +import type { ChannelsState } from "@stores/channels.store"; +import type { MembersState } from "@stores/members.store"; +import type { MessagesState } from "@stores/messages.store"; +import type { VoiceState } from "@stores/voice.store"; +import type { UiState } from "@stores/ui.store"; + +// --------------------------------------------------------------------------- +// Initial states (must match those in each store module) +// --------------------------------------------------------------------------- + +const AUTH_INITIAL: AuthState = { + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, +}; + +const CHANNELS_INITIAL: ChannelsState = { + channels: new Map(), + activeChannelId: null, +}; + +const MEMBERS_INITIAL: MembersState = { + members: new Map(), + typingUsers: new Map(), +}; + +const MESSAGES_INITIAL: MessagesState = { + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), +}; + +const VOICE_INITIAL: VoiceState = { + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, +}; + +const UI_INITIAL: UiState = { + sidebarCollapsed: false, + memberListVisible: true, + settingsOpen: false, + activeModal: null, + theme: "dark", + connectionStatus: "disconnected", + transientError: null, + persistentError: null, + collapsedCategories: new Set(), +}; + +// --------------------------------------------------------------------------- +// resetAllStores +// --------------------------------------------------------------------------- + +/** + * Reset every store to its initial state. Call this in `beforeEach` to + * ensure test isolation. + */ +export function resetAllStores(): void { + authStore.setState(() => ({ ...AUTH_INITIAL })); + channelsStore.setState(() => ({ ...CHANNELS_INITIAL, channels: new Map() })); + membersStore.setState(() => ({ + ...MEMBERS_INITIAL, + members: new Map(), + typingUsers: new Map(), + })); + messagesStore.setState(() => ({ + ...MESSAGES_INITIAL, + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), + })); + voiceStore.setState(() => ({ + ...VOICE_INITIAL, + voiceUsers: new Map(), + voiceConfigs: new Map(), + })); + uiStore.setState(() => ({ + ...UI_INITIAL, + collapsedCategories: new Set(), + })); +} + +// --------------------------------------------------------------------------- +// waitForStoreUpdate +// --------------------------------------------------------------------------- + +/** + * Returns a promise that resolves when the store's state matches the given + * predicate. Useful for waiting on asynchronous store updates (e.g. after + * dispatching a WS message that triggers a store change). + * + * Times out after `timeoutMs` (default 2000ms) to prevent hanging tests. + * + * @example + * ```ts + * await waitForStoreUpdate(authStore, (s) => s.isAuthenticated); + * ``` + */ +export function waitForStoreUpdate<T>( + store: Store<T>, + predicate: (state: T) => boolean, + timeoutMs = 2000, +): Promise<T> { + return new Promise<T>((resolve, reject) => { + // Check immediately — predicate may already be true + const current = store.getState(); + if (predicate(current)) { + resolve(current); + return; + } + + let timer: ReturnType<typeof setTimeout> | null = null; + + const unsub = store.subscribe((state) => { + if (predicate(state)) { + if (timer !== null) { + clearTimeout(timer); + } + unsub(); + resolve(state); + } + }); + + timer = setTimeout(() => { + unsub(); + reject( + new Error( + `waitForStoreUpdate timed out after ${timeoutMs}ms. ` + + `Last state: ${JSON.stringify(store.getState())}`, + ), + ); + }, timeoutMs); + }); +} diff --git a/Client/tauri-client/tests/integration/stores.test.ts b/Client/tauri-client/tests/integration/stores.test.ts new file mode 100644 index 00000000..602b48d1 --- /dev/null +++ b/Client/tauri-client/tests/integration/stores.test.ts @@ -0,0 +1,589 @@ +/** + * Integration tests — Store hydration via dispatcher. + * Verifies that WS events, routed through wireDispatcher, correctly + * update all domain stores (channels, members, messages, voice). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { WsClient, WsListener, ConnectionState } from "@lib/ws"; +import type { ServerMessage } from "@lib/types"; +import { wireDispatcher } from "@lib/dispatcher"; + +// ── Stores ────────────────────────────────────────────────────────── +import { channelsStore, setActiveChannel } from "@stores/channels.store"; +import { membersStore } from "@stores/members.store"; +import { messagesStore, addPendingSend, addMessage } from "@stores/messages.store"; +import { voiceStore } from "@stores/voice.store"; +import { authStore, setAuth } from "@stores/auth.store"; + +// ── Mock WsClient ─────────────────────────────────────────────────── + +interface MockWsClient extends WsClient { + /** Fire a server event into all registered handlers. */ + simulate(type: string, payload: unknown, id?: string): void; + /** All messages passed to send(). */ + readonly sent: Array<{ type: string; payload: unknown }>; +} + +function createMockWsClient(): MockWsClient { + const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>(); + const stateListeners = new Set<(s: ConnectionState) => void>(); + const sent: Array<{ type: string; payload: unknown }> = []; + let currentState: ConnectionState = "connected"; + + return { + connect() { + // no-op + }, + + disconnect() { + // no-op + }, + + send(msg) { + sent.push(msg as { type: string; payload: unknown }); + return crypto.randomUUID(); + }, + + on<T extends ServerMessage["type"]>( + type: T, + listener: WsListener<T>, + ): () => void { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + const set = listeners.get(type)!; + const wrapped = listener as unknown as WsListener<ServerMessage["type"]>; + set.add(wrapped); + return () => { + set.delete(wrapped); + }; + }, + + onStateChange(listener: (s: ConnectionState) => void): () => void { + stateListeners.add(listener); + return () => stateListeners.delete(listener); + }, + + onCertMismatch(): () => void { + return () => {}; + }, + + async acceptCertFingerprint(): Promise<void> { + // no-op in mock + }, + + getState(): ConnectionState { + return currentState; + }, + + _getWs() { + return null; + }, + + simulate(type: string, payload: unknown, id?: string): void { + const typeListeners = listeners.get(type); + if (!typeListeners) return; + for (const listener of typeListeners) { + (listener as (p: unknown, i?: string) => void)(payload, id); + } + }, + + get sent() { + return sent; + }, + }; +} + +// ── Helpers ───────────────────────────────────────────────────────── + +function resetAllStores(): void { + channelsStore.setState(() => ({ + channels: new Map(), + activeChannelId: null, + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); + messagesStore.setState(() => ({ + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), + })); + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, + })); + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); +} + +// ── Test Suite ─────────────────────────────────────────────────────── + +describe("Store integration via dispatcher", () => { + let ws: MockWsClient; + let cleanup: () => void; + + beforeEach(() => { + vi.restoreAllMocks(); + resetAllStores(); + ws = createMockWsClient(); + cleanup = wireDispatcher(ws); + }); + + afterEach(() => { + cleanup(); + }); + + // ──────────────────────────────────────────────────────────────── + // 1. Ready payload hydration + // ──────────────────────────────────────────────────────────────── + + describe("ready payload hydration", () => { + it("populates channels, members, and voice stores from ready event", () => { + ws.simulate("ready", { + channels: [ + { id: 1, name: "general", type: "text", category: "Text Channels", position: 0, unread_count: 3, last_message_id: 100 }, + { id: 2, name: "random", type: "text", category: "Text Channels", position: 1, unread_count: 0, last_message_id: 50 }, + { id: 3, name: "Voice Chat", type: "voice", category: "Voice Channels", position: 0 }, + ], + members: [ + { id: 1, username: "admin", avatar: null, role: "admin", status: "online" }, + { id: 2, username: "user1", avatar: null, role: "member", status: "idle" }, + { id: 3, username: "user2", avatar: null, role: "member", status: "offline" }, + ], + voice_states: [ + { channel_id: 3, user_id: 1, muted: false, deafened: false }, + { channel_id: 3, user_id: 2, muted: true, deafened: false }, + ], + roles: [ + { id: 1, name: "Admin", color: "#f1c40f", permissions: 0x3FFFFFFF }, + { id: 2, name: "Member", color: null, permissions: 0x3 }, + ], + }); + + // Channels + const channels = channelsStore.getState().channels; + expect(channels.size).toBe(3); + expect(channels.get(1)?.name).toBe("general"); + // Auto-select first text channel clears its unread count + expect(channels.get(1)?.unreadCount).toBe(0); + expect(channels.get(3)?.type).toBe("voice"); + + // Members + const members = membersStore.getState().members; + expect(members.size).toBe(3); + expect(members.get(1)?.username).toBe("admin"); + expect(members.get(1)?.role).toBe("admin"); + expect(members.get(2)?.status).toBe("idle"); + + // Voice + const voiceUsers = voiceStore.getState().voiceUsers; + const channel3Users = voiceUsers.get(3); + expect(channel3Users).toBeDefined(); + expect(channel3Users!.size).toBe(2); + expect(channel3Users!.get(1)?.muted).toBe(false); + expect(channel3Users!.get(2)?.muted).toBe(true); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // 2. Chat message flow (unread tracking) + // ──────────────────────────────────────────────────────────────── + + describe("chat message flow", () => { + beforeEach(() => { + // Seed channels + ws.simulate("ready", { + channels: [ + { id: 1, name: "general", type: "text", category: null, position: 0, unread_count: 0 }, + { id: 2, name: "random", type: "text", category: null, position: 1, unread_count: 0 }, + ], + members: [ + { id: 10, username: "sender", avatar: null, role: "member", status: "online" }, + ], + voice_states: [], + roles: [], + }); + }); + + it("adds message to store and increments unread on non-active channel", () => { + // After ready, channel 1 is auto-selected. Send to channel 2 (non-active). + ws.simulate("chat_message", { + id: 100, + channel_id: 2, + user: { id: 10, username: "sender", avatar: null }, + content: "Hello!", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T12:00:00Z", + }); + + const messages = messagesStore.getState().messagesByChannel.get(2); + expect(messages).toHaveLength(1); + expect(messages![0]!.content).toBe("Hello!"); + + const channel = channelsStore.getState().channels.get(2); + expect(channel?.unreadCount).toBe(1); + }); + + it("does not increment unread when message arrives on active channel", () => { + setActiveChannel(1); + + ws.simulate("chat_message", { + id: 101, + channel_id: 1, + user: { id: 10, username: "sender", avatar: null }, + content: "Active channel message", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T12:01:00Z", + }); + + const messages = messagesStore.getState().messagesByChannel.get(1); + expect(messages).toHaveLength(1); + + const channel = channelsStore.getState().channels.get(1); + expect(channel?.unreadCount).toBe(0); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // 3. Message edit and delete + // ──────────────────────────────────────────────────────────────── + + describe("message edit and delete", () => { + beforeEach(() => { + // Seed a message directly + addMessage({ + id: 200, + channel_id: 5, + user: { id: 1, username: "author", avatar: null }, + content: "Original content", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + }); + + it("updates content on chat_edited event", () => { + ws.simulate("chat_edited", { + message_id: 200, + channel_id: 5, + content: "Edited content", + edited_at: "2026-03-15T10:05:00Z", + }); + + const messages = messagesStore.getState().messagesByChannel.get(5); + expect(messages).toHaveLength(1); + expect(messages![0]!.content).toBe("Edited content"); + expect(messages![0]!.editedAt).toBe("2026-03-15T10:05:00Z"); + }); + + it("marks message as deleted on chat_deleted event", () => { + ws.simulate("chat_deleted", { + message_id: 200, + channel_id: 5, + }); + + const messages = messagesStore.getState().messagesByChannel.get(5); + expect(messages).toHaveLength(1); + expect(messages![0]!.deleted).toBe(true); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // 4. Reaction update + // ──────────────────────────────────────────────────────────────── + + describe("reaction update", () => { + beforeEach(() => { + // Set up auth so updateReaction knows the current user + setAuth( + "test-token", + { id: 99, username: "me", avatar: null, role: "member" }, + "Test Server", + "Welcome", + ); + + // Seed a message + addMessage({ + id: 300, + channel_id: 7, + user: { id: 1, username: "someone", avatar: null }, + content: "React to this", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T11:00:00Z", + }); + }); + + it("increases reaction count on add", () => { + ws.simulate("reaction_update", { + message_id: 300, + channel_id: 7, + emoji: "thumbsup", + user_id: 99, + action: "add", + }); + + const messages = messagesStore.getState().messagesByChannel.get(7); + const msg = messages![0]!; + expect(msg.reactions).toHaveLength(1); + expect(msg.reactions[0]!.emoji).toBe("thumbsup"); + expect(msg.reactions[0]!.count).toBe(1); + expect(msg.reactions[0]!.me).toBe(true); + }); + + it("decreases reaction count on remove and filters zero-count", () => { + // First add + ws.simulate("reaction_update", { + message_id: 300, + channel_id: 7, + emoji: "thumbsup", + user_id: 99, + action: "add", + }); + + // Then remove + ws.simulate("reaction_update", { + message_id: 300, + channel_id: 7, + emoji: "thumbsup", + user_id: 99, + action: "remove", + }); + + const messages = messagesStore.getState().messagesByChannel.get(7); + const msg = messages![0]!; + // Count drops to 0, so the reaction is filtered out + expect(msg.reactions).toHaveLength(0); + }); + + it("increments existing reaction count from another user", () => { + // First add from user 99 (me) + ws.simulate("reaction_update", { + message_id: 300, + channel_id: 7, + emoji: "heart", + user_id: 99, + action: "add", + }); + + // Second add from user 50 (someone else) + ws.simulate("reaction_update", { + message_id: 300, + channel_id: 7, + emoji: "heart", + user_id: 50, + action: "add", + }); + + const messages = messagesStore.getState().messagesByChannel.get(7); + const msg = messages![0]!; + expect(msg.reactions).toHaveLength(1); + expect(msg.reactions[0]!.count).toBe(2); + expect(msg.reactions[0]!.me).toBe(true); // still me + }); + }); + + // ──────────────────────────────────────────────────────────────── + // 5. Chat send confirmation + // ──────────────────────────────────────────────────────────────── + + describe("chat send confirmation", () => { + it("removes pending send on chat_send_ok with matching correlation ID", () => { + const correlationId = "corr-abc-123"; + addPendingSend(correlationId, 1); + + expect(messagesStore.getState().pendingSends.has(correlationId)).toBe(true); + + ws.simulate( + "chat_send_ok", + { message_id: 500, timestamp: "2026-03-15T13:00:00Z" }, + correlationId, + ); + + expect(messagesStore.getState().pendingSends.has(correlationId)).toBe(false); + }); + + it("does not remove pending send when correlation ID is missing", () => { + const correlationId = "corr-xyz-789"; + addPendingSend(correlationId, 1); + + // Simulate without an id + ws.simulate( + "chat_send_ok", + { message_id: 501, timestamp: "2026-03-15T13:01:00Z" }, + ); + + // Pending send remains because no correlation ID was provided + expect(messagesStore.getState().pendingSends.has(correlationId)).toBe(true); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // 6. Typing indicator + // ──────────────────────────────────────────────────────────────── + + describe("typing indicator", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("sets typing user in membersStore on typing event", () => { + ws.simulate("typing", { + channel_id: 1, + user_id: 42, + username: "typer", + }); + + const typing = membersStore.getState().typingUsers.get(1); + expect(typing).toBeDefined(); + expect(typing!.has(42)).toBe(true); + }); + + it("clears typing user after 5 seconds", () => { + ws.simulate("typing", { + channel_id: 1, + user_id: 42, + username: "typer", + }); + + vi.advanceTimersByTime(5001); + + const typing = membersStore.getState().typingUsers.get(1); + // Either the map entry is gone or the set is empty + const hasUser = typing?.has(42) ?? false; + expect(hasUser).toBe(false); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // 7. Member ban + // ──────────────────────────────────────────────────────────────── + + describe("member ban", () => { + it("removes member from store on member_ban event", () => { + ws.simulate("ready", { + channels: [], + members: [ + { id: 10, username: "innocent", avatar: null, role: "member", status: "online" }, + { id: 20, username: "troublemaker", avatar: null, role: "member", status: "online" }, + ], + voice_states: [], + roles: [], + }); + + expect(membersStore.getState().members.has(20)).toBe(true); + + ws.simulate("member_ban", { user_id: 20 }); + + expect(membersStore.getState().members.has(20)).toBe(false); + // Other members remain + expect(membersStore.getState().members.has(10)).toBe(true); + }); + }); + + // ──────────────────────────────────────────────────────────────── + // 8. Voice config and speakers + // ──────────────────────────────────────────────────────────────── + + describe("voice config and speakers", () => { + it("stores voice config from voice_config event", () => { + ws.simulate("voice_config", { + channel_id: 3, + quality: "high", + bitrate: 128000, + threshold_mode: "selective", + mixing_threshold: 5, + top_speakers: 3, + max_users: 25, + }); + + const config = voiceStore.getState().voiceConfigs.get(3); + expect(config).toBeDefined(); + expect(config!.quality).toBe("high"); + expect(config!.bitrate).toBe(128000); + expect(config!.threshold_mode).toBe("selective"); + expect(config!.mixing_threshold).toBe(5); + expect(config!.top_speakers).toBe(3); + expect(config!.max_users).toBe(25); + }); + + it("updates speaking states from voice_speakers event", () => { + // First seed voice users in channel 3 + ws.simulate("ready", { + channels: [], + members: [], + voice_states: [ + { channel_id: 3, user_id: 1, muted: false, deafened: false }, + { channel_id: 3, user_id: 2, muted: false, deafened: false }, + { channel_id: 3, user_id: 3, muted: false, deafened: false }, + ], + roles: [], + }); + + // User 1 and 3 are speaking + ws.simulate("voice_speakers", { + channel_id: 3, + speakers: [1, 3], + threshold_mode: "selective", + }); + + const channelUsers = voiceStore.getState().voiceUsers.get(3); + expect(channelUsers).toBeDefined(); + expect(channelUsers!.get(1)?.speaking).toBe(true); + expect(channelUsers!.get(2)?.speaking).toBe(false); + expect(channelUsers!.get(3)?.speaking).toBe(true); + }); + + it("clears speaking when user is no longer in speakers list", () => { + // Seed voice users + ws.simulate("ready", { + channels: [], + members: [], + voice_states: [ + { channel_id: 3, user_id: 1, muted: false, deafened: false }, + { channel_id: 3, user_id: 2, muted: false, deafened: false }, + ], + roles: [], + }); + + // User 1 speaking + ws.simulate("voice_speakers", { + channel_id: 3, + speakers: [1], + threshold_mode: "forwarding", + }); + + expect(voiceStore.getState().voiceUsers.get(3)!.get(1)?.speaking).toBe(true); + + // Now nobody speaking + ws.simulate("voice_speakers", { + channel_id: 3, + speakers: [], + threshold_mode: "forwarding", + }); + + expect(voiceStore.getState().voiceUsers.get(3)!.get(1)?.speaking).toBe(false); + expect(voiceStore.getState().voiceUsers.get(3)!.get(2)?.speaking).toBe(false); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/admin-actions.test.ts b/Client/tauri-client/tests/unit/admin-actions.test.ts new file mode 100644 index 00000000..4a05927f --- /dev/null +++ b/Client/tauri-client/tests/unit/admin-actions.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + createMemberContextMenu, + createChannelContextMenu, +} from "@components/AdminActions"; +import type { + MemberContextMenuOptions, + ChannelContextMenuOptions, +} from "@components/AdminActions"; + +describe("AdminActions", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + describe("MemberContextMenu", () => { + function makeMenu(overrides?: Partial<MemberContextMenuOptions>) { + const options: MemberContextMenuOptions = { + userId: 1, + username: "TestUser", + currentRole: "member", + availableRoles: ["admin", "moderator", "member"], + onKick: overrides?.onKick ?? vi.fn(async () => {}), + onBan: overrides?.onBan ?? vi.fn(async () => {}), + onChangeRole: overrides?.onChangeRole ?? vi.fn(async () => {}), + }; + const result = createMemberContextMenu(options); + container.appendChild(result.element); + return { result, options }; + } + + it("creates element with context-menu class", () => { + const { result } = makeMenu(); + expect(result.element.classList.contains("context-menu")).toBe(true); + result.destroy(); + }); + + it("renders Change Role item", () => { + const { result } = makeMenu(); + const items = result.element.querySelectorAll(".context-menu__item"); + const texts = Array.from(items).map((i) => i.textContent); + expect(texts.some((t) => t?.includes("Change Role"))).toBe(true); + result.destroy(); + }); + + it("renders role submenu with available roles", () => { + const { result } = makeMenu(); + const submenu = result.element.querySelector(".context-menu__submenu"); + expect(submenu).not.toBeNull(); + + const roleItems = submenu!.querySelectorAll(".context-menu__item"); + const roleTexts = Array.from(roleItems).map((r) => r.textContent); + expect(roleTexts).toContain("admin"); + expect(roleTexts).toContain("moderator"); + expect(roleTexts).toContain("member"); + result.destroy(); + }); + + it("marks current role as active in submenu", () => { + const { result } = makeMenu(); + const submenu = result.element.querySelector(".context-menu__submenu"); + const activeRole = submenu!.querySelector(".context-menu__item--active"); + expect(activeRole).not.toBeNull(); + expect(activeRole!.textContent).toBe("member"); + result.destroy(); + }); + + it("renders Kick and Ban items with danger class", () => { + const { result } = makeMenu(); + const dangerItems = result.element.querySelectorAll(".context-menu__item--danger"); + const texts = Array.from(dangerItems).map((i) => i.textContent); + expect(texts).toContain("Kick"); + expect(texts).toContain("Ban"); + result.destroy(); + }); + + it("Kick requires double-click confirmation", () => { + const onKick = vi.fn(async () => {}); + const { result } = makeMenu({ onKick }); + + const dangerItems = result.element.querySelectorAll(".context-menu__item--danger"); + const kickItem = Array.from(dangerItems).find((i) => i.textContent === "Kick") as HTMLDivElement; + + // First click changes text to confirmation + kickItem.click(); + expect(kickItem.textContent).toBe("Are you sure?"); + expect(onKick).not.toHaveBeenCalled(); + + // Second click confirms + kickItem.click(); + expect(onKick).toHaveBeenCalledOnce(); + result.destroy(); + }); + + it("Ban requires double-click confirmation", () => { + const onBan = vi.fn(async () => {}); + const { result } = makeMenu({ onBan }); + + const dangerItems = result.element.querySelectorAll(".context-menu__item--danger"); + const banItem = Array.from(dangerItems).find((i) => i.textContent === "Ban") as HTMLDivElement; + + banItem.click(); + expect(banItem.textContent).toBe("Are you sure?"); + + banItem.click(); + expect(onBan).toHaveBeenCalledOnce(); + result.destroy(); + }); + + it("renders separator between role and danger items", () => { + const { result } = makeMenu(); + const separator = result.element.querySelector(".context-menu__separator"); + expect(separator).not.toBeNull(); + result.destroy(); + }); + + it("destroy removes element from DOM", () => { + const { result } = makeMenu(); + expect(container.querySelector(".context-menu")).not.toBeNull(); + result.destroy(); + expect(container.querySelector(".context-menu")).toBeNull(); + }); + }); + + describe("ChannelContextMenu", () => { + function makeMenu(overrides?: Partial<ChannelContextMenuOptions>) { + const options: ChannelContextMenuOptions = { + channelId: 1, + channelName: "general", + onEdit: overrides?.onEdit ?? vi.fn(), + onDelete: overrides?.onDelete ?? vi.fn(async () => {}), + onCreate: overrides?.onCreate ?? vi.fn(), + }; + const result = createChannelContextMenu(options); + container.appendChild(result.element); + return { result, options }; + } + + it("creates element with context-menu class", () => { + const { result } = makeMenu(); + expect(result.element.classList.contains("context-menu")).toBe(true); + result.destroy(); + }); + + it("renders Edit Channel, Create Channel, and Delete Channel items", () => { + const { result } = makeMenu(); + const items = result.element.querySelectorAll(".context-menu__item"); + const texts = Array.from(items).map((i) => i.textContent); + + expect(texts).toContain("Edit Channel"); + expect(texts).toContain("Create Channel"); + expect(texts).toContain("Delete Channel"); + result.destroy(); + }); + + it("clicking Edit Channel calls onEdit", () => { + const onEdit = vi.fn(); + const { result } = makeMenu({ onEdit }); + + const items = result.element.querySelectorAll(".context-menu__item"); + const editItem = Array.from(items).find((i) => i.textContent === "Edit Channel") as HTMLDivElement; + editItem.click(); + + expect(onEdit).toHaveBeenCalledOnce(); + result.destroy(); + }); + + it("clicking Create Channel calls onCreate", () => { + const onCreate = vi.fn(); + const { result } = makeMenu({ onCreate }); + + const items = result.element.querySelectorAll(".context-menu__item"); + const createItem = Array.from(items).find((i) => i.textContent === "Create Channel") as HTMLDivElement; + createItem.click(); + + expect(onCreate).toHaveBeenCalledOnce(); + result.destroy(); + }); + + it("Delete Channel requires double-click confirmation", () => { + const onDelete = vi.fn(async () => {}); + const { result } = makeMenu({ onDelete }); + + const dangerItems = result.element.querySelectorAll(".context-menu__item--danger"); + const deleteItem = dangerItems[0] as HTMLDivElement; + + deleteItem.click(); + expect(deleteItem.textContent).toBe("Are you sure?"); + expect(onDelete).not.toHaveBeenCalled(); + + deleteItem.click(); + expect(onDelete).toHaveBeenCalledOnce(); + result.destroy(); + }); + + it("destroy removes element from DOM", () => { + const { result } = makeMenu(); + expect(container.querySelector(".context-menu")).not.toBeNull(); + result.destroy(); + expect(container.querySelector(".context-menu")).toBeNull(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/api.test.ts b/Client/tauri-client/tests/unit/api.test.ts new file mode 100644 index 00000000..d2a6ec3c --- /dev/null +++ b/Client/tauri-client/tests/unit/api.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock the Tauri HTTP plugin — vi.hoisted ensures the fn is available when +// vi.mock's factory runs (hoisted above all imports). +const { mockFetch } = vi.hoisted(() => ({ + mockFetch: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-http", () => ({ + fetch: mockFetch, +})); + +import { createApiClient, ApiClientError } from "../../src/lib/api"; + +function jsonResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: "OK", + json: () => Promise.resolve(data), + headers: new Headers(), + } as unknown as Response; +} + +function errorResponse( + status: number, + code: string, + message: string, +): Response { + return { + ok: false, + status, + statusText: message, + json: () => Promise.resolve({ error: code, message }), + headers: new Headers(), + } as unknown as Response; +} + +describe("API Client", () => { + let api: ReturnType<typeof createApiClient>; + let onUnauthorized: ReturnType<typeof vi.fn>; + + beforeEach(() => { + mockFetch.mockReset(); + onUnauthorized = vi.fn(); + api = createApiClient( + { host: "localhost:8443", token: "test-token" }, + onUnauthorized, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("API base path uses /api/v1/", () => { + it("login calls /api/v1/auth/login", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ token: "t", requires_2fa: false }), + ); + await api.login("user", "pass"); + const url = mockFetch.mock.calls[0]?.[0] as string; + expect(url).toBe("https://localhost:8443/api/v1/auth/login"); + }); + + it("getMessages calls /api/v1/channels/{id}/messages", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ messages: [], has_more: false }), + ); + await api.getMessages(5); + const url = mockFetch.mock.calls[0]?.[0] as string; + expect(url).toBe("https://localhost:8443/api/v1/channels/5/messages"); + }); + + it("search calls /api/v1/search", async () => { + mockFetch.mockResolvedValue(jsonResponse({ results: [] })); + await api.search("hello"); + const url = mockFetch.mock.calls[0]?.[0] as string; + expect(url).toContain("https://localhost:8443/api/v1/search"); + }); + + it("getHealth calls /api/v1/health", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ status: "ok", version: "1.0.0", uptime: 100 }), + ); + await api.getHealth(); + const url = mockFetch.mock.calls[0]?.[0] as string; + expect(url).toBe("https://localhost:8443/api/v1/health"); + }); + }); + + describe("auth endpoints", () => { + it("register sends invite_code", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ user: { id: 1, username: "u" }, token: "t" }, 201), + ); + await api.register("user", "pass", "invite123"); + const body = JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string); + expect(body.invite_code).toBe("invite123"); + }); + + it("sends Authorization header", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + await api.getMe(); + const headers = mockFetch.mock.calls[0]?.[1]?.headers as Record<string, string>; + expect(headers["Authorization"]).toBe("Bearer test-token"); + }); + }); + + describe("error handling", () => { + it("throws ApiClientError on non-ok response", async () => { + mockFetch.mockResolvedValue( + errorResponse(403, "FORBIDDEN", "No permission"), + ); + await expect(api.getMe()).rejects.toThrow(ApiClientError); + await expect(api.getMe()).rejects.toMatchObject({ + status: 403, + code: "FORBIDDEN", + }); + }); + + it("calls onUnauthorized on 401", async () => { + mockFetch.mockResolvedValue( + errorResponse(401, "UNAUTHORIZED", "Invalid session"), + ); + await expect(api.getMe()).rejects.toThrow(); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + }); + + it("does not call onUnauthorized on other errors", async () => { + mockFetch.mockResolvedValue( + errorResponse(500, "SERVER_ERROR", "Internal error"), + ); + await expect(api.getMe()).rejects.toThrow(); + expect(onUnauthorized).not.toHaveBeenCalled(); + }); + }); + + describe("cancellation", () => { + it("passes AbortSignal to fetch", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + const controller = new AbortController(); + await api.getMe(controller.signal); + expect(mockFetch.mock.calls[0]?.[1]?.signal).toBe(controller.signal); + }); + }); + + describe("pagination", () => { + it("getMessages passes before and limit params", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ messages: [], has_more: false }), + ); + await api.getMessages(5, { before: 100, limit: 25 }); + const url = mockFetch.mock.calls[0]?.[0] as string; + expect(url).toContain("before=100"); + expect(url).toContain("limit=25"); + }); + }); + + describe("config management", () => { + it("setConfig updates token", async () => { + mockFetch.mockResolvedValue(jsonResponse({})); + api.setConfig({ token: "new-token" }); + await api.getMe(); + const headers = mockFetch.mock.calls[0]?.[1]?.headers as Record<string, string>; + expect(headers["Authorization"]).toBe("Bearer new-token"); + }); + }); + + describe("user endpoints", () => { + it("getSessions calls correct endpoint", async () => { + mockFetch.mockResolvedValue(jsonResponse([])); + await api.getSessions(); + const url = mockFetch.mock.calls[0]?.[0] as string; + expect(url).toBe("https://localhost:8443/api/v1/users/me/sessions"); + }); + + it("revokeSession calls DELETE with session ID", async () => { + mockFetch.mockResolvedValue(jsonResponse(undefined, 204)); + await api.revokeSession(42); + const url = mockFetch.mock.calls[0]?.[0] as string; + const method = mockFetch.mock.calls[0]?.[1]?.method as string; + expect(url).toBe("https://localhost:8443/api/v1/users/me/sessions/42"); + expect(method).toBe("DELETE"); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/auth.store.test.ts b/Client/tauri-client/tests/unit/auth.store.test.ts new file mode 100644 index 00000000..c8f9d1df --- /dev/null +++ b/Client/tauri-client/tests/unit/auth.store.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + authStore, + setAuth, + clearAuth, + getToken, + getCurrentUser, + updateUser, +} from "../../src/stores/auth.store"; +import type { UserWithRole } from "../../src/lib/types"; + +const TEST_USER: UserWithRole = { + id: 42, + username: "testuser", + avatar: "avatar.png", + role: "member", +}; + +const TEST_TOKEN = "session-token-abc123"; +const TEST_SERVER_NAME = "My OwnCord Server"; +const TEST_MOTD = "Welcome to OwnCord!"; + +function resetStore(): void { + clearAuth(); +} + +describe("auth store", () => { + beforeEach(() => { + resetStore(); + }); + + // 1. Initial state is unauthenticated + describe("initial state", () => { + it("has null token", () => { + expect(authStore.getState().token).toBeNull(); + }); + + it("has null user", () => { + expect(authStore.getState().user).toBeNull(); + }); + + it("has null serverName", () => { + expect(authStore.getState().serverName).toBeNull(); + }); + + it("has null motd", () => { + expect(authStore.getState().motd).toBeNull(); + }); + + it("is not authenticated", () => { + expect(authStore.getState().isAuthenticated).toBe(false); + }); + }); + + // 2. setAuth populates all fields correctly + describe("setAuth", () => { + it("sets token", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + expect(authStore.getState().token).toBe(TEST_TOKEN); + }); + + it("sets user", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + expect(authStore.getState().user).toEqual(TEST_USER); + }); + + it("sets serverName", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + expect(authStore.getState().serverName).toBe(TEST_SERVER_NAME); + }); + + it("sets motd", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + expect(authStore.getState().motd).toBe(TEST_MOTD); + }); + + it("sets isAuthenticated to true", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + expect(authStore.getState().isAuthenticated).toBe(true); + }); + + it("returns a new state object on each call", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + const first = authStore.getState(); + setAuth("other-token", TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + const second = authStore.getState(); + expect(first).not.toBe(second); + }); + }); + + // 3. clearAuth resets to initial state + describe("clearAuth", () => { + it("resets all fields after being authenticated", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + clearAuth(); + + const state = authStore.getState(); + expect(state.token).toBeNull(); + expect(state.user).toBeNull(); + expect(state.serverName).toBeNull(); + expect(state.motd).toBeNull(); + expect(state.isAuthenticated).toBe(false); + }); + + it("produces a new state object", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + const before = authStore.getState(); + clearAuth(); + const after = authStore.getState(); + expect(before).not.toBe(after); + }); + }); + + // 4. getToken returns current token + describe("getToken", () => { + it("returns null when unauthenticated", () => { + expect(getToken()).toBeNull(); + }); + + it("returns token after setAuth", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + expect(getToken()).toBe(TEST_TOKEN); + }); + + it("returns null after clearAuth", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + clearAuth(); + expect(getToken()).toBeNull(); + }); + }); + + // 5. updateUser patches user fields + describe("updateUser", () => { + it("updates username on authenticated user", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + updateUser({ username: "newname" }); + expect(authStore.getState().user?.username).toBe("newname"); + }); + + it("preserves other user fields when patching", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + updateUser({ username: "newname" }); + const user = authStore.getState().user; + expect(user?.id).toBe(42); + expect(user?.avatar).toBe("avatar.png"); + expect(user?.role).toBe("member"); + }); + + it("is a no-op when user is null", () => { + updateUser({ username: "newname" }); + expect(authStore.getState().user).toBeNull(); + }); + + it("produces a new state object", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + const before = authStore.getState(); + updateUser({ username: "changed" }); + expect(authStore.getState()).not.toBe(before); + }); + + it("produces a new user object (immutable)", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + const userBefore = authStore.getState().user; + updateUser({ avatar: "new-avatar.png" }); + const userAfter = authStore.getState().user; + expect(userBefore).not.toBe(userAfter); + expect(userAfter?.avatar).toBe("new-avatar.png"); + }); + }); + + // 6. getCurrentUser returns current user + describe("getCurrentUser", () => { + it("returns null when unauthenticated", () => { + expect(getCurrentUser()).toBeNull(); + }); + + it("returns user after setAuth", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + expect(getCurrentUser()).toEqual(TEST_USER); + }); + + it("returns null after clearAuth", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + clearAuth(); + expect(getCurrentUser()).toBeNull(); + }); + }); + + // 6. Subscribe receives updates on setAuth/clearAuth + describe("subscribe", () => { + it("notifies on setAuth", () => { + const listener = vi.fn(); + const unsub = authStore.subscribe(listener); + + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + authStore.flush(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + token: TEST_TOKEN, + user: TEST_USER, + serverName: TEST_SERVER_NAME, + motd: TEST_MOTD, + isAuthenticated: true, + }), + ); + + unsub(); + }); + + it("notifies on clearAuth", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + + const listener = vi.fn(); + const unsub = authStore.subscribe(listener); + + clearAuth(); + authStore.flush(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + }), + ); + + unsub(); + }); + + it("does not notify after unsubscribe", () => { + const listener = vi.fn(); + const unsub = authStore.subscribe(listener); + unsub(); + + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + expect(listener).not.toHaveBeenCalled(); + }); + + it("notifies multiple subscribers independently", () => { + const listenerA = vi.fn(); + const listenerB = vi.fn(); + const unsubA = authStore.subscribe(listenerA); + const unsubB = authStore.subscribe(listenerB); + + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + authStore.flush(); + + expect(listenerA).toHaveBeenCalledTimes(1); + expect(listenerB).toHaveBeenCalledTimes(1); + + unsubA(); + unsubB(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts b/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts new file mode 100644 index 00000000..341e74d4 --- /dev/null +++ b/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createCertMismatchModal } from "../../src/components/CertMismatchModal"; +import { parseStoredFingerprint } from "../../src/lib/ws"; + +// --------------------------------------------------------------------------- +// parseStoredFingerprint +// --------------------------------------------------------------------------- + +describe("parseStoredFingerprint", () => { + it("extracts stored fingerprint from Rust message", () => { + const msg = + "Certificate fingerprint changed for localhost:8443.\n" + + "Stored: 51:32:d1:f9:61:47:e4:cc:26:6f:3a:87\n" + + "Current: 23:e4:00:61:11:f7:e5:12:eb:b9:2d:19\n" + + "This may indicate a man-in-the-middle attack."; + expect(parseStoredFingerprint(msg)).toBe( + "51:32:d1:f9:61:47:e4:cc:26:6f:3a:87", + ); + }); + + it("returns undefined for undefined message", () => { + expect(parseStoredFingerprint(undefined)).toBeUndefined(); + }); + + it("returns undefined when no Stored line present", () => { + expect(parseStoredFingerprint("some other message")).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + expect(parseStoredFingerprint("")).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// CertMismatchModal +// --------------------------------------------------------------------------- + +describe("CertMismatchModal", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function mountModal(overrides?: Partial<Parameters<typeof createCertMismatchModal>[0]>) { + const onAccept = vi.fn(); + const onReject = vi.fn(); + const modal = createCertMismatchModal({ + host: "localhost:8443", + storedFingerprint: "AA:BB:CC:DD", + newFingerprint: "11:22:33:44", + onAccept, + onReject, + ...overrides, + }); + modal.mount(container); + return { modal, onAccept, onReject }; + } + + it("renders a visible modal overlay", () => { + mountModal(); + const overlay = container.querySelector(".modal-overlay"); + expect(overlay).not.toBeNull(); + expect(overlay!.classList.contains("visible")).toBe(true); + }); + + it("displays the host in the details", () => { + mountModal(); + const values = container.querySelectorAll(".cert-value"); + const texts = Array.from(values).map((el) => el.textContent); + expect(texts).toContain("localhost:8443"); + }); + + it("displays stored and new fingerprints", () => { + mountModal(); + const fps = container.querySelectorAll(".cert-fingerprint"); + const texts = Array.from(fps).map((el) => el.textContent); + expect(texts).toContain("AA:BB:CC:DD"); + expect(texts).toContain("11:22:33:44"); + }); + + it("shows 'Unknown' when storedFingerprint is empty", () => { + mountModal({ storedFingerprint: "" }); + const fps = container.querySelectorAll(".cert-fingerprint"); + const texts = Array.from(fps).map((el) => el.textContent); + expect(texts).toContain("Unknown"); + }); + + it("calls onAccept when accept button is clicked", () => { + const { onAccept } = mountModal(); + const btn = container.querySelector(".btn-danger") as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + expect(onAccept).toHaveBeenCalledOnce(); + }); + + it("calls onReject when disconnect button is clicked", () => { + const { onReject } = mountModal(); + const btn = container.querySelector(".btn-ghost") as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("calls onReject when close X button is clicked", () => { + const { onReject } = mountModal(); + const btn = container.querySelector(".modal-close") as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("calls onReject when backdrop is clicked", () => { + const { onReject } = mountModal(); + const overlay = container.querySelector(".modal-overlay") as HTMLDivElement; + overlay.click(); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("does not call onReject when modal body is clicked", () => { + const { onReject } = mountModal(); + const modal = container.querySelector(".modal") as HTMLDivElement; + modal.click(); + expect(onReject).not.toHaveBeenCalled(); + }); + + it("destroy removes the modal from the DOM", () => { + const { modal } = mountModal(); + expect(container.querySelector(".modal-overlay")).not.toBeNull(); + modal.destroy?.(); + expect(container.querySelector(".modal-overlay")).toBeNull(); + }); + + it("displays the title 'Certificate Warning'", () => { + mountModal(); + const title = container.querySelector(".modal-header h3"); + expect(title?.textContent).toBe("Certificate Warning"); + }); + + it("displays the cert title 'Certificate Changed'", () => { + mountModal(); + const title = container.querySelector(".cert-title"); + expect(title?.textContent).toBe("Certificate Changed"); + }); +}); diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts new file mode 100644 index 00000000..187e68fb --- /dev/null +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createChannelSidebar } from "../../src/components/ChannelSidebar"; +import { + channelsStore, + setChannels, + setActiveChannel, +} from "../../src/stores/channels.store"; +import { authStore } from "../../src/stores/auth.store"; +import { uiStore, toggleCategory } from "../../src/stores/ui.store"; +import { voiceStore, updateVoiceState } from "../../src/stores/voice.store"; +import { membersStore } from "../../src/stores/members.store"; +import type { ReadyChannel } from "../../src/lib/types"; + +function resetStores(): void { + channelsStore.setState(() => ({ + channels: new Map(), + activeChannelId: null, + })); + authStore.setState(() => ({ + token: null, + user: null, + serverName: "Test Server", + motd: null, + isAuthenticated: false, + })); + uiStore.setState(() => ({ + sidebarCollapsed: false, + memberListVisible: true, + settingsOpen: false, + activeModal: null, + theme: "dark" as const, + connectionStatus: "disconnected" as const, + transientError: null, + persistentError: null, + collapsedCategories: new Set<string>(), + })); + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +const testChannels: ReadyChannel[] = [ + { + id: 1, + name: "general", + type: "text", + category: "Text Channels", + position: 0, + unread_count: 2, + last_message_id: 100, + }, + { + id: 2, + name: "random", + type: "text", + category: "Text Channels", + position: 1, + unread_count: 0, + last_message_id: 50, + }, + { + id: 3, + name: "voice-lobby", + type: "voice", + category: "Voice Channels", + position: 0, + }, + { + id: 4, + name: "announcements", + type: "announcement", + category: "Info", + position: 0, + unread_count: 5, + last_message_id: 200, + }, +]; + +describe("ChannelSidebar", () => { + let container: HTMLDivElement; + let sidebar: ReturnType<typeof createChannelSidebar>; + let onVoiceJoin: ReturnType<typeof vi.fn>; + let onVoiceLeave: ReturnType<typeof vi.fn>; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + onVoiceJoin = vi.fn(); + onVoiceLeave = vi.fn(); + sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave }); + }); + + afterEach(() => { + sidebar.destroy?.(); + container.remove(); + }); + + it("renders channel list from store", () => { + setChannels(testChannels); + sidebar.mount(container); + + const items = container.querySelectorAll(".channel-item"); + expect(items.length).toBe(4); + + const names = Array.from( + container.querySelectorAll(".ch-name"), + ).map((el) => el.textContent); + expect(names).toContain("general"); + expect(names).toContain("random"); + expect(names).toContain("voice-lobby"); + expect(names).toContain("announcements"); + }); + + it("groups channels by category", () => { + setChannels(testChannels); + sidebar.mount(container); + + const categories = container.querySelectorAll(".category"); + const categoryNames = Array.from(categories).map( + (el) => el.querySelector(".category-name")?.textContent, + ); + + expect(categoryNames).toContain("Text Channels"); + expect(categoryNames).toContain("Voice Channels"); + expect(categoryNames).toContain("Info"); + }); + + it("click channel sets active and clears unread", () => { + setChannels(testChannels); + sidebar.mount(container); + + // Channel 1 (general) has unread_count of 2 + const ch1Before = channelsStore.getState().channels.get(1); + expect(ch1Before?.unreadCount).toBe(2); + + const firstItem = container.querySelector( + '[data-channel-id="1"]', + ) as HTMLElement; + expect(firstItem).not.toBeNull(); + firstItem.click(); + + const state = channelsStore.getState(); + expect(state.activeChannelId).toBe(1); + expect(state.channels.get(1)?.unreadCount).toBe(0); + }); + + it("category collapse toggles visibility", () => { + setChannels(testChannels); + sidebar.mount(container); + + // Text Channels category should have 2 channels visible + const textChannelsBefore = container.querySelectorAll( + '.channel-item', + ); + expect(textChannelsBefore.length).toBe(4); + + // Click the "Text Channels" category header to collapse + const headers = container.querySelectorAll(".category"); + const textHeader = Array.from(headers).find( + (h) => h.querySelector(".category-name")?.textContent === "Text Channels", + ) as HTMLElement; + expect(textHeader).not.toBeUndefined(); + textHeader.click(); + uiStore.flush(); + + // After collapse, "Text Channels" channels should be hidden + // The sidebar re-renders on uiStore change, so channels under + // collapsed category are not in the DOM + const itemsAfter = container.querySelectorAll(".channel-item"); + expect(itemsAfter.length).toBe(2); // only Voice + Info channels remain + + // Expand again + const headersAfter = container.querySelectorAll(".category"); + const textHeaderAfter = Array.from(headersAfter).find( + (h) => h.querySelector(".category-name")?.textContent === "Text Channels", + ) as HTMLElement; + textHeaderAfter.click(); + uiStore.flush(); + + const itemsExpanded = container.querySelectorAll(".channel-item"); + expect(itemsExpanded.length).toBe(4); + }); + + it("displays server name from auth store", () => { + sidebar.mount(container); + + const serverName = container.querySelector(".channel-sidebar-header h2"); + expect(serverName?.textContent).toBe("Test Server"); + }); + + it("shows unread badge for channels with unread messages", () => { + setChannels(testChannels); + sidebar.mount(container); + + const badges = container.querySelectorAll(".unread-badge"); + expect(badges.length).toBe(2); // general (2) and announcements (5) + + const badgeTexts = Array.from(badges).map((b) => b.textContent); + expect(badgeTexts).toContain("2"); + expect(badgeTexts).toContain("5"); + }); + + it("marks active channel with active class", () => { + setChannels(testChannels); + setActiveChannel(2); + sidebar.mount(container); + + const activeItem = container.querySelector( + '[data-channel-id="2"]', + ); + expect(activeItem?.classList.contains("active")).toBe(true); + }); + + it("shows voice icon for voice channels", () => { + setChannels(testChannels); + sidebar.mount(container); + + const voiceItem = container.querySelector( + '[data-channel-id="3"]', + ); + const icon = voiceItem?.querySelector(".ch-icon"); + expect(icon).not.toBeNull(); + }); + + it("clicking voice channel calls onVoiceJoin instead of setActiveChannel", () => { + setChannels(testChannels); + sidebar.mount(container); + + const voiceItem = container.querySelector( + '[data-channel-id="3"]', + ) as HTMLElement; + voiceItem.click(); + + // Should NOT set active channel + expect(channelsStore.getState().activeChannelId).toBeNull(); + // Should call onVoiceJoin with channel id + expect(onVoiceJoin).toHaveBeenCalledWith(3); + }); + + it("clicking text channel still sets active channel normally", () => { + setChannels(testChannels); + sidebar.mount(container); + + const textItem = container.querySelector( + '[data-channel-id="1"]', + ) as HTMLElement; + textItem.click(); + + expect(channelsStore.getState().activeChannelId).toBe(1); + expect(onVoiceJoin).not.toHaveBeenCalled(); + }); + + it("clicking joined voice channel calls onVoiceLeave", () => { + setChannels(testChannels); + voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 })); + sidebar.mount(container); + + const voiceItem = container.querySelector( + '[data-channel-id="3"]', + ) as HTMLElement; + voiceItem.click(); + + expect(onVoiceLeave).toHaveBeenCalled(); + expect(onVoiceJoin).not.toHaveBeenCalled(); + }); + + it("shows connected voice users under voice channel", () => { + setChannels(testChannels); + // Add a member so username resolves + membersStore.setState((prev) => ({ + ...prev, + members: new Map([[10, { id: 10, username: "Alice", avatar: null, role: "member", status: "online" as const }]]), + })); + updateVoiceState({ + channel_id: 3, + user_id: 10, + username: "Alice", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }); + sidebar.mount(container); + + const voiceUsersList = container.querySelector(".voice-users-list"); + expect(voiceUsersList).not.toBeNull(); + + const userItems = container.querySelectorAll(".voice-user-item"); + expect(userItems.length).toBe(1); + + const userName = userItems[0]?.querySelector(".vu-name"); + expect(userName?.textContent).toBe("Alice"); + }); + + it("highlights voice channel as active when user is joined", () => { + setChannels(testChannels); + voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 })); + sidebar.mount(container); + + const voiceItem = container.querySelector( + '[data-channel-id="3"]', + ); + expect(voiceItem?.classList.contains("active")).toBe(true); + }); + + it("re-renders when voice store changes", () => { + setChannels(testChannels); + sidebar.mount(container); + + // Initially no voice users + let voiceUsers = container.querySelectorAll(".voice-user-item"); + expect(voiceUsers.length).toBe(0); + + // Add a voice user + updateVoiceState({ + channel_id: 3, + user_id: 20, + username: "Bob", + muted: true, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }); + voiceStore.flush(); + + voiceUsers = container.querySelectorAll(".voice-user-item"); + expect(voiceUsers.length).toBe(1); + + // Should show muted icon + const mutedIcon = voiceUsers[0]?.querySelector(".vu-muted"); + expect(mutedIcon).not.toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/channels.store.test.ts b/Client/tauri-client/tests/unit/channels.store.test.ts new file mode 100644 index 00000000..6557e7e8 --- /dev/null +++ b/Client/tauri-client/tests/unit/channels.store.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + channelsStore, + setChannels, + addChannel, + updateChannel, + removeChannel, + setActiveChannel, + getActiveChannel, + getChannelsByCategory, + incrementUnread, + clearUnread, +} from '../../src/stores/channels.store'; +import type { + ReadyChannel, + ChannelCreatePayload, + ChannelUpdatePayload, +} from '../../src/lib/types'; + +function resetStore(): void { + channelsStore.setState(() => ({ + channels: new Map(), + activeChannelId: null, + })); +} + +const readyChannels: ReadyChannel[] = [ + { id: 1, name: 'general', type: 'text', category: 'Text', position: 0, unread_count: 3, last_message_id: 100 }, + { id: 2, name: 'voice-lobby', type: 'voice', category: 'Voice', position: 0 }, + { id: 3, name: 'announcements', type: 'announcement', category: 'Text', position: 1, unread_count: 0, last_message_id: 50 }, +]; + +describe('channels store', () => { + beforeEach(() => { + resetStore(); + }); + + it('has empty initial state', () => { + const state = channelsStore.getState(); + expect(state.channels.size).toBe(0); + expect(state.activeChannelId).toBeNull(); + }); + + describe('setChannels', () => { + it('populates channels from ready payload', () => { + setChannels(readyChannels); + const state = channelsStore.getState(); + + expect(state.channels.size).toBe(3); + + const general = state.channels.get(1); + expect(general).toEqual({ + id: 1, + name: 'general', + type: 'text', + category: 'Text', + position: 0, + unreadCount: 3, + lastMessageId: 100, + }); + + const voice = state.channels.get(2); + expect(voice).toEqual({ + id: 2, + name: 'voice-lobby', + type: 'voice', + category: 'Voice', + position: 0, + unreadCount: 0, + lastMessageId: null, + }); + }); + + it('defaults unread_count to 0 and last_message_id to null', () => { + setChannels([{ id: 10, name: 'test', type: 'text', category: null, position: 0 }]); + const ch = channelsStore.getState().channels.get(10); + expect(ch?.unreadCount).toBe(0); + expect(ch?.lastMessageId).toBeNull(); + }); + }); + + describe('addChannel', () => { + it('adds a new channel', () => { + setChannels(readyChannels); + + const payload: ChannelCreatePayload = { + id: 4, + name: 'new-channel', + type: 'text', + category: 'Text', + position: 2, + }; + addChannel(payload); + + const state = channelsStore.getState(); + expect(state.channels.size).toBe(4); + + const added = state.channels.get(4); + expect(added).toEqual({ + id: 4, + name: 'new-channel', + type: 'text', + category: 'Text', + position: 2, + unreadCount: 0, + lastMessageId: null, + }); + }); + + it('does not mutate the previous channels map', () => { + setChannels(readyChannels); + const before = channelsStore.getState().channels; + + addChannel({ id: 5, name: 'extra', type: 'text', category: null, position: 0 }); + const after = channelsStore.getState().channels; + + expect(before).not.toBe(after); + expect(before.size).toBe(3); + expect(after.size).toBe(4); + }); + }); + + describe('updateChannel', () => { + it('updates name immutably', () => { + setChannels(readyChannels); + const before = channelsStore.getState().channels.get(1); + + const update: ChannelUpdatePayload = { id: 1, name: 'renamed' }; + updateChannel(update); + + const after = channelsStore.getState().channels.get(1); + expect(after?.name).toBe('renamed'); + expect(after?.position).toBe(0); // unchanged + expect(before).not.toBe(after); + }); + + it('updates position immutably', () => { + setChannels(readyChannels); + + updateChannel({ id: 1, position: 5 }); + + const ch = channelsStore.getState().channels.get(1); + expect(ch?.position).toBe(5); + expect(ch?.name).toBe('general'); // unchanged + }); + + it('updates both name and position', () => { + setChannels(readyChannels); + + updateChannel({ id: 1, name: 'new-name', position: 10 }); + + const ch = channelsStore.getState().channels.get(1); + expect(ch?.name).toBe('new-name'); + expect(ch?.position).toBe(10); + }); + + it('is a no-op for unknown channel id', () => { + setChannels(readyChannels); + const before = channelsStore.getState(); + + updateChannel({ id: 999, name: 'ghost' }); + + const after = channelsStore.getState(); + expect(after).toBe(before); + }); + }); + + describe('removeChannel', () => { + it('removes a channel', () => { + setChannels(readyChannels); + + removeChannel(1); + + const state = channelsStore.getState(); + expect(state.channels.size).toBe(2); + expect(state.channels.has(1)).toBe(false); + }); + + it('clears activeChannelId if removed channel was active', () => { + setChannels(readyChannels); + setActiveChannel(1); + expect(channelsStore.getState().activeChannelId).toBe(1); + + removeChannel(1); + + expect(channelsStore.getState().activeChannelId).toBeNull(); + }); + + it('preserves activeChannelId if removed channel was not active', () => { + setChannels(readyChannels); + setActiveChannel(2); + + removeChannel(1); + + expect(channelsStore.getState().activeChannelId).toBe(2); + }); + }); + + describe('setActiveChannel', () => { + it('sets active channel id', () => { + setChannels(readyChannels); + + setActiveChannel(2); + + expect(channelsStore.getState().activeChannelId).toBe(2); + }); + + it('sets active channel to null', () => { + setChannels(readyChannels); + setActiveChannel(1); + + setActiveChannel(null); + + expect(channelsStore.getState().activeChannelId).toBeNull(); + }); + + it('clears unread count for the activated channel', () => { + setChannels(readyChannels); + // channel 1 starts with unreadCount: 3 + expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(3); + + setActiveChannel(1); + + expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(0); + }); + + it('does not mutate channels map when clearing unread', () => { + setChannels(readyChannels); + const before = channelsStore.getState().channels; + + setActiveChannel(1); + + const after = channelsStore.getState().channels; + expect(before).not.toBe(after); + // other channels unchanged + expect(after.get(2)).toBe(before.get(2)); + }); + + it('skips channels map update when unread is already 0', () => { + setChannels(readyChannels); + // channel 2 has unreadCount: 0 + const before = channelsStore.getState().channels; + + setActiveChannel(2); + + const after = channelsStore.getState().channels; + expect(before).toBe(after); + }); + }); + + describe('getActiveChannel', () => { + it('returns null when no active channel', () => { + expect(getActiveChannel()).toBeNull(); + }); + + it('returns the active Channel object', () => { + setChannels(readyChannels); + setActiveChannel(1); + + const active = getActiveChannel(); + expect(active).toEqual({ + id: 1, + name: 'general', + type: 'text', + category: 'Text', + position: 0, + unreadCount: 0, + lastMessageId: 100, + }); + }); + + it('returns null if activeChannelId refers to a non-existent channel', () => { + setActiveChannel(999); + + expect(getActiveChannel()).toBeNull(); + }); + }); + + describe('getChannelsByCategory', () => { + it('groups channels by category and sorts by position', () => { + setChannels(readyChannels); + + const grouped = getChannelsByCategory(); + + expect(grouped.size).toBe(2); + + const textChannels = grouped.get('Text'); + expect(textChannels).toHaveLength(2); + expect(textChannels?.[0]?.name).toBe('general'); // position 0 + expect(textChannels?.[1]?.name).toBe('announcements'); // position 1 + + const voiceChannels = grouped.get('Voice'); + expect(voiceChannels).toHaveLength(1); + expect(voiceChannels?.[0]?.name).toBe('voice-lobby'); + }); + + it('handles null category', () => { + setChannels([ + { id: 1, name: 'uncategorized', type: 'text', category: null, position: 0 }, + ]); + + const grouped = getChannelsByCategory(); + expect(grouped.has(null)).toBe(true); + expect(grouped.get(null)).toHaveLength(1); + }); + + it('returns empty map when no channels', () => { + const grouped = getChannelsByCategory(); + expect(grouped.size).toBe(0); + }); + }); + + describe('incrementUnread', () => { + it('increments unread count for a channel', () => { + setChannels(readyChannels); + + incrementUnread(1); + + const ch = channelsStore.getState().channels.get(1); + expect(ch?.unreadCount).toBe(4); // was 3 + }); + + it('skips increment for the active channel', () => { + setChannels(readyChannels); + setActiveChannel(1); + // setActiveChannel clears unread, so it's now 0 + expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(0); + + incrementUnread(1); + + const ch = channelsStore.getState().channels.get(1); + expect(ch?.unreadCount).toBe(0); // unchanged — active channel skips increment + }); + + it('is a no-op for unknown channel id', () => { + setChannels(readyChannels); + const before = channelsStore.getState(); + + incrementUnread(999); + + expect(channelsStore.getState()).toBe(before); + }); + }); + + describe('clearUnread', () => { + it('resets unread count to 0', () => { + setChannels(readyChannels); + expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(3); + + clearUnread(1); + + expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(0); + }); + + it('is a no-op for unknown channel id', () => { + setChannels(readyChannels); + const before = channelsStore.getState(); + + clearUnread(999); + + expect(channelsStore.getState()).toBe(before); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/chat-header.test.ts b/Client/tauri-client/tests/unit/chat-header.test.ts new file mode 100644 index 00000000..5d0c8c9e --- /dev/null +++ b/Client/tauri-client/tests/unit/chat-header.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { buildChatHeader } from "../../src/pages/main-page/ChatHeader"; + +describe("ChatHeader", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders the chat header element", () => { + const { element } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + expect(container.querySelector('[data-testid="chat-header"]')).not.toBeNull(); + }); + + it("displays default channel name", () => { + const { element, refs } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + expect(refs.nameEl.textContent).toBe("general"); + expect(container.querySelector('[data-testid="chat-header-name"]')?.textContent).toBe("general"); + }); + + it("displays hash prefix", () => { + const { element } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + const hash = container.querySelector(".ch-hash"); + expect(hash?.textContent).toBe("#"); + }); + + it("contains a search input", () => { + const { element } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + const searchInput = container.querySelector(".search-input") as HTMLInputElement; + expect(searchInput).not.toBeNull(); + expect(searchInput.placeholder).toBe("Search..."); + }); + + it("calls onTogglePins when pin button is clicked", () => { + const onTogglePins = vi.fn(); + const { element } = buildChatHeader({ + onTogglePins, + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + const pinBtn = container.querySelector('[data-testid="pin-btn"]') as HTMLButtonElement; + pinBtn.click(); + expect(onTogglePins).toHaveBeenCalledOnce(); + }); + + it("calls onToggleMembers when members toggle is clicked", () => { + const onToggleMembers = vi.fn(); + const { element } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers, + }); + container.appendChild(element); + + const membersToggle = container.querySelector('[data-testid="members-toggle"]') as HTMLButtonElement; + membersToggle.click(); + expect(onToggleMembers).toHaveBeenCalledOnce(); + }); + + it("provides mutable refs for channel name and topic", () => { + const { element, refs } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + // Update name via ref + refs.nameEl.textContent = "announcements"; + expect(container.querySelector('[data-testid="chat-header-name"]')?.textContent).toBe("announcements"); + + // Update topic via ref + refs.topicEl.textContent = "Important news"; + expect(container.querySelector(".ch-topic")?.textContent).toBe("Important news"); + }); + + it("has proper aria labels on buttons", () => { + const { element } = buildChatHeader({ + onTogglePins: vi.fn(), + onToggleMembers: vi.fn(), + }); + container.appendChild(element); + + const pinBtn = container.querySelector('[data-testid="pin-btn"]'); + expect(pinBtn?.getAttribute("aria-label")).toBe("Pins"); + + const membersToggle = container.querySelector('[data-testid="members-toggle"]'); + expect(membersToggle?.getAttribute("aria-label")).toBe("Toggle member list"); + }); +}); diff --git a/Client/tauri-client/tests/unit/chat.test.ts b/Client/tauri-client/tests/unit/chat.test.ts new file mode 100644 index 00000000..f02ac49c --- /dev/null +++ b/Client/tauri-client/tests/unit/chat.test.ts @@ -0,0 +1,653 @@ +/** + * Step 5.47 — Chat unit tests. + * Tests for message grouping, day dividers, @mention parsing, + * typing indicator, reaction bar, message actions, and message input. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// --------------------------------------------------------------------------- +// MessageList helpers (we test the exported component's behavior via DOM) +// --------------------------------------------------------------------------- + +import { createMessageList } from "../../src/components/MessageList"; +import { + messagesStore, + addMessage, + setMessages, +} from "../../src/stores/messages.store"; +import { membersStore, setMembers } from "../../src/stores/members.store"; + +// Reset stores before each test +function resetStores(): void { + messagesStore.setState(() => ({ + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +// Helper to create a basic message payload +function makeMessage( + id: number, + userId: number, + username: string, + content: string, + timestamp: string, + opts?: { + replyTo?: number; + deleted?: boolean; + editedAt?: string; + role?: string; + }, +) { + return { + id, + channel_id: 1, + user: { + id: userId, + username, + avatar: null, + role: opts?.role ?? "member", + }, + content, + reply_to: opts?.replyTo ?? null, + attachments: [], + reactions: [], + pinned: false, + edited_at: opts?.editedAt ?? null, + deleted: opts?.deleted ?? false, + timestamp, + }; +} + +describe("MessageList", () => { + let container: HTMLDivElement; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders empty when no messages", () => { + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + const messagesContainer = container.querySelector(".messages-container"); + expect(messagesContainer).not.toBeNull(); + const virtualContent = messagesContainer?.querySelector(".virtual-content"); + expect(virtualContent).not.toBeNull(); + expect(virtualContent?.children.length).toBe(0); + list.destroy?.(); + }); + + it("renders messages after store update", () => { + setMessages(1, [ + makeMessage(1, 10, "Alice", "Hello", "2026-03-15T10:00:00Z"), + ], false); + + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + const groups = container.querySelectorAll(".message"); + expect(groups.length).toBe(1); + const username = container.querySelector(".msg-author"); + expect(username?.textContent).toBe("Alice"); + list.destroy?.(); + }); + + describe("message grouping", () => { + it("groups consecutive messages from same user within 5 minutes", () => { + setMessages(1, [ + makeMessage(1, 10, "Alice", "Hi", "2026-03-15T10:00:00Z"), + makeMessage(2, 10, "Alice", "How are you?", "2026-03-15T10:02:00Z"), + makeMessage(3, 10, "Alice", "Anyone there?", "2026-03-15T10:04:00Z"), + ], false); + + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + const messages = container.querySelectorAll(".message"); + expect(messages.length).toBe(3); + // 2nd and 3rd should be grouped + expect(messages[1]?.classList.contains("grouped")).toBe(true); + expect(messages[2]?.classList.contains("grouped")).toBe(true); + list.destroy?.(); + }); + + it("breaks group when user changes", () => { + setMessages(1, [ + makeMessage(1, 10, "Alice", "Hi", "2026-03-15T10:00:00Z"), + makeMessage(2, 20, "Bob", "Hey!", "2026-03-15T10:01:00Z"), + ], false); + + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + const groups = container.querySelectorAll(".message"); + expect(groups.length).toBe(2); + list.destroy?.(); + }); + + it("breaks group when gap exceeds 5 minutes", () => { + setMessages(1, [ + makeMessage(1, 10, "Alice", "Hi", "2026-03-15T10:00:00Z"), + makeMessage(2, 10, "Alice", "Later", "2026-03-15T10:10:00Z"), + ], false); + + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + const groups = container.querySelectorAll(".message"); + expect(groups.length).toBe(2); + list.destroy?.(); + }); + }); + + describe("day dividers", () => { + it("inserts day divider between messages on different days", () => { + setMessages(1, [ + makeMessage(1, 10, "Alice", "Day 1", "2026-03-10T12:00:00Z"), + makeMessage(2, 10, "Alice", "Day 2", "2026-03-15T12:00:00Z"), + ], false); + + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + const dividers = container.querySelectorAll(".msg-day-divider"); + expect(dividers.length).toBe(2); // one for each day + list.destroy?.(); + }); + }); + + describe("@mention parsing", () => { + it("wraps @username in .mention span", () => { + setMessages(1, [ + makeMessage(1, 10, "Alice", "Hey @Bob check this", "2026-03-15T10:00:00Z"), + ], false); + + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + const mentions = container.querySelectorAll(".mention"); + expect(mentions.length).toBe(1); + expect(mentions[0]?.textContent).toBe("@Bob"); + list.destroy?.(); + }); + + it("handles multiple @mentions in one message", () => { + setMessages(1, [ + makeMessage(1, 10, "Alice", "@Bob and @Charlie look", "2026-03-15T10:00:00Z"), + ], false); + + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + const mentions = container.querySelectorAll(".mention"); + expect(mentions.length).toBe(2); + list.destroy?.(); + }); + }); + + describe("deleted and edited messages", () => { + it("shows [message deleted] for deleted messages", () => { + setMessages(1, [ + makeMessage(1, 10, "Alice", "secret", "2026-03-15T10:00:00Z", { deleted: true }), + ], false); + + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + const deleted = container.querySelector(".msg-text"); + expect(deleted?.textContent).toBe("[message deleted]"); + list.destroy?.(); + }); + + it("shows (edited) indicator for edited messages", () => { + setMessages(1, [ + makeMessage(1, 10, "Alice", "updated text", "2026-03-15T10:00:00Z", { + editedAt: "2026-03-15T10:05:00Z", + }), + ], false); + + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + const edited = container.querySelector(".msg-edited"); + expect(edited?.textContent).toBe("(edited)"); + list.destroy?.(); + }); + }); + + describe("system messages", () => { + it("applies msg--system class to System user messages", () => { + setMessages(1, [ + makeMessage(1, 0, "System", "Alice joined", "2026-03-15T10:00:00Z"), + ], false); + + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + const systemGroup = container.querySelector(".system-msg"); + expect(systemGroup).not.toBeNull(); + list.destroy?.(); + }); + }); + + it("reacts to store changes", () => { + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + + expect(container.querySelectorAll(".message").length).toBe(0); + + // Add a message via store + addMessage({ + id: 1, + channel_id: 1, + user: { id: 10, username: "Alice", avatar: null }, + content: "Hello!", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + messagesStore.flush(); + + expect(container.querySelectorAll(".message").length).toBe(1); + list.destroy?.(); + }); + + it("cleans up subscriptions on destroy", () => { + const list = createMessageList({ + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }); + list.mount(container); + list.destroy?.(); + + // After destroy, adding messages should not cause re-render + addMessage({ + id: 2, + channel_id: 1, + user: { id: 10, username: "Alice", avatar: null }, + content: "After destroy", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + + // Container should be empty since component was destroyed + expect(container.querySelector(".messages-container")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// TypingIndicator +// --------------------------------------------------------------------------- + +import { createTypingIndicator } from "../../src/components/TypingIndicator"; +import { setTyping, clearTyping } from "../../src/stores/members.store"; + +describe("TypingIndicator", () => { + let container: HTMLDivElement; + + beforeEach(() => { + resetStores(); + setMembers([ + { id: 1, username: "Alice", avatar: null, role: "member", status: "online" }, + { id: 2, username: "Bob", avatar: null, role: "member", status: "online" }, + { id: 3, username: "Charlie", avatar: null, role: "member", status: "online" }, + { id: 10, username: "Me", avatar: null, role: "member", status: "online" }, + ]); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("is hidden when no one is typing", () => { + const indicator = createTypingIndicator({ channelId: 1, currentUserId: 10 }); + indicator.mount(container); + + const root = container.querySelector(".typing-bar"); + // Empty = hidden via CSS .typing-bar:empty { height: 0 } + expect(root?.children.length).toBe(0); + indicator.destroy?.(); + }); + + it("shows single user typing", () => { + const indicator = createTypingIndicator({ channelId: 1, currentUserId: 10 }); + indicator.mount(container); + + setTyping(1, 1); // Alice typing in channel 1 + membersStore.flush(); + + const root = container.querySelector(".typing-bar"); + expect(root?.textContent).toContain("Alice"); + expect(root?.textContent).toContain("is typing"); + indicator.destroy?.(); + }); + + it("shows two users typing", () => { + const indicator = createTypingIndicator({ channelId: 1, currentUserId: 10 }); + indicator.mount(container); + + setTyping(1, 1); // Alice + setTyping(1, 2); // Bob + membersStore.flush(); + + const root = container.querySelector(".typing-bar"); + expect(root?.textContent).toContain("and"); + expect(root?.textContent).toContain("are typing"); + indicator.destroy?.(); + }); + + it("shows 'Several people' for 3+ users", () => { + const indicator = createTypingIndicator({ channelId: 1, currentUserId: 10 }); + indicator.mount(container); + + setTyping(1, 1); + setTyping(1, 2); + setTyping(1, 3); + membersStore.flush(); + + const root = container.querySelector(".typing-bar"); + expect(root?.textContent).toContain("Several people are typing..."); + indicator.destroy?.(); + }); + + it("excludes current user from typing display", () => { + const indicator = createTypingIndicator({ channelId: 1, currentUserId: 10 }); + indicator.mount(container); + + setTyping(1, 10); // Me typing — should be filtered + + const root = container.querySelector(".typing-bar"); + expect(root?.children.length).toBe(0); + indicator.destroy?.(); + }); +}); + +// --------------------------------------------------------------------------- +// MessageInput +// --------------------------------------------------------------------------- + +import { createMessageInput } from "../../src/components/MessageInput"; + +describe("MessageInput", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("mounts with textarea and send button", () => { + const input = createMessageInput({ + channelId: 1, + channelName: "general", + onSend: vi.fn(), + onTyping: vi.fn(), + onEditMessage: vi.fn(), + }); + input.mount(container); + + expect(container.querySelector(".msg-textarea")).not.toBeNull(); + expect(container.querySelector("[aria-label='Send message']")).not.toBeNull(); + input.destroy?.(); + }); + + it("sends message on Enter key", () => { + const onSend = vi.fn(); + const input = createMessageInput({ + channelId: 1, + channelName: "general", + onSend, + onTyping: vi.fn(), + onEditMessage: vi.fn(), + }); + input.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "Hello world"; + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(onSend).toHaveBeenCalledWith("Hello world", null, []); + input.destroy?.(); + }); + + it("does not send on Shift+Enter", () => { + const onSend = vi.fn(); + const input = createMessageInput({ + channelId: 1, + channelName: "general", + onSend, + onTyping: vi.fn(), + onEditMessage: vi.fn(), + }); + input.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "Hello"; + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", shiftKey: true })); + expect(onSend).not.toHaveBeenCalled(); + input.destroy?.(); + }); + + it("clears input after sending", () => { + const input = createMessageInput({ + channelId: 1, + channelName: "general", + onSend: vi.fn(), + onTyping: vi.fn(), + onEditMessage: vi.fn(), + }); + input.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "Hello"; + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(textarea.value).toBe(""); + input.destroy?.(); + }); + + it("shows reply bar when setReplyTo is called", () => { + const input = createMessageInput({ + channelId: 1, + channelName: "general", + onSend: vi.fn(), + onTyping: vi.fn(), + onEditMessage: vi.fn(), + }); + input.mount(container); + + input.setReplyTo(5, "Alice"); + const replyBar = container.querySelector(".reply-bar"); + expect(replyBar?.classList.contains("visible")).toBe(true); + expect(replyBar?.textContent).toContain("Alice"); + input.destroy?.(); + }); + + it("hides reply bar when clearReply is called", () => { + const input = createMessageInput({ + channelId: 1, + channelName: "general", + onSend: vi.fn(), + onTyping: vi.fn(), + onEditMessage: vi.fn(), + }); + input.mount(container); + + input.setReplyTo(5, "Alice"); + input.clearReply(); + const replyBar = container.querySelector(".reply-bar"); + expect(replyBar?.classList.contains("visible")).toBe(false); + input.destroy?.(); + }); + + it("enters edit mode and calls onEditMessage", () => { + const onEditMessage = vi.fn(); + const input = createMessageInput({ + channelId: 1, + channelName: "general", + onSend: vi.fn(), + onTyping: vi.fn(), + onEditMessage, + }); + input.mount(container); + + input.startEdit(42, "original text"); + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + expect(textarea.value).toBe("original text"); + + textarea.value = "updated text"; + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(onEditMessage).toHaveBeenCalledWith(42, "updated text"); + input.destroy?.(); + }); + + it("throttles typing events to 3 seconds", () => { + vi.useFakeTimers(); + const onTyping = vi.fn(); + const input = createMessageInput({ + channelId: 1, + channelName: "general", + onSend: vi.fn(), + onTyping, + onEditMessage: vi.fn(), + }); + input.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + + // First input triggers typing + textarea.dispatchEvent(new Event("input")); + expect(onTyping).toHaveBeenCalledTimes(1); + + // Immediate second input should NOT trigger + textarea.dispatchEvent(new Event("input")); + expect(onTyping).toHaveBeenCalledTimes(1); + + // After 3 seconds, should trigger again + vi.advanceTimersByTime(3000); + textarea.dispatchEvent(new Event("input")); + expect(onTyping).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + input.destroy?.(); + }); +}); diff --git a/Client/tauri-client/tests/unit/connect-page.test.ts b/Client/tauri-client/tests/unit/connect-page.test.ts new file mode 100644 index 00000000..fbc7b546 --- /dev/null +++ b/Client/tauri-client/tests/unit/connect-page.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createConnectPage } from "../../src/pages/ConnectPage"; +import type { ConnectPageCallbacks, SimpleProfile } from "../../src/pages/ConnectPage"; +import { uiStore } from "../../src/stores/ui.store"; + +// Mock SettingsOverlay so we don't pull in all its dependencies +vi.mock("../../src/components/SettingsOverlay", () => ({ + createSettingsOverlay: () => ({ + mount: vi.fn(), + destroy: vi.fn(), + }), +})); + +// Mock ui.store actions +vi.mock("../../src/stores/ui.store", async () => { + const actual = await vi.importActual<typeof import("../../src/stores/ui.store")>( + "../../src/stores/ui.store", + ); + return { + ...actual, + openSettings: vi.fn(), + closeSettings: vi.fn(), + }; +}); + +function makeCallbacks(overrides: Partial<ConnectPageCallbacks> = {}): ConnectPageCallbacks { + return { + onLogin: vi.fn().mockResolvedValue(undefined), + onRegister: vi.fn().mockResolvedValue(undefined), + onTotpSubmit: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +const testProfiles: SimpleProfile[] = [ + { name: "Test Server", host: "localhost:8443" }, +]; + +describe("ConnectPage", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders the connect page with form elements", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + expect(container.querySelector(".connect-page")).not.toBeNull(); + expect(container.querySelector(".connect-form")).not.toBeNull(); + expect(container.querySelector("#host")).not.toBeNull(); + expect(container.querySelector("#username")).not.toBeNull(); + expect(container.querySelector("#password")).not.toBeNull(); + + page.destroy?.(); + }); + + it("renders server profiles in the server panel", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + const serverItems = container.querySelectorAll(".server-item"); + expect(serverItems.length).toBe(1); + + const serverName = container.querySelector(".srv-name"); + expect(serverName?.textContent).toBe("Test Server"); + + page.destroy?.(); + }); + + it("fills host input when a server profile is clicked", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + const serverItem = container.querySelector(".server-item") as HTMLElement; + serverItem.click(); + + const hostInput = container.querySelector("#host") as HTMLInputElement; + expect(hostInput.value).toBe("localhost:8443"); + + page.destroy?.(); + }); + + it("shows error when submitting empty form", async () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + // Clear any default host value + const hostInput = container.querySelector("#host") as HTMLInputElement; + hostInput.value = ""; + + const form = container.querySelector(".connect-form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + // Wait for async handler + await vi.waitFor(() => { + const errorBanner = container.querySelector(".error-banner"); + expect(errorBanner!.classList.contains("visible")).toBe(true); + }); + + page.destroy?.(); + }); + + it("shows validation error for short password", async () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + const hostInput = container.querySelector("#host") as HTMLInputElement; + const usernameInput = container.querySelector("#username") as HTMLInputElement; + const passwordInput = container.querySelector("#password") as HTMLInputElement; + + hostInput.value = "localhost:8443"; + usernameInput.value = "testuser"; + passwordInput.value = "short"; + + const form = container.querySelector(".connect-form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + const errorBanner = container.querySelector(".error-banner"); + expect(errorBanner!.classList.contains("visible")).toBe(true); + expect(errorBanner!.textContent).toContain("at least 8 characters"); + }); + + page.destroy?.(); + }); + + it("calls onLogin with form values on valid submit", async () => { + const onLogin = vi.fn().mockResolvedValue(undefined); + const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles); + page.mount(container); + + const hostInput = container.querySelector("#host") as HTMLInputElement; + const usernameInput = container.querySelector("#username") as HTMLInputElement; + const passwordInput = container.querySelector("#password") as HTMLInputElement; + + hostInput.value = "localhost:8443"; + usernameInput.value = "testuser"; + passwordInput.value = "password123"; + + const form = container.querySelector(".connect-form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + expect(onLogin).toHaveBeenCalledWith("localhost:8443", "testuser", "password123"); + }); + + page.destroy?.(); + }); + + it("toggles between login and register mode", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + // Initially in login mode — invite group hidden + const inviteGroup = container.querySelector("#invite")!.closest(".form-group") as HTMLElement; + expect(inviteGroup.classList.contains("form-group--hidden")).toBe(true); + + // Click toggle link + const toggleLink = container.querySelector(".form-switch a") as HTMLElement; + toggleLink.click(); + + // Now in register mode — invite group visible + expect(inviteGroup.classList.contains("form-group--hidden")).toBe(false); + + // Submit button text changes + const btnText = container.querySelector(".btn-text"); + expect(btnText?.textContent).toBe("Register"); + + page.destroy?.(); + }); + + it("shows TOTP overlay when showTotp is called", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + const totpOverlay = container.querySelector(".totp-overlay")!; + expect(totpOverlay.classList.contains("totp-overlay--hidden")).toBe(true); + + page.showTotp(); + expect(totpOverlay.classList.contains("totp-overlay--hidden")).toBe(false); + + page.destroy?.(); + }); + + it("shows error message via showError", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + page.showError("Connection refused"); + + const errorBanner = container.querySelector(".error-banner"); + expect(errorBanner!.classList.contains("visible")).toBe(true); + expect(errorBanner!.textContent).toBe("Connection refused"); + + page.destroy?.(); + }); + + it("resets to idle state via resetToIdle", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + page.showError("Some error"); + page.resetToIdle(); + + const errorBanner = container.querySelector(".error-banner"); + expect(errorBanner!.classList.contains("visible")).toBe(false); + + const submitBtn = container.querySelector(".btn-primary") as HTMLButtonElement; + expect(submitBtn.disabled).toBe(false); + + page.destroy?.(); + }); + + it("disables form inputs during loading state", async () => { + let resolveLogin: () => void; + const loginPromise = new Promise<void>((resolve) => { resolveLogin = resolve; }); + const onLogin = vi.fn().mockReturnValue(loginPromise); + + const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles); + page.mount(container); + + const hostInput = container.querySelector("#host") as HTMLInputElement; + const usernameInput = container.querySelector("#username") as HTMLInputElement; + const passwordInput = container.querySelector("#password") as HTMLInputElement; + + hostInput.value = "localhost:8443"; + usernameInput.value = "testuser"; + passwordInput.value = "password123"; + + const form = container.querySelector(".connect-form") as HTMLFormElement; + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + expect(hostInput.disabled).toBe(true); + expect(usernameInput.disabled).toBe(true); + expect(passwordInput.disabled).toBe(true); + }); + + resolveLogin!(); + + page.destroy?.(); + }); + + it("cleans up on destroy", () => { + const page = createConnectPage(makeCallbacks(), testProfiles); + page.mount(container); + + expect(container.querySelector(".connect-page")).not.toBeNull(); + + page.destroy?.(); + expect(container.querySelector(".connect-page")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/connected-overlay.test.ts b/Client/tauri-client/tests/unit/connected-overlay.test.ts new file mode 100644 index 00000000..d049513b --- /dev/null +++ b/Client/tauri-client/tests/unit/connected-overlay.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createConnectedOverlay } from "@components/ConnectedOverlay"; + +describe("ConnectedOverlay", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + function makeOverlay(onReady = vi.fn()) { + return createConnectedOverlay({ + serverName: "TestServer", + username: "testuser", + motd: "Welcome to the test server!", + onReady, + }); + } + + it("creates overlay element with connected-overlay class", () => { + const overlay = makeOverlay(); + expect(overlay.element.classList.contains("connected-overlay")).toBe(true); + overlay.destroy(); + }); + + it("is hidden by default (no visible class)", () => { + const overlay = makeOverlay(); + expect(overlay.element.classList.contains("visible")).toBe(false); + overlay.destroy(); + }); + + it("show() adds visible class", () => { + const overlay = makeOverlay(); + overlay.show(); + expect(overlay.element.classList.contains("visible")).toBe(true); + overlay.destroy(); + }); + + it("renders server icon with first letter", () => { + const overlay = makeOverlay(); + const icon = overlay.element.querySelector(".connected-srv-icon"); + expect(icon).not.toBeNull(); + expect(icon!.textContent).toBe("T"); + overlay.destroy(); + }); + + it("renders connected text", () => { + const overlay = makeOverlay(); + const text = overlay.element.querySelector(".connected-text"); + expect(text).not.toBeNull(); + expect(text!.textContent).toBe("Connected!"); + overlay.destroy(); + }); + + it("renders username", () => { + const overlay = makeOverlay(); + const user = overlay.element.querySelector(".connected-user"); + expect(user).not.toBeNull(); + expect(user!.textContent).toBe("Logged in as testuser"); + overlay.destroy(); + }); + + it("renders MOTD", () => { + const overlay = makeOverlay(); + const motd = overlay.element.querySelector(".connected-motd"); + expect(motd).not.toBeNull(); + expect(motd!.textContent).toBe("Welcome to the test server!"); + overlay.destroy(); + }); + + it("renders loading spinner text", () => { + const overlay = makeOverlay(); + const loader = overlay.element.querySelector(".connected-loader span"); + expect(loader).not.toBeNull(); + expect(loader!.textContent).toBe("Loading server data..."); + overlay.destroy(); + }); + + it("renders check badge SVG", () => { + const overlay = makeOverlay(); + const badge = overlay.element.querySelector(".connected-check-badge"); + expect(badge).not.toBeNull(); + const svg = badge!.querySelector("svg"); + expect(svg).not.toBeNull(); + overlay.destroy(); + }); + + it("markReady() changes loader text", () => { + const overlay = makeOverlay(); + overlay.markReady(); + const loader = overlay.element.querySelector(".connected-loader span"); + expect(loader!.textContent).toContain("Ready!"); + overlay.destroy(); + }); + + it("markReady() hides spinner", () => { + const overlay = makeOverlay(); + overlay.markReady(); + const spinner = overlay.element.querySelector(".spinner") as HTMLElement; + expect(spinner.style.display).toBe("none"); + overlay.destroy(); + }); + + it("markReady() calls onReady after delay", () => { + const onReady = vi.fn(); + const overlay = makeOverlay(onReady); + overlay.markReady(); + expect(onReady).not.toHaveBeenCalled(); + vi.advanceTimersByTime(800); + expect(onReady).toHaveBeenCalledOnce(); + overlay.destroy(); + }); + + it("destroy() prevents onReady callback", () => { + const onReady = vi.fn(); + const overlay = makeOverlay(onReady); + overlay.markReady(); + overlay.destroy(); + vi.advanceTimersByTime(800); + expect(onReady).not.toHaveBeenCalled(); + }); + + it("empty MOTD renders empty motd div", () => { + const overlay = createConnectedOverlay({ + serverName: "Server", + username: "user", + motd: "", + onReady: vi.fn(), + }); + const motd = overlay.element.querySelector(".connected-motd"); + expect(motd).not.toBeNull(); + expect(motd!.textContent).toBe(""); + overlay.destroy(); + }); +}); diff --git a/Client/tauri-client/tests/unit/create-channel-modal.test.ts b/Client/tauri-client/tests/unit/create-channel-modal.test.ts new file mode 100644 index 00000000..a75eb37c --- /dev/null +++ b/Client/tauri-client/tests/unit/create-channel-modal.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + isVoiceCategory, + allowedTypesForCategory, + createCreateChannelModal, +} from "@components/CreateChannelModal"; +import type { CreateChannelModalOptions } from "@components/CreateChannelModal"; + +// --------------------------------------------------------------------------- +// Pure function tests +// --------------------------------------------------------------------------- + +describe("isVoiceCategory", () => { + it("returns true for 'Voice Channels'", () => { + expect(isVoiceCategory("Voice Channels")).toBe(true); + }); + + it("returns true for uppercase 'VOICE CHANNELS'", () => { + expect(isVoiceCategory("VOICE CHANNELS")).toBe(true); + }); + + it("returns true for 'voice'", () => { + expect(isVoiceCategory("voice")).toBe(true); + }); + + it("returns false for 'Text Channels'", () => { + expect(isVoiceCategory("Text Channels")).toBe(false); + }); + + it("returns false for 'Chat'", () => { + expect(isVoiceCategory("Chat")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isVoiceCategory("")).toBe(false); + }); +}); + +describe("allowedTypesForCategory", () => { + it("returns only voice for voice categories", () => { + expect(allowedTypesForCategory("Voice Channels")).toEqual(["voice"]); + }); + + it("returns text and announcement for text categories", () => { + expect(allowedTypesForCategory("Text Channels")).toEqual([ + "text", + "announcement", + ]); + }); + + it("returns text and announcement for 'Chat'", () => { + expect(allowedTypesForCategory("Chat")).toEqual([ + "text", + "announcement", + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Component tests +// --------------------------------------------------------------------------- + +describe("CreateChannelModal", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + // Clean up any modals attached to document.body + document.querySelectorAll("[data-testid='create-channel-modal']").forEach((el) => el.remove()); + }); + + function makeModal(category: string, overrides?: Partial<CreateChannelModalOptions>) { + const options: CreateChannelModalOptions = { + category, + onCreate: overrides?.onCreate ?? vi.fn(async () => {}), + onClose: overrides?.onClose ?? vi.fn(), + }; + const modal = createCreateChannelModal(options); + modal.mount(container); + return { modal, options }; + } + + it("renders the modal overlay", () => { + const { modal } = makeModal("Text Channels"); + const overlay = container.querySelector("[data-testid='create-channel-modal']"); + expect(overlay).not.toBeNull(); + modal.destroy?.(); + }); + + it("shows only text and announcement types for text categories", () => { + const { modal } = makeModal("Text Channels"); + const select = container.querySelector("[data-testid='channel-type-select']") as HTMLSelectElement; + const options = Array.from(select.options).map((o) => o.value); + expect(options).toEqual(["text", "announcement"]); + expect(options).not.toContain("voice"); + modal.destroy?.(); + }); + + it("shows only voice type for voice categories", () => { + const { modal } = makeModal("Voice Channels"); + const select = container.querySelector("[data-testid='channel-type-select']") as HTMLSelectElement; + const options = Array.from(select.options).map((o) => o.value); + expect(options).toEqual(["voice"]); + expect(options).not.toContain("text"); + modal.destroy?.(); + }); + + it("displays the category name as read-only", () => { + const { modal } = makeModal("Voice Channels"); + const overlay = container.querySelector("[data-testid='create-channel-modal']"); + expect(overlay?.textContent).toContain("Voice Channels"); + modal.destroy?.(); + }); + + it("shows error when submitting with empty name", () => { + const onCreate = vi.fn(async () => {}); + const { modal } = makeModal("Text Channels", { onCreate }); + + const submitBtn = container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement; + submitBtn.click(); + + const error = container.querySelector("[data-testid='channel-create-error']"); + expect(error?.textContent).toContain("required"); + expect(onCreate).not.toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("calls onCreate with correct data when name is provided", async () => { + const onCreate = vi.fn(async () => {}); + const { modal } = makeModal("Text Channels", { onCreate }); + + const nameInput = container.querySelector("[data-testid='channel-name-input']") as HTMLInputElement; + nameInput.value = "test-channel"; + + const submitBtn = container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement; + submitBtn.click(); + + // Wait for async handler + await vi.waitFor(() => { + expect(onCreate).toHaveBeenCalledWith({ + name: "test-channel", + type: "text", + category: "Text Channels", + }); + }); + + modal.destroy?.(); + }); + + it("calls onClose when close button is clicked", () => { + const onClose = vi.fn(); + const { modal } = makeModal("Text Channels", { onClose }); + + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + closeBtn.click(); + + expect(onClose).toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("removes overlay on destroy", () => { + const { modal } = makeModal("Text Channels"); + expect(container.querySelector("[data-testid='create-channel-modal']")).not.toBeNull(); + modal.destroy?.(); + expect(container.querySelector("[data-testid='create-channel-modal']")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/delete-channel-modal.test.ts b/Client/tauri-client/tests/unit/delete-channel-modal.test.ts new file mode 100644 index 00000000..bc0b6ab3 --- /dev/null +++ b/Client/tauri-client/tests/unit/delete-channel-modal.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createDeleteChannelModal } from "@components/DeleteChannelModal"; +import type { DeleteChannelModalOptions } from "@components/DeleteChannelModal"; + +describe("DeleteChannelModal", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.querySelectorAll("[data-testid='delete-channel-modal']").forEach((el) => el.remove()); + }); + + function makeModal(overrides?: Partial<DeleteChannelModalOptions>) { + const options: DeleteChannelModalOptions = { + channelId: 1, + channelName: "general", + onConfirm: overrides?.onConfirm ?? vi.fn(async () => {}), + onClose: overrides?.onClose ?? vi.fn(), + }; + const modal = createDeleteChannelModal(options); + modal.mount(container); + return { modal, options }; + } + + it("renders the modal overlay", () => { + const { modal } = makeModal(); + expect(container.querySelector("[data-testid='delete-channel-modal']")).not.toBeNull(); + modal.destroy?.(); + }); + + it("displays channel name in warning message", () => { + const { modal } = makeModal(); + const overlay = container.querySelector("[data-testid='delete-channel-modal']"); + expect(overlay?.textContent).toContain("#general"); + modal.destroy?.(); + }); + + it("displays cannot be undone warning", () => { + const { modal } = makeModal(); + const overlay = container.querySelector("[data-testid='delete-channel-modal']"); + expect(overlay?.textContent).toContain("cannot be undone"); + modal.destroy?.(); + }); + + it("calls onConfirm when delete button is clicked", async () => { + const onConfirm = vi.fn(async () => {}); + const { modal } = makeModal({ onConfirm }); + const deleteBtn = container.querySelector("[data-testid='delete-channel-confirm']") as HTMLButtonElement; + deleteBtn.click(); + + await vi.waitFor(() => { + expect(onConfirm).toHaveBeenCalled(); + }); + modal.destroy?.(); + }); + + it("calls onClose when close button is clicked", () => { + const onClose = vi.fn(); + const { modal } = makeModal({ onClose }); + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + closeBtn.click(); + expect(onClose).toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("calls onClose when cancel button is clicked", () => { + const onClose = vi.fn(); + const { modal } = makeModal({ onClose }); + const cancelBtn = container.querySelector(".btn-modal-cancel") as HTMLButtonElement; + cancelBtn.click(); + expect(onClose).toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("removes overlay on destroy", () => { + const { modal } = makeModal(); + expect(container.querySelector("[data-testid='delete-channel-modal']")).not.toBeNull(); + modal.destroy?.(); + expect(container.querySelector("[data-testid='delete-channel-modal']")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts new file mode 100644 index 00000000..ac831c22 --- /dev/null +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { wireDispatcher } from "../../src/lib/dispatcher"; +import { authStore, clearAuth } from "../../src/stores/auth.store"; +import { channelsStore } from "../../src/stores/channels.store"; +import { messagesStore } from "../../src/stores/messages.store"; +import { membersStore } from "../../src/stores/members.store"; +import { voiceStore } from "../../src/stores/voice.store"; +import type { WsClient, WsListener } from "../../src/lib/ws"; +import type { ServerMessage } from "../../src/lib/types"; + +// Suppress console output +vi.spyOn(console, "info").mockImplementation(() => {}); +vi.spyOn(console, "warn").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); + +/** + * Create a mock WsClient that stores listener registrations + * and provides a `dispatch` helper to fire events. + */ +function createMockWs() { + const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>(); + + const ws: WsClient = { + connect: vi.fn(), + disconnect: vi.fn(), + send: vi.fn(() => "test-id"), + on<T extends ServerMessage["type"]>( + type: T, + listener: WsListener<T>, + ): () => void { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + listeners.get(type)!.add(listener as unknown as WsListener<ServerMessage["type"]>); + return () => { + listeners.get(type)?.delete(listener as unknown as WsListener<ServerMessage["type"]>); + }; + }, + onStateChange: vi.fn(() => () => {}), + onCertMismatch: vi.fn(() => () => {}), + acceptCertFingerprint: vi.fn(async () => {}), + getState: vi.fn(() => "disconnected" as const), + _getWs: vi.fn(() => null), + }; + + function dispatch(type: string, payload: unknown, id?: string): void { + const set = listeners.get(type); + if (set) { + for (const listener of set) { + (listener as (p: unknown, id?: string) => void)(payload, id); + } + } + } + + return { ws, dispatch, listeners }; +} + +describe("WS Dispatcher", () => { + let cleanup: () => void; + let mock: ReturnType<typeof createMockWs>; + + beforeEach(() => { + vi.useFakeTimers(); + // Reset all stores to initial state + authStore.setState(() => ({ + token: "test-token", + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + channelsStore.setState(() => ({ + channels: new Map(), + activeChannelId: null, + })); + messagesStore.setState(() => ({ + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, + })); + + mock = createMockWs(); + cleanup = wireDispatcher(mock.ws); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("wires auth_ok to auth store", () => { + mock.dispatch("auth_ok", { + user: { id: 1, username: "alex", avatar: null, role: "admin" }, + server_name: "TestServer", + motd: "Welcome!", + }); + + const state = authStore.getState(); + expect(state.isAuthenticated).toBe(true); + expect(state.user?.username).toBe("alex"); + expect(state.serverName).toBe("TestServer"); + }); + + it("wires auth_error to clear auth", () => { + mock.dispatch("auth_error", { message: "Invalid token" }); + expect(authStore.getState().isAuthenticated).toBe(false); + }); + + it("wires ready to channels, members, and voice stores", () => { + mock.dispatch("ready", { + channels: [ + { id: 1, name: "general", type: "text", category: null, position: 0 }, + { id: 2, name: "voice", type: "voice", category: null, position: 1 }, + ], + members: [ + { id: 1, username: "alex", avatar: null, role: "admin", status: "online" }, + ], + voice_states: [ + { channel_id: 2, user_id: 1, muted: false, deafened: false }, + ], + roles: [], + }); + + expect(channelsStore.getState().channels.size).toBe(2); + expect(membersStore.getState().members.size).toBe(1); + expect(voiceStore.getState().voiceUsers.size).toBe(1); + }); + + it("wires chat_message to messages store", () => { + mock.dispatch("chat_message", { + id: 100, + channel_id: 1, + user: { id: 1, username: "alex", avatar: null }, + content: "Hello world", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + + const msgs = messagesStore.getState().messagesByChannel.get(1); + expect(msgs).toHaveLength(1); + expect(msgs![0]!.content).toBe("Hello world"); + }); + + it("wires chat_message to increment unread for non-active channel", () => { + // Set up a channel first + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(5, { + id: 5, + name: "off-topic", + type: "text" as const, + category: null, + position: 0, + unreadCount: 0, + lastMessageId: null, + }); + return { ...prev, channels: ch, activeChannelId: 1 }; // active is channel 1 + }); + + mock.dispatch("chat_message", { + id: 200, + channel_id: 5, // different from active + user: { id: 2, username: "bob", avatar: null }, + content: "ping", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + + const ch = channelsStore.getState().channels.get(5); + expect(ch?.unreadCount).toBe(1); + }); + + it("wires presence to members store", () => { + // Add a member first + membersStore.setState((prev) => { + const m = new Map(prev.members); + m.set(1, { id: 1, username: "alex", avatar: null, role: "admin", status: "online" as const }); + return { ...prev, members: m }; + }); + + mock.dispatch("presence", { user_id: 1, status: "idle" }); + expect(membersStore.getState().members.get(1)?.status).toBe("idle"); + }); + + it("wires typing to members store", () => { + mock.dispatch("typing", { channel_id: 1, user_id: 42, username: "bob" }); + const typing = membersStore.getState().typingUsers.get(1); + expect(typing?.has(42)).toBe(true); + }); + + it("wires channel_create to channels store", () => { + mock.dispatch("channel_create", { + id: 10, + name: "new-channel", + type: "text", + category: "General", + position: 5, + }); + + expect(channelsStore.getState().channels.has(10)).toBe(true); + }); + + it("wires channel_delete to channels store", () => { + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(10, { + id: 10, + name: "doomed", + type: "text" as const, + category: null, + position: 0, + unreadCount: 0, + lastMessageId: null, + }); + return { ...prev, channels: ch }; + }); + + mock.dispatch("channel_delete", { id: 10 }); + expect(channelsStore.getState().channels.has(10)).toBe(false); + }); + + it("wires member_join to members store", () => { + mock.dispatch("member_join", { + user: { id: 99, username: "newuser", avatar: null, role: "member" }, + }); + expect(membersStore.getState().members.has(99)).toBe(true); + }); + + it("wires chat_send_ok to confirmSend in messages store", () => { + // Add a pending send (correlationId -> channelId) + messagesStore.setState((prev) => { + const pending = new Map(prev.pendingSends); + pending.set("corr-123", 1); + return { ...prev, pendingSends: pending }; + }); + + expect(messagesStore.getState().pendingSends.has("corr-123")).toBe(true); + + mock.dispatch( + "chat_send_ok", + { message_id: 500, timestamp: "2026-03-15T10:00:00Z" }, + "corr-123", + ); + + expect(messagesStore.getState().pendingSends.has("corr-123")).toBe(false); + }); + + it("wires member_ban to remove member from members store", () => { + membersStore.setState((prev) => { + const m = new Map(prev.members); + m.set(77, { id: 77, username: "banned-user", avatar: null, role: "member", status: "online" as const }); + return { ...prev, members: m }; + }); + + mock.dispatch("member_ban", { user_id: 77 }); + expect(membersStore.getState().members.has(77)).toBe(false); + }); + + it("wires member_leave to members store", () => { + membersStore.setState((prev) => { + const m = new Map(prev.members); + m.set(99, { id: 99, username: "bye", avatar: null, role: "member", status: "online" as const }); + return { ...prev, members: m }; + }); + + mock.dispatch("member_leave", { user_id: 99 }); + expect(membersStore.getState().members.has(99)).toBe(false); + }); + + it("wires voice_state to voice store", () => { + mock.dispatch("voice_state", { + channel_id: 2, + user_id: 1, + username: "alex", + muted: true, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }); + + const users = voiceStore.getState().voiceUsers.get(2); + expect(users?.get(1)?.muted).toBe(true); + }); + + it("cleanup removes all listeners", () => { + cleanup(); + + // After cleanup, dispatching should not affect stores + mock.dispatch("chat_message", { + id: 999, + channel_id: 1, + user: { id: 1, username: "ghost", avatar: null }, + content: "should not appear", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T12:00:00Z", + }); + + expect(messagesStore.getState().messagesByChannel.get(1)).toBeUndefined(); + }); +}); diff --git a/Client/tauri-client/tests/unit/dm-sidebar.test.ts b/Client/tauri-client/tests/unit/dm-sidebar.test.ts new file mode 100644 index 00000000..0bb68f20 --- /dev/null +++ b/Client/tauri-client/tests/unit/dm-sidebar.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createDmSidebar } from "../../src/components/DmSidebar"; +import type { DmConversation } from "../../src/components/DmSidebar"; + +const makeConvo = (overrides: Partial<DmConversation> = {}): DmConversation => ({ + userId: 1, + username: "Alice", + avatar: null, + status: "online", + lastMessage: "Hello!", + timestamp: "2025-01-01T00:00:00Z", + unread: false, + ...overrides, +}); + +describe("DmSidebar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders the sidebar with search input", () => { + const sidebar = createDmSidebar({ + conversations: [], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const searchInput = container.querySelector(".dm-search"); + expect(searchInput).not.toBeNull(); + expect((searchInput as HTMLInputElement).placeholder).toBe("Find a conversation"); + + sidebar.destroy?.(); + }); + + it("renders Friends nav item", () => { + const sidebar = createDmSidebar({ + conversations: [], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const friendsNav = container.querySelector(".dm-nav-item"); + expect(friendsNav).not.toBeNull(); + expect(friendsNav!.textContent).toBe("Friends"); + + sidebar.destroy?.(); + }); + + it("marks Friends nav as active when friendsActive is true", () => { + const sidebar = createDmSidebar({ + conversations: [], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + friendsActive: true, + }); + sidebar.mount(container); + + const friendsNav = container.querySelector(".dm-nav-item"); + expect(friendsNav!.classList.contains("active")).toBe(true); + + sidebar.destroy?.(); + }); + + it("renders conversation items", () => { + const conversations: DmConversation[] = [ + makeConvo({ userId: 1, username: "Alice" }), + makeConvo({ userId: 2, username: "Bob" }), + ]; + + const sidebar = createDmSidebar({ + conversations, + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const items = container.querySelectorAll(".dm-item"); + expect(items.length).toBe(2); + + sidebar.destroy?.(); + }); + + it("sorts unread conversations first", () => { + const conversations: DmConversation[] = [ + makeConvo({ userId: 1, username: "Alice", unread: false }), + makeConvo({ userId: 2, username: "Bob", unread: true }), + ]; + + const sidebar = createDmSidebar({ + conversations, + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const items = container.querySelectorAll(".dm-item"); + // Bob (unread) should come first + expect(items[0]!.querySelector(".dm-name")!.textContent).toBe("Bob"); + expect(items[1]!.querySelector(".dm-name")!.textContent).toBe("Alice"); + + sidebar.destroy?.(); + }); + + it("shows unread dot for unread conversations", () => { + const sidebar = createDmSidebar({ + conversations: [makeConvo({ unread: true })], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const unreadDot = container.querySelector(".dm-unread"); + expect(unreadDot).not.toBeNull(); + + sidebar.destroy?.(); + }); + + it("calls onSelectConversation when a DM item is clicked", () => { + const onSelectConversation = vi.fn(); + const sidebar = createDmSidebar({ + conversations: [makeConvo({ userId: 42 })], + onSelectConversation, + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const item = container.querySelector(".dm-item") as HTMLElement; + item.click(); + expect(onSelectConversation).toHaveBeenCalledWith(42); + + sidebar.destroy?.(); + }); + + it("calls onCloseDm when close button is clicked", () => { + const onCloseDm = vi.fn(); + const sidebar = createDmSidebar({ + conversations: [makeConvo({ userId: 42 })], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + onCloseDm, + }); + sidebar.mount(container); + + const closeBtn = container.querySelector(".dm-close") as HTMLButtonElement; + closeBtn.click(); + expect(onCloseDm).toHaveBeenCalledWith(42); + + sidebar.destroy?.(); + }); + + it("calls onNewDm when add button is clicked", () => { + const onNewDm = vi.fn(); + const sidebar = createDmSidebar({ + conversations: [], + onSelectConversation: vi.fn(), + onNewDm, + }); + sidebar.mount(container); + + const addBtn = container.querySelector(".dm-add") as HTMLButtonElement; + addBtn.click(); + expect(onNewDm).toHaveBeenCalledOnce(); + + sidebar.destroy?.(); + }); + + it("shows avatar initial when no avatar image", () => { + const sidebar = createDmSidebar({ + conversations: [makeConvo({ username: "alice", avatar: null })], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const avatar = container.querySelector(".dm-avatar"); + expect(avatar!.textContent).toBe("A"); + + sidebar.destroy?.(); + }); + + it("shows avatar image when avatar URL is provided", () => { + const sidebar = createDmSidebar({ + conversations: [makeConvo({ avatar: "http://example.com/img.png" })], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const img = container.querySelector(".dm-avatar img") as HTMLImageElement; + expect(img).not.toBeNull(); + expect(img.src).toBe("http://example.com/img.png"); + + sidebar.destroy?.(); + }); + + it("marks active conversation with active class", () => { + const sidebar = createDmSidebar({ + conversations: [makeConvo({ active: true })], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + const item = container.querySelector(".dm-item"); + expect(item!.classList.contains("active")).toBe(true); + + sidebar.destroy?.(); + }); + + it("cleans up on destroy", () => { + const sidebar = createDmSidebar({ + conversations: [], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + + expect(container.querySelector(".channel-sidebar")).not.toBeNull(); + + sidebar.destroy?.(); + expect(container.querySelector(".channel-sidebar")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/dom.test.ts b/Client/tauri-client/tests/unit/dom.test.ts new file mode 100644 index 00000000..249107a2 --- /dev/null +++ b/Client/tauri-client/tests/unit/dom.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { + escapeHtml, + createElement, + setText, + appendChildren, + clearChildren, + qs, + qsa, +} from "../../src/lib/dom"; + +describe("escapeHtml", () => { + it("escapes all HTML special characters", () => { + expect(escapeHtml('<script>alert("xss")</script>')).toBe( + "<script>alert("xss")</script>", + ); + }); + + it("escapes ampersands", () => { + expect(escapeHtml("foo & bar")).toBe("foo & bar"); + }); + + it("escapes single quotes", () => { + expect(escapeHtml("it's")).toBe("it's"); + }); + + it("returns empty string unchanged", () => { + expect(escapeHtml("")).toBe(""); + }); + + it("leaves safe text unchanged", () => { + expect(escapeHtml("Hello world 123")).toBe("Hello world 123"); + }); +}); + +describe("createElement", () => { + it("creates an element with the given tag", () => { + const el = createElement("div"); + expect(el.tagName).toBe("DIV"); + }); + + it("sets text content safely", () => { + const el = createElement("span", {}, "<script>xss</script>"); + expect(el.textContent).toBe("<script>xss</script>"); + expect(el.innerHTML).toBe("<script>xss</script>"); + }); + + it("sets class attribute", () => { + const el = createElement("div", { class: "foo bar" }); + expect(el.className).toBe("foo bar"); + }); + + it("sets data attributes", () => { + const el = createElement("div", { "data-id": "42" }); + expect(el.dataset["id"]).toBe("42"); + }); + + it("sets aria attributes", () => { + const el = createElement("button", { "aria-label": "Close" }); + expect(el.getAttribute("aria-label")).toBe("Close"); + }); + + it("sets regular attributes", () => { + const el = createElement("input", { type: "text", id: "name" }); + expect(el.getAttribute("type")).toBe("text"); + expect(el.id).toBe("name"); + }); +}); + +describe("setText", () => { + it("sets text content safely", () => { + const el = document.createElement("div"); + setText(el, "<b>bold</b>"); + expect(el.textContent).toBe("<b>bold</b>"); + expect(el.children.length).toBe(0); + }); +}); + +describe("appendChildren", () => { + it("appends element children", () => { + const parent = document.createElement("div"); + const child1 = document.createElement("span"); + const child2 = document.createElement("p"); + appendChildren(parent, child1, child2); + expect(parent.children.length).toBe(2); + }); + + it("appends string children as text nodes", () => { + const parent = document.createElement("div"); + appendChildren(parent, "hello ", "world"); + expect(parent.textContent).toBe("hello world"); + expect(parent.childNodes.length).toBe(2); + }); + + it("appends mixed children", () => { + const parent = document.createElement("div"); + const span = createElement("span", {}, "bold"); + appendChildren(parent, "text ", span); + expect(parent.childNodes.length).toBe(2); + expect(parent.textContent).toBe("text bold"); + }); +}); + +describe("clearChildren", () => { + it("removes all children", () => { + const parent = document.createElement("div"); + parent.appendChild(document.createElement("span")); + parent.appendChild(document.createElement("p")); + parent.appendChild(document.createTextNode("text")); + expect(parent.childNodes.length).toBe(3); + clearChildren(parent); + expect(parent.childNodes.length).toBe(0); + }); + + it("handles already empty element", () => { + const parent = document.createElement("div"); + clearChildren(parent); + expect(parent.childNodes.length).toBe(0); + }); +}); + +describe("qs and qsa", () => { + it("qs finds element by selector", () => { + const container = document.createElement("div"); + const child = document.createElement("span"); + child.className = "target"; + container.appendChild(child); + document.body.appendChild(container); + + expect(qs(".target")).toBe(child); + + document.body.removeChild(container); + }); + + it("qs returns null when not found", () => { + expect(qs(".nonexistent-class-12345")).toBeNull(); + }); + + it("qs scopes to parent", () => { + const parent = document.createElement("div"); + const child = document.createElement("span"); + child.className = "scoped"; + parent.appendChild(child); + + const other = document.createElement("div"); + expect(qs(".scoped", other)).toBeNull(); + expect(qs(".scoped", parent)).toBe(child); + }); + + it("qsa returns array of matches", () => { + const container = document.createElement("div"); + container.innerHTML = ""; // intentionally empty + const a = document.createElement("span"); + a.className = "item"; + const b = document.createElement("span"); + b.className = "item"; + container.appendChild(a); + container.appendChild(b); + document.body.appendChild(container); + + const results = qsa(".item", container); + expect(results).toHaveLength(2); + expect(Array.isArray(results)).toBe(true); + + document.body.removeChild(container); + }); +}); diff --git a/Client/tauri-client/tests/unit/edit-channel-modal.test.ts b/Client/tauri-client/tests/unit/edit-channel-modal.test.ts new file mode 100644 index 00000000..cd0b14e8 --- /dev/null +++ b/Client/tauri-client/tests/unit/edit-channel-modal.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createEditChannelModal } from "@components/EditChannelModal"; +import type { EditChannelModalOptions } from "@components/EditChannelModal"; + +describe("EditChannelModal", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.querySelectorAll("[data-testid='edit-channel-modal']").forEach((el) => el.remove()); + }); + + function makeModal(overrides?: Partial<EditChannelModalOptions>) { + const options: EditChannelModalOptions = { + channelId: 1, + channelName: "general", + channelType: "text", + onSave: overrides?.onSave ?? vi.fn(async () => {}), + onClose: overrides?.onClose ?? vi.fn(), + }; + const modal = createEditChannelModal(options); + modal.mount(container); + return { modal, options }; + } + + it("renders the modal overlay", () => { + const { modal } = makeModal(); + expect(container.querySelector("[data-testid='edit-channel-modal']")).not.toBeNull(); + modal.destroy?.(); + }); + + it("pre-fills the name input with current channel name", () => { + const { modal } = makeModal(); + const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + expect(input.value).toBe("general"); + modal.destroy?.(); + }); + + it("displays the channel type as read-only", () => { + const { modal } = makeModal(); + const overlay = container.querySelector("[data-testid='edit-channel-modal']"); + expect(overlay?.textContent).toContain("Text"); + modal.destroy?.(); + }); + + it("shows error when saving with empty name", () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave }); + const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + input.value = ""; + + const saveBtn = container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement; + saveBtn.click(); + + const error = container.querySelector("[data-testid='edit-channel-error']"); + expect(error?.textContent).toContain("required"); + expect(onSave).not.toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("calls onSave with updated name", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave }); + const input = container.querySelector("[data-testid='edit-channel-name-input']") as HTMLInputElement; + input.value = "renamed-channel"; + + const saveBtn = container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith({ name: "renamed-channel" }); + }); + modal.destroy?.(); + }); + + it("calls onClose when close button is clicked", () => { + const onClose = vi.fn(); + const { modal } = makeModal({ onClose }); + const closeBtn = container.querySelector(".modal-close") as HTMLButtonElement; + closeBtn.click(); + expect(onClose).toHaveBeenCalled(); + modal.destroy?.(); + }); + + it("removes overlay on destroy", () => { + const { modal } = makeModal(); + expect(container.querySelector("[data-testid='edit-channel-modal']")).not.toBeNull(); + modal.destroy?.(); + expect(container.querySelector("[data-testid='edit-channel-modal']")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/emoji-picker.test.ts b/Client/tauri-client/tests/unit/emoji-picker.test.ts new file mode 100644 index 00000000..a00f992c --- /dev/null +++ b/Client/tauri-client/tests/unit/emoji-picker.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createEmojiPicker } from "@components/EmojiPicker"; +import type { EmojiPickerOptions } from "@components/EmojiPicker"; + +describe("EmojiPicker", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + localStorage.clear(); + }); + + afterEach(() => { + container.remove(); + localStorage.clear(); + }); + + function makePicker(overrides?: Partial<EmojiPickerOptions>) { + const options: EmojiPickerOptions = { + onSelect: overrides?.onSelect ?? vi.fn(), + onClose: overrides?.onClose ?? vi.fn(), + customEmoji: overrides?.customEmoji, + }; + const picker = createEmojiPicker(options); + container.appendChild(picker.element); + return { picker, options }; + } + + it("creates element with emoji-picker and open classes", () => { + const { picker } = makePicker(); + expect(picker.element.classList.contains("emoji-picker")).toBe(true); + expect(picker.element.classList.contains("open")).toBe(true); + picker.destroy(); + }); + + it("renders search input", () => { + const { picker } = makePicker(); + const input = picker.element.querySelector(".ep-search") as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.placeholder).toBe("Search emoji..."); + picker.destroy(); + }); + + it("renders category labels", () => { + const { picker } = makePicker(); + const labels = picker.element.querySelectorAll(".ep-category-label"); + const labelTexts = Array.from(labels).map((l) => l.textContent); + + // Should have built-in categories (Smileys, People, Nature, Food, Objects, Symbols) + // Recent is empty so should not appear + expect(labelTexts).toContain("Smileys"); + expect(labelTexts).toContain("People"); + expect(labelTexts).toContain("Nature"); + expect(labelTexts).toContain("Food"); + expect(labelTexts).toContain("Objects"); + expect(labelTexts).toContain("Symbols"); + picker.destroy(); + }); + + it("renders emoji grid with ep-emoji spans", () => { + const { picker } = makePicker(); + const emojiSpans = picker.element.querySelectorAll(".ep-emoji"); + expect(emojiSpans.length).toBeGreaterThan(0); + picker.destroy(); + }); + + it("clicking an emoji calls onSelect", () => { + const onSelect = vi.fn(); + const { picker } = makePicker({ onSelect }); + + const firstEmoji = picker.element.querySelector(".ep-emoji") as HTMLSpanElement; + expect(firstEmoji).not.toBeNull(); + firstEmoji.click(); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(typeof onSelect.mock.calls[0]![0]).toBe("string"); + picker.destroy(); + }); + + it("clicking an emoji saves to recent in localStorage", () => { + const { picker } = makePicker(); + + const firstEmoji = picker.element.querySelector(".ep-emoji") as HTMLSpanElement; + firstEmoji.click(); + + const stored = localStorage.getItem("owncord:recent-emoji"); + expect(stored).not.toBeNull(); + const recent = JSON.parse(stored!); + expect(Array.isArray(recent)).toBe(true); + expect(recent.length).toBeGreaterThan(0); + picker.destroy(); + }); + + it("search filters emoji", () => { + const { picker } = makePicker(); + + const input = picker.element.querySelector(".ep-search") as HTMLInputElement; + // Set a search query that won't match any emoji character + input.value = "zzzznotanemoji"; + input.dispatchEvent(new Event("input")); + + // Should show "No emoji found" empty state + const emptyState = picker.element.querySelector("div[style*='text-align: center']"); + expect(emptyState).not.toBeNull(); + expect(emptyState!.textContent).toBe("No emoji found"); + picker.destroy(); + }); + + it("Escape key calls onClose", () => { + const onClose = vi.fn(); + const { picker } = makePicker({ onClose }); + + picker.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(onClose).toHaveBeenCalledOnce(); + picker.destroy(); + }); + + it("renders custom emoji when provided", () => { + const { picker } = makePicker({ + customEmoji: [ + { shortcode: "test_emoji", url: "https://example.com/emoji.png" }, + ], + }); + + const labels = picker.element.querySelectorAll(".ep-category-label"); + const labelTexts = Array.from(labels).map((l) => l.textContent); + expect(labelTexts).toContain("Custom"); + picker.destroy(); + }); + + it("renders Recent category when localStorage has recent emoji", () => { + localStorage.setItem("owncord:recent-emoji", JSON.stringify(["😀", "😎"])); + const { picker } = makePicker(); + + const labels = picker.element.querySelectorAll(".ep-category-label"); + const labelTexts = Array.from(labels).map((l) => l.textContent); + expect(labelTexts).toContain("Recent"); + picker.destroy(); + }); + + it("destroy aborts event listeners", () => { + const onSelect = vi.fn(); + const { picker } = makePicker({ onSelect }); + const firstEmoji = picker.element.querySelector(".ep-emoji") as HTMLSpanElement; + + picker.destroy(); + firstEmoji.click(); + + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/file-upload.test.ts b/Client/tauri-client/tests/unit/file-upload.test.ts new file mode 100644 index 00000000..2119a3f3 --- /dev/null +++ b/Client/tauri-client/tests/unit/file-upload.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createFileUpload } from "@components/FileUpload"; +import type { FileUploadOptions, FileUploadComponent } from "@components/FileUpload"; + +describe("FileUpload", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function makeUpload(overrides?: Partial<FileUploadOptions>): FileUploadComponent { + const options: FileUploadOptions = { + onUpload: overrides?.onUpload ?? vi.fn(async () => {}), + maxSizeMb: overrides?.maxSizeMb, + }; + const upload = createFileUpload(options); + upload.mount(container); + return upload; + } + + it("mounts with file-upload class", () => { + const upload = makeUpload(); + expect(container.querySelector(".file-upload")).not.toBeNull(); + upload.destroy?.(); + }); + + it("renders dropzone (hidden by default)", () => { + const upload = makeUpload(); + const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; + expect(dropzone).not.toBeNull(); + expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(true); + upload.destroy?.(); + }); + + it("renders hidden file input", () => { + const upload = makeUpload(); + const input = container.querySelector(".file-upload__input") as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.type).toBe("file"); + expect(input.style.display).toBe("none"); + upload.destroy?.(); + }); + + it("preview is hidden by default", () => { + const upload = makeUpload(); + const preview = container.querySelector(".file-upload__preview") as HTMLDivElement; + expect(preview).not.toBeNull(); + expect(preview.classList.contains("file-upload__preview--hidden")).toBe(true); + upload.destroy?.(); + }); + + it("error div is hidden by default", () => { + const upload = makeUpload(); + const errorDiv = container.querySelector(".file-upload__error") as HTMLDivElement; + expect(errorDiv).not.toBeNull(); + expect(errorDiv.classList.contains("file-upload__error--hidden")).toBe(true); + upload.destroy?.(); + }); + + it("renders drop text in dropzone", () => { + const upload = makeUpload(); + const droptext = container.querySelector(".file-upload__droptext"); + expect(droptext).not.toBeNull(); + expect(droptext!.textContent).toBe("Drop files here"); + upload.destroy?.(); + }); + + it("renders preview sub-elements (thumb, name, size, progress, cancel)", () => { + const upload = makeUpload(); + expect(container.querySelector(".file-upload__thumb")).not.toBeNull(); + expect(container.querySelector(".file-upload__name")).not.toBeNull(); + expect(container.querySelector(".file-upload__size")).not.toBeNull(); + expect(container.querySelector(".file-upload__progress")).not.toBeNull(); + expect(container.querySelector(".file-upload__progress-bar")).not.toBeNull(); + expect(container.querySelector(".file-upload__cancel")).not.toBeNull(); + upload.destroy?.(); + }); + + it("dragenter shows dropzone", () => { + const upload = makeUpload(); + const root = container.querySelector(".file-upload") as HTMLDivElement; + const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; + + root.dispatchEvent(new Event("dragenter", { bubbles: true })); + expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(false); + upload.destroy?.(); + }); + + it("dragleave hides dropzone", () => { + const upload = makeUpload(); + const root = container.querySelector(".file-upload") as HTMLDivElement; + const dropzone = container.querySelector(".file-upload__dropzone") as HTMLDivElement; + + root.dispatchEvent(new Event("dragenter", { bubbles: true })); + root.dispatchEvent(new Event("dragleave", { bubbles: true })); + expect(dropzone.classList.contains("file-upload__dropzone--hidden")).toBe(true); + upload.destroy?.(); + }); + + it("openPicker triggers file input click", () => { + const upload = makeUpload(); + const input = container.querySelector(".file-upload__input") as HTMLInputElement; + const clickSpy = vi.spyOn(input, "click"); + + upload.openPicker(); + expect(clickSpy).toHaveBeenCalledOnce(); + upload.destroy?.(); + }); + + it("destroy removes DOM", () => { + const upload = makeUpload(); + expect(container.querySelector(".file-upload")).not.toBeNull(); + upload.destroy?.(); + expect(container.querySelector(".file-upload")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/invite-manager.test.ts b/Client/tauri-client/tests/unit/invite-manager.test.ts new file mode 100644 index 00000000..8dd22b1c --- /dev/null +++ b/Client/tauri-client/tests/unit/invite-manager.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + createInviteManager, + type InviteItem, + type InviteManagerOptions, +} from "@components/InviteManager"; + +function makeInvite(overrides: Partial<InviteItem> = {}): InviteItem { + return { + code: "abc123xyz", + createdBy: "admin", + createdAt: "2025-01-01T00:00:00Z", + uses: 3, + maxUses: 10, + expiresAt: null, + ...overrides, + }; +} + +function makeOptions(overrides: Partial<InviteManagerOptions> = {}): InviteManagerOptions { + return { + invites: [makeInvite()], + onCreateInvite: vi.fn(() => Promise.resolve(makeInvite({ code: "newcode123" }))), + onRevokeInvite: vi.fn(() => Promise.resolve()), + onCopyLink: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + ...overrides, + }; +} + +describe("InviteManager", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("mounts with overlay class and modal", () => { + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const overlay = container.querySelector(".invite-manager-overlay"); + expect(overlay).not.toBeNull(); + const modal = container.querySelector(".invite-manager"); + expect(modal).not.toBeNull(); + + mgr.destroy?.(); + }); + + it("renders invite items from options.invites", () => { + const opts = makeOptions({ + invites: [makeInvite({ code: "aaa111bbb" }), makeInvite({ code: "ccc222ddd" })], + }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const items = container.querySelectorAll(".invite-item"); + expect(items.length).toBe(2); + + mgr.destroy?.(); + }); + + it("masks codes (first 3 + ... + last 3)", () => { + const opts = makeOptions({ invites: [makeInvite({ code: "abcdefghi" })] }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const codeEl = container.querySelector(".invite-item__code"); + expect(codeEl?.textContent).toBe("abc...ghi"); + + mgr.destroy?.(); + }); + + it("click copy calls onCopyLink with code", () => { + const opts = makeOptions({ invites: [makeInvite({ code: "abc123xyz" })] }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const copyBtn = container.querySelector(".invite-item__copy") as HTMLButtonElement; + copyBtn.click(); + expect(opts.onCopyLink).toHaveBeenCalledWith("abc123xyz"); + + mgr.destroy?.(); + }); + + it("click create calls onCreateInvite and adds to list on resolve", async () => { + const newInvite = makeInvite({ code: "newcode123" }); + const opts = makeOptions({ + invites: [], + onCreateInvite: vi.fn(() => Promise.resolve(newInvite)), + }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + expect(container.querySelectorAll(".invite-item").length).toBe(0); + + const createBtn = container.querySelector(".invite-manager__create") as HTMLButtonElement; + createBtn.click(); + + // Wait for the promise to resolve + await vi.waitFor(() => { + expect(container.querySelectorAll(".invite-item").length).toBe(1); + }); + + mgr.destroy?.(); + }); + + it("click revoke calls onRevokeInvite and removes from list on resolve", async () => { + const opts = makeOptions({ invites: [makeInvite({ code: "abc123xyz" })] }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + expect(container.querySelectorAll(".invite-item").length).toBe(1); + + const revokeBtn = container.querySelector(".invite-item__revoke") as HTMLButtonElement; + revokeBtn.click(); + expect(opts.onRevokeInvite).toHaveBeenCalledWith("abc123xyz"); + + await vi.waitFor(() => { + expect(container.querySelectorAll(".invite-item").length).toBe(0); + }); + + mgr.destroy?.(); + }); + + it("close button calls onClose", () => { + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const closeBtn = container.querySelector(".invite-manager__close") as HTMLButtonElement; + closeBtn.click(); + expect(opts.onClose).toHaveBeenCalledOnce(); + + mgr.destroy?.(); + }); + + it("escape key calls onClose", () => { + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(opts.onClose).toHaveBeenCalledOnce(); + + mgr.destroy?.(); + }); + + it("clicking overlay backdrop calls onClose", () => { + const opts = makeOptions(); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const overlay = container.querySelector(".invite-manager-overlay") as HTMLDivElement; + // Clicking the overlay itself (not the modal) + overlay.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(opts.onClose).toHaveBeenCalledOnce(); + + mgr.destroy?.(); + }); + + it("create failure calls onError", async () => { + const opts = makeOptions({ + onCreateInvite: vi.fn(() => Promise.reject(new Error("fail"))), + }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const createBtn = container.querySelector(".invite-manager__create") as HTMLButtonElement; + createBtn.click(); + + await vi.waitFor(() => { + expect(opts.onError).toHaveBeenCalledWith("Failed to create invite"); + }); + + mgr.destroy?.(); + }); + + it("revoke failure calls onError", async () => { + const opts = makeOptions({ + onRevokeInvite: vi.fn(() => Promise.reject(new Error("fail"))), + }); + const mgr = createInviteManager(opts); + mgr.mount(container); + + const revokeBtn = container.querySelector(".invite-item__revoke") as HTMLButtonElement; + revokeBtn.click(); + + await vi.waitFor(() => { + expect(opts.onError).toHaveBeenCalledWith("Failed to revoke invite"); + }); + + mgr.destroy?.(); + }); +}); diff --git a/Client/tauri-client/tests/unit/keybinds-tab.test.ts b/Client/tauri-client/tests/unit/keybinds-tab.test.ts new file mode 100644 index 00000000..451f709a --- /dev/null +++ b/Client/tauri-client/tests/unit/keybinds-tab.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { buildKeybindsTab } from "../../src/components/settings/KeybindsTab"; + +describe("KeybindsTab", () => { + it("returns a div with settings-pane class", () => { + const el = buildKeybindsTab(); + expect(el.tagName).toBe("DIV"); + expect(el.className).toBe("settings-pane active"); + }); + + it("renders a Keybinds header", () => { + const el = buildKeybindsTab(); + const h1 = el.querySelector("h1"); + expect(h1).not.toBeNull(); + expect(h1!.textContent).toBe("Keybinds"); + }); + + it("renders Push to Talk keybind row", () => { + const el = buildKeybindsTab(); + const rows = el.querySelectorAll(".keybind-row"); + expect(rows.length).toBe(2); + const pttLabel = rows[0]!.querySelector(".setting-label"); + expect(pttLabel!.textContent).toBe("Push to Talk"); + }); + + it("renders Quick Switcher keybind row with Ctrl + K", () => { + const el = buildKeybindsTab(); + const rows = el.querySelectorAll(".keybind-row"); + const kbd = rows[1]!.querySelector(".kbd"); + expect(kbd!.textContent).toBe("Ctrl + K"); + }); + + it("shows fallback for PTT when not configured", () => { + const el = buildKeybindsTab(); + const rows = el.querySelectorAll(".keybind-row"); + const kbd = rows[0]!.querySelector(".kbd"); + expect(kbd!.textContent).toBe("Not set"); + }); +}); diff --git a/Client/tauri-client/tests/unit/logger.test.ts b/Client/tauri-client/tests/unit/logger.test.ts new file mode 100644 index 00000000..4d7230f7 --- /dev/null +++ b/Client/tauri-client/tests/unit/logger.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createLogger, + setLogLevel, + addLogListener, +} from "../../src/lib/logger"; + +describe("logger", () => { + beforeEach(() => { + setLogLevel("debug"); + vi.restoreAllMocks(); + }); + + it("logs to console at each level", () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const log = createLogger("test"); + log.debug("debug msg"); + log.info("info msg"); + log.warn("warn msg"); + log.error("error msg"); + + expect(debugSpy).toHaveBeenCalledTimes(1); + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it("respects log level filtering", () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + setLogLevel("warn"); + const log = createLogger("test"); + log.debug("should not appear"); + log.warn("should appear"); + + expect(debugSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("includes component name in output", () => { + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + + const log = createLogger("MyComponent"); + log.info("hello"); + + expect(infoSpy).toHaveBeenCalledTimes(1); + const firstArg = infoSpy.mock.calls[0]?.[0] as string; + expect(firstArg).toContain("[MyComponent]"); + }); + + it("includes data parameter when provided", () => { + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + + const log = createLogger("test"); + log.info("with data", { key: "value" }); + + expect(infoSpy).toHaveBeenCalledWith( + expect.any(String), + "with data", + { key: "value" }, + ); + }); + + it("notifies listeners", () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + const listener = vi.fn(); + const unsubscribe = addLogListener(listener); + + const log = createLogger("test"); + log.info("hello"); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0]?.[0]).toMatchObject({ + level: "info", + component: "test", + message: "hello", + }); + + unsubscribe(); + log.info("after unsubscribe"); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("unsubscribe removes listener", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const listener = vi.fn(); + const unsubscribe = addLogListener(listener); + + unsubscribe(); + + const log = createLogger("test"); + log.warn("should not reach listener"); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/logs-tab.test.ts b/Client/tauri-client/tests/unit/logs-tab.test.ts new file mode 100644 index 00000000..eef9d94f --- /dev/null +++ b/Client/tauri-client/tests/unit/logs-tab.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// vi.hoisted ensures these are available when vi.mock factory runs +const { + mockGetLogBuffer, + mockClearLogBuffer, + mockAddLogListener, + mockSetLogLevel, +} = vi.hoisted(() => ({ + mockGetLogBuffer: vi.fn(), + mockClearLogBuffer: vi.fn(), + mockAddLogListener: vi.fn(), + mockSetLogLevel: vi.fn(), +})); + +vi.mock("@lib/logger", () => ({ + getLogBuffer: mockGetLogBuffer, + clearLogBuffer: mockClearLogBuffer, + addLogListener: mockAddLogListener, + setLogLevel: mockSetLogLevel, + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); + +import { createLogsTab } from "../../src/components/settings/LogsTab"; +import type { TabName } from "../../src/components/SettingsOverlay"; + +function makeMockEntry(level: "debug" | "info" | "warn" | "error", msg: string) { + return { + level, + message: msg, + component: "test", + timestamp: "2026-03-17T12:00:00.000Z", + }; +} + +describe("LogsTab", () => { + let controller: AbortController; + + beforeEach(() => { + vi.restoreAllMocks(); + controller = new AbortController(); + mockGetLogBuffer.mockReturnValue([]); + mockAddLogListener.mockReturnValue(() => {}); + }); + + afterEach(() => { + controller.abort(); + }); + + it("returns an object with build and cleanup", () => { + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + expect(handle).toHaveProperty("build"); + expect(handle).toHaveProperty("cleanup"); + }); + + it("build() returns a div with settings-pane class", () => { + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + expect(el.tagName).toBe("DIV"); + expect(el.className).toBe("settings-pane active"); + }); + + it("renders a Logs header", () => { + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + const h1 = el.querySelector("h1"); + expect(h1!.textContent).toBe("Logs"); + }); + + it("renders log entries from getLogBuffer", () => { + mockGetLogBuffer.mockReturnValue([ + makeMockEntry("info", "hello"), + makeMockEntry("warn", "warning"), + ]); + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + const entries = el.querySelectorAll(".log-entry"); + expect(entries.length).toBe(2); + }); + + it("renders log entry with data field", () => { + mockGetLogBuffer.mockReturnValue([ + { ...makeMockEntry("info", "with data"), data: { key: "value" } }, + ]); + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + const pre = el.querySelector("pre"); + expect(pre).not.toBeNull(); + }); + + it("renders log entry with string data field", () => { + mockGetLogBuffer.mockReturnValue([ + { ...makeMockEntry("info", "str data"), data: "some string" }, + ]); + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + // Find the <pre> inside a log-entry row (not the diagnostics result <pre>). + const pre = el.querySelector(".log-entry pre"); + expect(pre).not.toBeNull(); + expect(pre!.textContent).toBe("some string"); + }); + + it("renders filter dropdown and level selector", () => { + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + const selects = el.querySelectorAll("select"); + expect(selects.length).toBe(2); + }); + + it("renders Clear Logs and Refresh buttons", () => { + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + const buttons = el.querySelectorAll("button"); + const texts = Array.from(buttons).map((b) => b.textContent); + expect(texts).toContain("Clear Logs"); + expect(texts).toContain("Refresh"); + }); + + it("shows entry count", () => { + mockGetLogBuffer.mockReturnValue([ + makeMockEntry("info", "one"), + makeMockEntry("info", "two"), + makeMockEntry("info", "three"), + ]); + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + expect(el.textContent).toContain("3 entries"); + }); + + it("subscribes to log listener on build", () => { + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + handle.build(); + expect(mockAddLogListener).toHaveBeenCalledTimes(1); + }); + + it("cleanup unsubscribes log listener", () => { + const unsub = vi.fn(); + mockAddLogListener.mockReturnValue(unsub); + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + handle.build(); + handle.cleanup(); + expect(unsub).toHaveBeenCalledTimes(1); + }); + + it("filter dropdown changes filter level", () => { + mockGetLogBuffer.mockReturnValue([ + makeMockEntry("info", "info msg"), + makeMockEntry("warn", "warn msg"), + ]); + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + const filterSelect = el.querySelectorAll("select")[0]!; + + // Change to "warn" filter + filterSelect.value = "warn"; + filterSelect.dispatchEvent(new Event("change")); + + const entries = el.querySelectorAll(".log-entry"); + expect(entries.length).toBe(1); + }); + + it("clear button calls clearLogBuffer", () => { + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + const clearBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent === "Clear Logs", + ); + clearBtn!.click(); + expect(mockClearLogBuffer).toHaveBeenCalledTimes(1); + }); + + it("level selector calls setLogLevel", () => { + const handle = createLogsTab(() => "Logs" as TabName, controller.signal); + const el = handle.build(); + const levelSelect = el.querySelectorAll("select")[1]!; + levelSelect.value = "error"; + levelSelect.dispatchEvent(new Event("change")); + expect(mockSetLogLevel).toHaveBeenCalledWith("error"); + }); +}); diff --git a/Client/tauri-client/tests/unit/member-list.test.ts b/Client/tauri-client/tests/unit/member-list.test.ts new file mode 100644 index 00000000..5d8fa441 --- /dev/null +++ b/Client/tauri-client/tests/unit/member-list.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createMemberList } from "@components/MemberList"; +import { membersStore } from "@stores/members.store"; +import type { Member } from "@stores/members.store"; +import type { UserStatus } from "../../src/lib/types"; + +function resetStore(): void { + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function makeMember(overrides: Partial<Member> & { id: number; username: string }): Member { + return { + avatar: null, + role: "member", + status: "online" as UserStatus, + ...overrides, + }; +} + +function setTestMembers(members: Member[]): void { + const map = new Map<number, Member>(); + for (const m of members) { + map.set(m.id, m); + } + membersStore.setState((prev) => ({ ...prev, members: map })); +} + +const testMembers: Member[] = [ + makeMember({ id: 1, username: "Alice", role: "owner", status: "online" as UserStatus }), + makeMember({ id: 2, username: "Bob", role: "admin", status: "idle" as UserStatus }), + makeMember({ id: 3, username: "Charlie", role: "moderator", status: "online" as UserStatus }), + makeMember({ id: 4, username: "Dave", role: "member", status: "offline" as UserStatus }), + makeMember({ id: 5, username: "Eve", role: "member", status: "online" as UserStatus }), + makeMember({ id: 6, username: "Frank", role: "admin", status: "online" as UserStatus }), +]; + +describe("MemberList", () => { + let container: HTMLDivElement; + let memberList: ReturnType<typeof createMemberList>; + + beforeEach(() => { + resetStore(); + container = document.createElement("div"); + document.body.appendChild(container); + memberList = createMemberList(); + }); + + afterEach(() => { + memberList.destroy?.(); + container.remove(); + }); + + it("mounts with member-list class", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const root = container.querySelector(".member-list"); + expect(root).not.toBeNull(); + expect(root!.getAttribute("data-testid")).toBe("member-list"); + }); + + it("groups members by role (OWNER, ADMIN, MODERATOR, MEMBER)", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const headers = container.querySelectorAll(".member-role-group"); + const headerTexts = Array.from(headers).map((h) => h.textContent); + + // Should have all 4 role groups + expect(headers.length).toBe(4); + expect(headerTexts[0]).toContain("OWNER"); + expect(headerTexts[1]).toContain("ADMIN"); + expect(headerTexts[2]).toContain("MODERATOR"); + expect(headerTexts[3]).toContain("MEMBER"); + }); + + it("sorts by status within groups (online first)", () => { + // Two admins: Frank (online) and Bob (idle) + setTestMembers(testMembers); + memberList.mount(container); + + const memberItems = container.querySelectorAll(".member-item"); + const adminItems: HTMLDivElement[] = []; + let inAdminGroup = false; + + // Walk items in DOM order to extract admin group members + const allElements = container.querySelectorAll(".member-role-group, .member-item"); + for (const el of allElements) { + if (el.classList.contains("member-role-group")) { + inAdminGroup = el.textContent?.includes("ADMIN") ?? false; + } else if (inAdminGroup && el.classList.contains("member-item")) { + adminItems.push(el as HTMLDivElement); + } + } + + expect(adminItems.length).toBe(2); + // Frank (online, priority 0) should come before Bob (idle, priority 1) + expect(adminItems[0]!.getAttribute("data-testid")).toBe("member-6"); // Frank + expect(adminItems[1]!.getAttribute("data-testid")).toBe("member-2"); // Bob + }); + + it("shows role group headers with count", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const headers = container.querySelectorAll(".member-role-group"); + const headerTexts = Array.from(headers).map((h) => h.textContent); + + // OWNER has 1, ADMIN has 2, MODERATOR has 1, MEMBER has 2 + expect(headerTexts[0]).toContain("1"); + expect(headerTexts[1]).toContain("2"); + expect(headerTexts[2]).toContain("1"); + expect(headerTexts[3]).toContain("2"); + }); + + it("shows member avatars with first letter", () => { + setTestMembers(testMembers); + memberList.mount(container); + + const avatars = container.querySelectorAll(".mi-avatar"); + const letters = Array.from(avatars).map((a) => a.textContent?.trim()); + + expect(letters).toContain("A"); // Alice + expect(letters).toContain("B"); // Bob + expect(letters).toContain("C"); // Charlie + }); + + it("offline members have offline class", () => { + setTestMembers(testMembers); + memberList.mount(container); + + // Dave (id 4) is offline + const daveItem = container.querySelector('[data-testid="member-4"]'); + expect(daveItem).not.toBeNull(); + expect(daveItem!.classList.contains("offline")).toBe(true); + + // Eve (id 5) is online, should NOT have offline class + const eveItem = container.querySelector('[data-testid="member-5"]'); + expect(eveItem).not.toBeNull(); + expect(eveItem!.classList.contains("offline")).toBe(false); + }); + + it("empty store renders no groups", () => { + memberList.mount(container); + + const headers = container.querySelectorAll(".member-role-group"); + expect(headers.length).toBe(0); + + const items = container.querySelectorAll(".member-item"); + expect(items.length).toBe(0); + }); + + it("destroy removes DOM", () => { + setTestMembers(testMembers); + memberList.mount(container); + + expect(container.querySelector(".member-list")).not.toBeNull(); + memberList.destroy?.(); + expect(container.querySelector(".member-list")).toBeNull(); + }); + + it("reacts to store changes", () => { + memberList.mount(container); + expect(container.querySelectorAll(".member-item").length).toBe(0); + + // Add members after mount + setTestMembers(testMembers); + membersStore.flush(); + + expect(container.querySelectorAll(".member-item").length).toBe(6); + }); +}); diff --git a/Client/tauri-client/tests/unit/members.store.test.ts b/Client/tauri-client/tests/unit/members.store.test.ts new file mode 100644 index 00000000..453cfac5 --- /dev/null +++ b/Client/tauri-client/tests/unit/members.store.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + membersStore, + setMembers, + addMember, + removeMember, + updateMemberRole, + updatePresence, + setTyping, + clearTyping, + getOnlineMembers, + getTypingUsers, +} from "../../src/stores/members.store"; +import type { ReadyMember, MemberJoinPayload, UserStatus } from "../../src/lib/types"; + +const MEMBER_ALICE: ReadyMember = { + id: 1, + username: "alice", + avatar: "alice.png", + role: "admin", + status: "online", +}; + +const MEMBER_BOB: ReadyMember = { + id: 2, + username: "bob", + avatar: null, + role: "member", + status: "idle", +}; + +const MEMBER_CAROL: ReadyMember = { + id: 3, + username: "carol", + avatar: "carol.png", + role: "member", + status: "offline", +}; + +function resetStore(): void { + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +describe("members store", () => { + beforeEach(() => { + vi.useFakeTimers(); + resetStore(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("initial state", () => { + it("has empty members map", () => { + // Reset already applied; check fresh state + expect(membersStore.getState().members.size).toBe(0); + }); + + it("has empty typingUsers map", () => { + expect(membersStore.getState().typingUsers.size).toBe(0); + }); + }); + + describe("setMembers", () => { + it("populates members from ready payload", () => { + setMembers([MEMBER_ALICE, MEMBER_BOB]); + const state = membersStore.getState(); + expect(state.members.size).toBe(2); + expect(state.members.get(1)).toEqual({ + id: 1, + username: "alice", + avatar: "alice.png", + role: "admin", + status: "online", + }); + }); + + it("replaces existing members entirely", () => { + setMembers([MEMBER_ALICE, MEMBER_BOB]); + setMembers([MEMBER_CAROL]); + const state = membersStore.getState(); + expect(state.members.size).toBe(1); + expect(state.members.has(1)).toBe(false); + expect(state.members.has(3)).toBe(true); + }); + + it("produces a new state object", () => { + const before = membersStore.getState(); + setMembers([MEMBER_ALICE]); + const after = membersStore.getState(); + expect(before).not.toBe(after); + }); + }); + + describe("addMember", () => { + it("adds a new member from member_join payload", () => { + const payload: MemberJoinPayload = { + user: { id: 10, username: "newuser", avatar: null, role: "member" }, + }; + addMember(payload); + const member = membersStore.getState().members.get(10); + expect(member).toEqual({ + id: 10, + username: "newuser", + avatar: null, + role: "member", + status: "online", + }); + }); + + it("does not remove existing members", () => { + setMembers([MEMBER_ALICE]); + addMember({ + user: { id: 10, username: "newuser", avatar: null, role: "member" }, + }); + expect(membersStore.getState().members.size).toBe(2); + expect(membersStore.getState().members.has(1)).toBe(true); + }); + }); + + describe("removeMember", () => { + it("removes a member by userId", () => { + setMembers([MEMBER_ALICE, MEMBER_BOB]); + removeMember(1); + const state = membersStore.getState(); + expect(state.members.size).toBe(1); + expect(state.members.has(1)).toBe(false); + }); + + it("is a no-op for non-existent userId", () => { + setMembers([MEMBER_ALICE]); + const before = membersStore.getState(); + removeMember(999); + // Map was still rebuilt, but size unchanged + expect(membersStore.getState().members.size).toBe(1); + }); + }); + + describe("updateMemberRole", () => { + it("updates role of an existing member", () => { + setMembers([MEMBER_BOB]); + updateMemberRole(2, "admin"); + expect(membersStore.getState().members.get(2)?.role).toBe("admin"); + }); + + it("preserves other fields", () => { + setMembers([MEMBER_BOB]); + updateMemberRole(2, "admin"); + const member = membersStore.getState().members.get(2)!; + expect(member.username).toBe("bob"); + expect(member.status).toBe("idle"); + }); + + it("returns same state for unknown userId", () => { + setMembers([MEMBER_ALICE]); + const before = membersStore.getState(); + updateMemberRole(999, "admin"); + expect(membersStore.getState()).toBe(before); + }); + }); + + describe("updatePresence", () => { + it("updates status of an existing member", () => { + setMembers([MEMBER_ALICE]); + updatePresence(1, "dnd"); + expect(membersStore.getState().members.get(1)?.status).toBe("dnd"); + }); + + it("preserves other fields", () => { + setMembers([MEMBER_ALICE]); + updatePresence(1, "idle"); + const member = membersStore.getState().members.get(1)!; + expect(member.username).toBe("alice"); + expect(member.role).toBe("admin"); + }); + + it("returns same state for unknown userId", () => { + setMembers([MEMBER_ALICE]); + const before = membersStore.getState(); + updatePresence(999, "online"); + expect(membersStore.getState()).toBe(before); + }); + }); + + describe("setTyping / clearTyping", () => { + it("adds a user to the typing set for a channel", () => { + setMembers([MEMBER_ALICE]); + setTyping(100, 1); + const typingSet = membersStore.getState().typingUsers.get(100); + expect(typingSet).toBeDefined(); + expect(typingSet!.has(1)).toBe(true); + }); + + it("supports multiple users typing in the same channel", () => { + setMembers([MEMBER_ALICE, MEMBER_BOB]); + setTyping(100, 1); + setTyping(100, 2); + const typingSet = membersStore.getState().typingUsers.get(100); + expect(typingSet!.size).toBe(2); + }); + + it("clearTyping removes a user from the channel", () => { + setMembers([MEMBER_ALICE, MEMBER_BOB]); + setTyping(100, 1); + setTyping(100, 2); + clearTyping(100, 1); + const typingSet = membersStore.getState().typingUsers.get(100); + expect(typingSet!.has(1)).toBe(false); + expect(typingSet!.has(2)).toBe(true); + }); + + it("removes the channel entry when last user clears", () => { + setTyping(100, 1); + clearTyping(100, 1); + expect(membersStore.getState().typingUsers.has(100)).toBe(false); + }); + + it("auto-clears typing after 5 seconds", () => { + setMembers([MEMBER_ALICE]); + setTyping(100, 1); + expect(membersStore.getState().typingUsers.get(100)?.has(1)).toBe(true); + + vi.advanceTimersByTime(5000); + + expect(membersStore.getState().typingUsers.has(100)).toBe(false); + }); + + it("resets the auto-clear timer when setTyping is called again", () => { + setMembers([MEMBER_ALICE]); + setTyping(100, 1); + + // Advance 3 seconds, then set typing again + vi.advanceTimersByTime(3000); + setTyping(100, 1); + + // Advance another 3 seconds — original timer would have expired + vi.advanceTimersByTime(3000); + expect(membersStore.getState().typingUsers.get(100)?.has(1)).toBe(true); + + // Advance remaining 2 seconds to hit the new 5s timer + vi.advanceTimersByTime(2000); + expect(membersStore.getState().typingUsers.has(100)).toBe(false); + }); + + it("clearTyping is a no-op for non-typing user", () => { + setMembers([MEMBER_ALICE]); + const before = membersStore.getState(); + clearTyping(100, 1); + expect(membersStore.getState()).toBe(before); + }); + }); + + describe("getOnlineMembers", () => { + it("returns members where status is not offline", () => { + setMembers([MEMBER_ALICE, MEMBER_BOB, MEMBER_CAROL]); + const online = getOnlineMembers(); + expect(online).toHaveLength(2); + expect(online.map((m) => m.id).sort()).toEqual([1, 2]); + }); + + it("returns empty array when all members are offline", () => { + setMembers([MEMBER_CAROL]); + expect(getOnlineMembers()).toHaveLength(0); + }); + + it("returns empty array when no members exist", () => { + expect(getOnlineMembers()).toHaveLength(0); + }); + }); + + describe("getTypingUsers", () => { + it("returns Member objects for users typing in a channel", () => { + setMembers([MEMBER_ALICE, MEMBER_BOB]); + setTyping(100, 1); + const typing = getTypingUsers(100); + expect(typing).toHaveLength(1); + expect(typing[0]?.username).toBe("alice"); + }); + + it("returns empty array for a channel with no typing users", () => { + setMembers([MEMBER_ALICE]); + expect(getTypingUsers(100)).toHaveLength(0); + }); + + it("skips userId not found in members", () => { + setTyping(100, 999); + expect(getTypingUsers(100)).toHaveLength(0); + }); + }); + + describe("subscribe", () => { + it("notifies on setMembers", () => { + const listener = vi.fn(); + const unsub = membersStore.subscribe(listener); + setMembers([MEMBER_ALICE]); + membersStore.flush(); + expect(listener).toHaveBeenCalledTimes(1); + unsub(); + }); + + it("does not notify after unsubscribe", () => { + const listener = vi.fn(); + const unsub = membersStore.subscribe(listener); + unsub(); + setMembers([MEMBER_ALICE]); + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/message-input.test.ts b/Client/tauri-client/tests/unit/message-input.test.ts new file mode 100644 index 00000000..6362238d --- /dev/null +++ b/Client/tauri-client/tests/unit/message-input.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@components/EmojiPicker", () => ({ + createEmojiPicker: () => ({ + element: document.createElement("div"), + destroy: vi.fn(), + }), +})); + +import { + createMessageInput, + type MessageInputOptions, +} from "@components/MessageInput"; + +function makeOptions(overrides: Partial<MessageInputOptions> = {}): MessageInputOptions { + return { + channelId: 1, + channelName: "general", + onSend: vi.fn(), + onTyping: vi.fn(), + onEditMessage: vi.fn(), + ...overrides, + }; +} + +describe("MessageInput", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("mounts with message-input-wrap class", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + expect(container.querySelector(".message-input-wrap")).not.toBeNull(); + + comp.destroy?.(); + }); + + it("has textarea with correct placeholder", () => { + const opts = makeOptions({ channelName: "random" }); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + expect(textarea.placeholder).toBe("Message #random"); + + comp.destroy?.(); + }); + + it("send button click calls onSend with textarea content", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "Hello world"; + + const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement; + sendBtn.click(); + + expect(opts.onSend).toHaveBeenCalledWith("Hello world", null, []); + + comp.destroy?.(); + }); + + it("enter key sends message", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "Enter message"; + + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + + expect(opts.onSend).toHaveBeenCalledWith("Enter message", null, []); + + comp.destroy?.(); + }); + + it("shift+enter does NOT send (just newlines)", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "Line 1"; + + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true }), + ); + + expect(opts.onSend).not.toHaveBeenCalled(); + + comp.destroy?.(); + }); + + it("empty textarea does not send", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = ""; + + const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement; + sendBtn.click(); + + expect(opts.onSend).not.toHaveBeenCalled(); + + comp.destroy?.(); + }); + + it("setReplyTo shows reply bar", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + comp.setReplyTo(42, "testuser"); + + const replyBar = container.querySelector(".reply-bar") as HTMLDivElement; + expect(replyBar.classList.contains("visible")).toBe(true); + expect(replyBar.textContent).toContain("testuser"); + + comp.destroy?.(); + }); + + it("clearReply hides reply bar", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + comp.setReplyTo(42, "testuser"); + comp.clearReply(); + + const replyBar = container.querySelector(".reply-bar") as HTMLDivElement; + expect(replyBar.classList.contains("visible")).toBe(false); + + comp.destroy?.(); + }); + + it("startEdit sets textarea value and shows edit bar", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + comp.startEdit(99, "editing this"); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + expect(textarea.value).toBe("editing this"); + + // The edit bar is the second .reply-bar + const bars = container.querySelectorAll(".reply-bar"); + const editBar = bars[1] as HTMLDivElement; + expect(editBar.classList.contains("visible")).toBe(true); + + comp.destroy?.(); + }); + + it("cancelEdit clears textarea and hides edit bar", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + comp.startEdit(99, "editing this"); + comp.cancelEdit(); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + expect(textarea.value).toBe(""); + + const bars = container.querySelectorAll(".reply-bar"); + const editBar = bars[1] as HTMLDivElement; + expect(editBar.classList.contains("visible")).toBe(false); + + comp.destroy?.(); + }); + + it("typing emits onTyping (throttled)", () => { + vi.useFakeTimers(); + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + + // First input should trigger onTyping + textarea.dispatchEvent(new Event("input", { bubbles: true })); + expect(opts.onTyping).toHaveBeenCalledTimes(1); + + // Immediate second input should NOT trigger (throttled at 3s) + textarea.dispatchEvent(new Event("input", { bubbles: true })); + expect(opts.onTyping).toHaveBeenCalledTimes(1); + + // After 3 seconds, should fire again + vi.advanceTimersByTime(3000); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + expect(opts.onTyping).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + comp.destroy?.(); + }); + + it("attach button is disabled with tooltip", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const attachBtn = container.querySelector(".attach-btn") as HTMLButtonElement; + expect(attachBtn).not.toBeNull(); + expect(attachBtn.disabled).toBe(true); + expect(attachBtn.title).toBe("File uploads not available"); + + comp.destroy?.(); + }); + + it("debounces rapid sends", () => { + vi.useFakeTimers(); + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement; + + textarea.value = "msg1"; + sendBtn.click(); + expect(opts.onSend).toHaveBeenCalledTimes(1); + + // Immediately try to send again (within 200ms debounce) + textarea.value = "msg2"; + sendBtn.click(); + expect(opts.onSend).toHaveBeenCalledTimes(1); // still 1 + + // After debounce period + vi.advanceTimersByTime(200); + textarea.value = "msg3"; + sendBtn.click(); + expect(opts.onSend).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + comp.destroy?.(); + }); +}); diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts new file mode 100644 index 00000000..1205e639 --- /dev/null +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createMessageList } from "@components/MessageList"; +import type { MessageListOptions } from "@components/MessageList"; +import { messagesStore } from "@stores/messages.store"; +import { membersStore } from "@stores/members.store"; +import type { Message } from "@stores/messages.store"; + +function resetStores(): void { + messagesStore.setState(() => ({ + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function makeMessage(overrides: Partial<Message> & { id: number }): Message { + return { + channelId: 1, + user: { id: 1, username: "Alice", avatar: null }, + content: `Message ${overrides.id}`, + replyTo: null, + attachments: [], + reactions: [], + editedAt: null, + deleted: false, + timestamp: "2024-01-15T12:00:00Z", + ...overrides, + }; +} + +function setMessages(channelId: number, messages: Message[]): void { + messagesStore.setState((prev) => { + const next = new Map(prev.messagesByChannel); + next.set(channelId, messages); + return { ...prev, messagesByChannel: next }; + }); +} + +function setHasMore(channelId: number, value: boolean): void { + messagesStore.setState((prev) => { + const next = new Map(prev.hasMore); + next.set(channelId, value); + return { ...prev, hasMore: next }; + }); +} + +export type MessageListComponent = ReturnType<typeof createMessageList>; + +describe("MessageList", () => { + let container: HTMLDivElement; + let msgList: MessageListComponent; + let options: MessageListOptions; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + options = { + channelId: 1, + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + }; + msgList = createMessageList(options); + }); + + afterEach(() => { + msgList.destroy?.(); + container.remove(); + }); + + it("mounts with messages-container class", () => { + msgList.mount(container); + const root = container.querySelector(".messages-container"); + expect(root).not.toBeNull(); + }); + + it("renders virtual scroll structure (spacers + content)", () => { + msgList.mount(container); + expect(container.querySelector(".virtual-spacer-top")).not.toBeNull(); + expect(container.querySelector(".virtual-content")).not.toBeNull(); + expect(container.querySelector(".virtual-spacer-bottom")).not.toBeNull(); + }); + + it("renders messages from store", () => { + const messages = [ + makeMessage({ id: 1, content: "Hello" }), + makeMessage({ id: 2, content: "World" }), + ]; + setMessages(1, messages); + msgList.mount(container); + + const content = container.querySelector(".virtual-content"); + expect(content).not.toBeNull(); + // Should have rendered items (day divider + messages) + expect(content!.children.length).toBeGreaterThan(0); + }); + + it("empty channel renders no content children (besides spacers)", () => { + msgList.mount(container); + const content = container.querySelector(".virtual-content"); + expect(content).not.toBeNull(); + expect(content!.children.length).toBe(0); + }); + + it("destroy removes DOM and cleans up", () => { + msgList.mount(container); + expect(container.querySelector(".messages-container")).not.toBeNull(); + msgList.destroy?.(); + expect(container.querySelector(".messages-container")).toBeNull(); + }); + + it("reacts to store updates", () => { + msgList.mount(container); + const content = container.querySelector(".virtual-content"); + expect(content!.children.length).toBe(0); + + // Add messages + setMessages(1, [makeMessage({ id: 1, content: "New message" })]); + messagesStore.flush(); + + expect(content!.children.length).toBeGreaterThan(0); + }); + + it("scrollToMessage returns true when message exists in virtual items", () => { + const messages = [ + makeMessage({ id: 1, content: "Hello" }), + makeMessage({ id: 2, content: "Target message" }), + makeMessage({ id: 3, content: "World" }), + ]; + setMessages(1, messages); + msgList.mount(container); + + const result = msgList.scrollToMessage(2); + expect(result).toBe(true); + }); + + it("scrollToMessage returns false when message not found", () => { + setMessages(1, [makeMessage({ id: 1 })]); + msgList.mount(container); + + const result = msgList.scrollToMessage(999); + expect(result).toBe(false); + }); + + it("renders day dividers between messages on different days", () => { + const messages = [ + makeMessage({ id: 1, timestamp: "2024-01-15T12:00:00Z" }), + makeMessage({ id: 2, timestamp: "2024-01-16T12:00:00Z" }), + ]; + setMessages(1, messages); + msgList.mount(container); + + // Virtual scroll in jsdom has no real layout (clientHeight=0), + // so we verify content was rendered at all — the render window + // may include all items since offsetToIndex returns 0-based for + // zero-height containers. Check for msg-day-divider class. + const content = container.querySelector(".virtual-content"); + expect(content).not.toBeNull(); + // The virtual scroll renders items based on estimated heights. + // In jsdom with 0 clientHeight, renderWindow computes start=0, end=OVERSCAN+1. + // With only 4 items (2 dividers + 2 messages), all should be in the window. + const dividers = container.querySelectorAll(".msg-day-divider"); + expect(dividers.length).toBe(2); + }); +}); diff --git a/Client/tauri-client/tests/unit/messages.store.test.ts b/Client/tauri-client/tests/unit/messages.store.test.ts new file mode 100644 index 00000000..a4c3b515 --- /dev/null +++ b/Client/tauri-client/tests/unit/messages.store.test.ts @@ -0,0 +1,475 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + messagesStore, + addMessage, + setMessages, + prependMessages, + editMessage, + deleteMessage, + addPendingSend, + confirmSend, + getChannelMessages, + isChannelLoaded, + clearChannelMessages, +} from "../../src/stores/messages.store"; +import type { + ChatMessagePayload, + ChatEditedPayload, + ChatDeletedPayload, + MessageResponse, + MessageUser, + Attachment, +} from "../../src/lib/types"; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const TEST_USER: MessageUser = { + id: 1, + username: "alice", + avatar: "alice.png", +}; + +const TEST_USER_2: MessageUser = { + id: 2, + username: "bob", + avatar: null, +}; + +const ATTACHMENT: Attachment = { + id: "att-1", + filename: "screenshot.png", + size: 1024, + mime: "image/png", + url: "/uploads/screenshot.png", +}; + +function makeChatPayload(overrides?: Partial<ChatMessagePayload>): ChatMessagePayload { + return { + id: 100, + channel_id: 1, + user: TEST_USER, + content: "Hello world", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + ...overrides, + }; +} + +function makeMessageResponse(overrides?: Partial<MessageResponse>): MessageResponse { + return { + id: 200, + channel_id: 1, + user: TEST_USER, + content: "REST message", + reply_to: null, + attachments: [], + reactions: [], + pinned: false, + edited_at: null, + deleted: false, + timestamp: "2026-03-15T09:00:00Z", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Reset helper — clears all channels we might have touched +// --------------------------------------------------------------------------- + +function resetStore(): void { + clearChannelMessages(1); + clearChannelMessages(2); + clearChannelMessages(99); + // Clear any leftover pending sends by confirming them + const pending = messagesStore.getState().pendingSends; + for (const [corrId] of pending) { + confirmSend(corrId, 0, ""); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("messages store", () => { + beforeEach(() => { + resetStore(); + }); + + // 1. Initial state is empty + describe("initial state", () => { + it("has empty messagesByChannel", () => { + expect(messagesStore.getState().messagesByChannel.size).toBe(0); + }); + + it("has empty pendingSends", () => { + expect(messagesStore.getState().pendingSends.size).toBe(0); + }); + + it("has empty loadedChannels", () => { + expect(messagesStore.getState().loadedChannels.size).toBe(0); + }); + + it("has empty hasMore", () => { + expect(messagesStore.getState().hasMore.size).toBe(0); + }); + }); + + // 2. addMessage appends to correct channel + describe("addMessage", () => { + it("adds a message to the correct channel", () => { + addMessage(makeChatPayload({ id: 1, channel_id: 1 })); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(1); + expect(msgs[0]!.id).toBe(1); + expect(msgs[0]!.channelId).toBe(1); + }); + + it("converts snake_case fields to camelCase", () => { + addMessage( + makeChatPayload({ + id: 10, + channel_id: 2, + reply_to: 5, + attachments: [ATTACHMENT], + }), + ); + + const msg = getChannelMessages(2)[0]!; + expect(msg.channelId).toBe(2); + expect(msg.replyTo).toBe(5); + expect(msg.attachments).toEqual([ATTACHMENT]); + expect(msg.editedAt).toBeNull(); + expect(msg.deleted).toBe(false); + }); + + it("appends subsequent messages in order", () => { + addMessage(makeChatPayload({ id: 1, channel_id: 1 })); + addMessage(makeChatPayload({ id: 2, channel_id: 1, content: "Second" })); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(2); + expect(msgs[0]!.id).toBe(1); + expect(msgs[1]!.id).toBe(2); + }); + + it("keeps messages in separate channels isolated", () => { + addMessage(makeChatPayload({ id: 1, channel_id: 1 })); + addMessage(makeChatPayload({ id: 2, channel_id: 2 })); + + expect(getChannelMessages(1)).toHaveLength(1); + expect(getChannelMessages(2)).toHaveLength(1); + }); + + it("produces a new state reference", () => { + const before = messagesStore.getState(); + addMessage(makeChatPayload()); + const after = messagesStore.getState(); + expect(before).not.toBe(after); + }); + }); + + // 3. setMessages bulk sets and marks loaded + describe("setMessages", () => { + it("sets messages for a channel", () => { + // API returns newest-first; store reverses to oldest-first for display. + const responses = [ + makeMessageResponse({ id: 11 }), + makeMessageResponse({ id: 10 }), + ]; + setMessages(1, responses, false); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(2); + expect(msgs[0]!.id).toBe(10); + expect(msgs[1]!.id).toBe(11); + }); + + it("marks channel as loaded", () => { + expect(isChannelLoaded(1)).toBe(false); + setMessages(1, [], false); + expect(isChannelLoaded(1)).toBe(true); + }); + + it("stores hasMore flag", () => { + setMessages(1, [], true); + expect(messagesStore.getState().hasMore.get(1)).toBe(true); + + setMessages(2, [], false); + expect(messagesStore.getState().hasMore.get(2)).toBe(false); + }); + + it("converts MessageResponse fields to camelCase", () => { + setMessages( + 1, + [makeMessageResponse({ edited_at: "2026-03-15T11:00:00Z", reply_to: 3 })], + false, + ); + + const msg = getChannelMessages(1)[0]!; + expect(msg.editedAt).toBe("2026-03-15T11:00:00Z"); + expect(msg.replyTo).toBe(3); + }); + + it("replaces existing messages for the channel", () => { + setMessages(1, [makeMessageResponse({ id: 10 })], false); + setMessages(1, [makeMessageResponse({ id: 20 })], false); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(1); + expect(msgs[0]!.id).toBe(20); + }); + }); + + // 4. prependMessages prepends older messages + describe("prependMessages", () => { + it("prepends older messages before existing ones", () => { + // API returns newest-first; store reverses to oldest-first. + setMessages(1, [makeMessageResponse({ id: 20 })], true); + prependMessages( + 1, + [makeMessageResponse({ id: 15 }), makeMessageResponse({ id: 10 })], + false, + ); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(3); + expect(msgs[0]!.id).toBe(10); + expect(msgs[1]!.id).toBe(15); + expect(msgs[2]!.id).toBe(20); + }); + + it("updates hasMore flag", () => { + setMessages(1, [makeMessageResponse({ id: 20 })], true); + expect(messagesStore.getState().hasMore.get(1)).toBe(true); + + prependMessages(1, [makeMessageResponse({ id: 10 })], false); + expect(messagesStore.getState().hasMore.get(1)).toBe(false); + }); + + it("works on a channel with no existing messages", () => { + prependMessages(1, [makeMessageResponse({ id: 5 })], false); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(1); + expect(msgs[0]!.id).toBe(5); + }); + }); + + // 5. editMessage updates content and editedAt + describe("editMessage", () => { + it("updates content and editedAt for the target message", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1, content: "Original" })); + + const editPayload: ChatEditedPayload = { + message_id: 100, + channel_id: 1, + content: "Edited content", + edited_at: "2026-03-15T12:00:00Z", + }; + editMessage(editPayload); + + const msg = getChannelMessages(1)[0]!; + expect(msg.content).toBe("Edited content"); + expect(msg.editedAt).toBe("2026-03-15T12:00:00Z"); + }); + + it("does not affect other messages in the channel", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1, content: "First" })); + addMessage(makeChatPayload({ id: 101, channel_id: 1, content: "Second" })); + + editMessage({ + message_id: 100, + channel_id: 1, + content: "Edited", + edited_at: "2026-03-15T12:00:00Z", + }); + + const msgs = getChannelMessages(1); + expect(msgs[0]!.content).toBe("Edited"); + expect(msgs[1]!.content).toBe("Second"); + }); + + it("is a no-op if the channel does not exist", () => { + const before = messagesStore.getState(); + editMessage({ + message_id: 999, + channel_id: 99, + content: "Nope", + edited_at: "2026-03-15T12:00:00Z", + }); + const after = messagesStore.getState(); + expect(before).toBe(after); + }); + + it("produces a new message object (immutable update)", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + const original = getChannelMessages(1)[0]!; + + editMessage({ + message_id: 100, + channel_id: 1, + content: "Edited", + edited_at: "2026-03-15T12:00:00Z", + }); + const edited = getChannelMessages(1)[0]!; + + expect(original).not.toBe(edited); + }); + }); + + // 6. deleteMessage marks as deleted + describe("deleteMessage", () => { + it("marks the message as deleted", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + + const deletePayload: ChatDeletedPayload = { + message_id: 100, + channel_id: 1, + }; + deleteMessage(deletePayload); + + const msg = getChannelMessages(1)[0]!; + expect(msg.deleted).toBe(true); + }); + + it("keeps the message in the array (soft delete)", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + addMessage(makeChatPayload({ id: 101, channel_id: 1 })); + + deleteMessage({ message_id: 100, channel_id: 1 }); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(2); + expect(msgs[0]!.deleted).toBe(true); + expect(msgs[1]!.deleted).toBe(false); + }); + + it("is a no-op if the channel does not exist", () => { + const before = messagesStore.getState(); + deleteMessage({ message_id: 999, channel_id: 99 }); + const after = messagesStore.getState(); + expect(before).toBe(after); + }); + }); + + // 7. addPendingSend / confirmSend lifecycle + describe("pending send lifecycle", () => { + it("addPendingSend tracks correlationId -> channelId", () => { + addPendingSend("corr-1", 1); + + const pending = messagesStore.getState().pendingSends; + expect(pending.get("corr-1")).toBe(1); + }); + + it("confirmSend removes the pending entry", () => { + addPendingSend("corr-1", 1); + confirmSend("corr-1", 100, "2026-03-15T10:00:00Z"); + + const pending = messagesStore.getState().pendingSends; + expect(pending.has("corr-1")).toBe(false); + }); + + it("tracks multiple pending sends independently", () => { + addPendingSend("corr-1", 1); + addPendingSend("corr-2", 2); + + expect(messagesStore.getState().pendingSends.size).toBe(2); + + confirmSend("corr-1", 100, "2026-03-15T10:00:00Z"); + + const pending = messagesStore.getState().pendingSends; + expect(pending.size).toBe(1); + expect(pending.has("corr-1")).toBe(false); + expect(pending.get("corr-2")).toBe(2); + }); + + it("confirmSend is a no-op for unknown correlationId", () => { + const before = messagesStore.getState(); + confirmSend("unknown", 100, "2026-03-15T10:00:00Z"); + const after = messagesStore.getState(); + // State still changes (new Map created), but pending size is 0 + expect(after.pendingSends.size).toBe(0); + }); + }); + + // 8. getChannelMessages returns empty for unknown channel + describe("getChannelMessages", () => { + it("returns empty array for a channel with no messages", () => { + const msgs = getChannelMessages(999); + expect(msgs).toEqual([]); + expect(msgs).toHaveLength(0); + }); + + it("returns the messages after addMessage", () => { + addMessage(makeChatPayload({ id: 1, channel_id: 1 })); + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(1); + }); + }); + + // 9. clearChannelMessages clears + describe("clearChannelMessages", () => { + it("removes messages for the channel", () => { + setMessages(1, [makeMessageResponse({ id: 10 })], true); + expect(getChannelMessages(1)).toHaveLength(1); + + clearChannelMessages(1); + expect(getChannelMessages(1)).toHaveLength(0); + }); + + it("removes loaded status for the channel", () => { + setMessages(1, [], false); + expect(isChannelLoaded(1)).toBe(true); + + clearChannelMessages(1); + expect(isChannelLoaded(1)).toBe(false); + }); + + it("removes hasMore for the channel", () => { + setMessages(1, [], true); + expect(messagesStore.getState().hasMore.get(1)).toBe(true); + + clearChannelMessages(1); + expect(messagesStore.getState().hasMore.has(1)).toBe(false); + }); + + it("does not affect other channels", () => { + setMessages(1, [makeMessageResponse({ id: 10 })], false); + setMessages(2, [makeMessageResponse({ id: 20, channel_id: 2 })], false); + + clearChannelMessages(1); + + expect(getChannelMessages(1)).toHaveLength(0); + expect(getChannelMessages(2)).toHaveLength(1); + expect(isChannelLoaded(2)).toBe(true); + }); + + it("is safe to call on a channel that was never loaded", () => { + clearChannelMessages(999); + expect(getChannelMessages(999)).toHaveLength(0); + }); + }); + + // 10. isChannelLoaded selector + describe("isChannelLoaded", () => { + it("returns false for unknown channel", () => { + expect(isChannelLoaded(999)).toBe(false); + }); + + it("returns true after setMessages", () => { + setMessages(1, [], false); + expect(isChannelLoaded(1)).toBe(true); + }); + + it("returns false after clearChannelMessages", () => { + setMessages(1, [], false); + clearChannelMessages(1); + expect(isChannelLoaded(1)).toBe(false); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/overlay-managers.test.ts b/Client/tauri-client/tests/unit/overlay-managers.test.ts new file mode 100644 index 00000000..75c9876e --- /dev/null +++ b/Client/tauri-client/tests/unit/overlay-managers.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Mock } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks (vi.hoisted so they're available in vi.mock factories) +// --------------------------------------------------------------------------- + +const { + mockLogError, + mockInviteManagerMount, + mockInviteManagerDestroy, + mockPinnedMessagesMount, + mockPinnedMessagesDestroy, +} = vi.hoisted(() => ({ + mockLogError: vi.fn(), + mockInviteManagerMount: vi.fn(), + mockInviteManagerDestroy: vi.fn(), + mockPinnedMessagesMount: vi.fn(), + mockPinnedMessagesDestroy: vi.fn(), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: mockLogError, + }), +})); + +vi.mock("@components/QuickSwitcher", () => ({ + createQuickSwitcher: vi.fn(() => ({ + mount: vi.fn(), + destroy: vi.fn(), + })), +})); + +vi.mock("@components/InviteManager", () => ({ + createInviteManager: vi.fn(() => ({ + mount: mockInviteManagerMount, + destroy: mockInviteManagerDestroy, + })), +})); + +vi.mock("@components/PinnedMessages", () => ({ + createPinnedMessages: vi.fn(() => ({ + mount: mockPinnedMessagesMount, + destroy: mockPinnedMessagesDestroy, + })), +})); + +vi.mock("@stores/channels.store", () => ({ + setActiveChannel: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import { createInviteManager } from "@components/InviteManager"; +import { createPinnedMessages } from "@components/PinnedMessages"; +import { + createInviteManagerController, + createPinnedPanelController, +} from "@pages/main-page/OverlayManagers"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeInviteResponse(overrides: Record<string, unknown> = {}) { + return { + id: 1, + code: "abc123xyz", + url: "https://example.com/abc123xyz", + max_uses: 10, + use_count: 3, + expires_at: null, + ...overrides, + }; +} + +function makeMockApi(overrides: Record<string, unknown> = {}) { + return { + getInvites: vi.fn().mockResolvedValue([makeInviteResponse()]), + createInvite: vi.fn().mockResolvedValue(makeInviteResponse({ code: "new123" })), + revokeInvite: vi.fn().mockResolvedValue(undefined), + getPins: vi.fn().mockResolvedValue({ + messages: [ + { id: 1, user: { username: "Alice" }, content: "Pinned msg", created_at: "2024-01-01" }, + ], + }), + unpinMessage: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockToast() { + return { show: vi.fn() }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("createInviteManagerController", () => { + let root: HTMLDivElement; + + beforeEach(() => { + root = document.createElement("div"); + document.body.appendChild(root); + vi.clearAllMocks(); + }); + + afterEach(() => { + root.remove(); + }); + + it("opens invite manager and mounts to root", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + + const controller = createInviteManagerController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + }); + + await controller.open(); + + expect(createInviteManager).toHaveBeenCalledOnce(); + expect(mockInviteManagerMount).toHaveBeenCalledWith(root); + }); + + it("onRevokeInvite catches API error and re-throws for component handling", async () => { + const api = makeMockApi({ + revokeInvite: vi.fn().mockRejectedValue(new Error("network error")), + }); + const toast = makeMockToast(); + + const controller = createInviteManagerController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + }); + + await controller.open(); + + // Extract the onRevokeInvite callback passed to InviteManager + const opts = (createInviteManager as Mock).mock.calls[0]![0] as { + onRevokeInvite: (code: string) => Promise<void>; + }; + + // The callback should re-throw so InviteManager's catch prevents optimistic removal + await expect(opts.onRevokeInvite("abc123xyz")).rejects.toThrow("network error"); + + // Controller should log the error with context + expect(mockLogError).toHaveBeenCalled(); + }); + + it("onRevokeInvite succeeds normally when API works", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + + const controller = createInviteManagerController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + }); + + await controller.open(); + + const opts = (createInviteManager as Mock).mock.calls[0]![0] as { + onRevokeInvite: (code: string) => Promise<void>; + }; + + await expect(opts.onRevokeInvite("abc123xyz")).resolves.toBeUndefined(); + expect(mockLogError).not.toHaveBeenCalled(); + }); + + it("shows toast when open fails to load invites", async () => { + const api = makeMockApi({ + getInvites: vi.fn().mockRejectedValue(new Error("load failed")), + }); + const toast = makeMockToast(); + + const controller = createInviteManagerController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + }); + + await controller.open(); + + expect(toast.show).toHaveBeenCalledWith("Failed to load invites", "error"); + }); +}); + +describe("createPinnedPanelController", () => { + let root: HTMLDivElement; + + beforeEach(() => { + root = document.createElement("div"); + document.body.appendChild(root); + vi.clearAllMocks(); + }); + + afterEach(() => { + root.remove(); + }); + + it("toggles pinned panel open and mounts to root", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + }); + + await controller.toggle(); + + expect(createPinnedMessages).toHaveBeenCalledOnce(); + expect(mockPinnedMessagesMount).toHaveBeenCalledWith(root); + }); + + it("onUnpin catches API error, shows toast, and does NOT close the panel", async () => { + const api = makeMockApi({ + unpinMessage: vi.fn().mockRejectedValue(new Error("unpin failed")), + }); + const toast = makeMockToast(); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + }); + + await controller.toggle(); + + // Extract onUnpin callback passed to PinnedMessages + const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as { + onUnpin: (msgId: number) => void; + }; + + // Call onUnpin — it should handle the error internally + opts.onUnpin(1); + + // Wait for the async error handling to complete + await vi.waitFor(() => { + expect(toast.show).toHaveBeenCalledWith("Failed to unpin message", "error"); + }); + + // Panel should NOT have been destroyed (still open) + expect(mockPinnedMessagesDestroy).not.toHaveBeenCalled(); + }); + + it("onUnpin closes panel on success", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + }); + + await controller.toggle(); + + const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as { + onUnpin: (msgId: number) => void; + }; + + opts.onUnpin(1); + + // Wait for the async success handling to complete + await vi.waitFor(() => { + expect(mockPinnedMessagesDestroy).toHaveBeenCalled(); + }); + + // No error toast should be shown + expect(toast.show).not.toHaveBeenCalled(); + }); + + it("onJumpToMessage calls provided scroll callback and closes panel", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + const mockScrollToMessage = vi.fn().mockReturnValue(true); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + onJumpToMessage: mockScrollToMessage, + }); + + await controller.toggle(); + + const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as { + onJumpToMessage: (msgId: number) => void; + }; + + opts.onJumpToMessage(1); + + expect(mockScrollToMessage).toHaveBeenCalledWith(1); + expect(mockPinnedMessagesDestroy).toHaveBeenCalled(); + }); + + it("onJumpToMessage shows toast when message not in loaded window", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + const mockScrollToMessage = vi.fn().mockReturnValue(false); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + onJumpToMessage: mockScrollToMessage, + }); + + await controller.toggle(); + + const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as { + onJumpToMessage: (msgId: number) => void; + }; + + opts.onJumpToMessage(999); + + expect(mockScrollToMessage).toHaveBeenCalledWith(999); + expect(toast.show).toHaveBeenCalledWith( + expect.stringContaining("not in"), + "info", + ); + // Panel should NOT close when message not found + expect(mockPinnedMessagesDestroy).not.toHaveBeenCalled(); + }); + + it("shows toast when toggle fails to load pins", async () => { + const api = makeMockApi({ + getPins: vi.fn().mockRejectedValue(new Error("load failed")), + }); + const toast = makeMockToast(); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + }); + + await controller.toggle(); + + expect(toast.show).toHaveBeenCalledWith("Failed to load pinned messages", "error"); + }); +}); diff --git a/Client/tauri-client/tests/unit/permissions.test.ts b/Client/tauri-client/tests/unit/permissions.test.ts new file mode 100644 index 00000000..80c3c49e --- /dev/null +++ b/Client/tauri-client/tests/unit/permissions.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest'; +import { + hasPermission, + hasAnyPermission, + hasAllPermissions, + computeEffective, + isAdministrator, +} from '../../src/lib/permissions'; +import { Permission } from '../../src/lib/types'; + +// Default role permission values (from SCHEMA.md) +const OWNER_PERMS = 0x7FFFFFFF; +const ADMIN_PERMS = 0x3FFFFFFF; // admin has bits 0-29 but NOT ADMINISTRATOR (bit 30) +const MODERATOR_PERMS = 0x000FFFFF; +const MEMBER_PERMS = 0x00000663; + +describe('hasPermission', () => { + it('member can SEND_MESSAGES', () => { + expect(hasPermission(MEMBER_PERMS, Permission.SEND_MESSAGES)).toBe(true); + }); + + it('member cannot MANAGE_MESSAGES', () => { + expect(hasPermission(MEMBER_PERMS, Permission.MANAGE_MESSAGES)).toBe(false); + }); + + it('ADMINISTRATOR bypass — admin with ADMINISTRATOR can do anything', () => { + const permsWithAdmin = Permission.ADMINISTRATOR; + expect(hasPermission(permsWithAdmin, Permission.MANAGE_MESSAGES)).toBe(true); + expect(hasPermission(permsWithAdmin, Permission.BAN_MEMBERS)).toBe(true); + }); + + it('owner has all permissions', () => { + expect(hasPermission(OWNER_PERMS, Permission.SEND_MESSAGES)).toBe(true); + expect(hasPermission(OWNER_PERMS, Permission.MANAGE_SERVER)).toBe(true); + expect(hasPermission(OWNER_PERMS, Permission.VIEW_AUDIT_LOG)).toBe(true); + expect(hasPermission(OWNER_PERMS, Permission.ADMINISTRATOR)).toBe(true); + }); +}); + +describe('hasAnyPermission', () => { + it('returns true if any match', () => { + expect( + hasAnyPermission( + MEMBER_PERMS, + Permission.SEND_MESSAGES, + Permission.MANAGE_MESSAGES, + ), + ).toBe(true); + }); + + it('returns false if none match', () => { + expect( + hasAnyPermission( + MEMBER_PERMS, + Permission.MANAGE_MESSAGES, + Permission.BAN_MEMBERS, + ), + ).toBe(false); + }); +}); + +describe('hasAllPermissions', () => { + it('returns true when all match', () => { + expect( + hasAllPermissions( + MEMBER_PERMS, + Permission.SEND_MESSAGES, + Permission.READ_MESSAGES, + ), + ).toBe(true); + }); + + it('returns false when one missing', () => { + expect( + hasAllPermissions( + MEMBER_PERMS, + Permission.SEND_MESSAGES, + Permission.MANAGE_MESSAGES, + ), + ).toBe(false); + }); +}); + +describe('computeEffective', () => { + it('deny overrides allow', () => { + const base = MEMBER_PERMS; + const allow = Permission.MANAGE_MESSAGES; + const deny = Permission.MANAGE_MESSAGES; + const effective = computeEffective(base, allow, deny); + expect(effective & Permission.MANAGE_MESSAGES).toBe(0); + }); + + it('ADMINISTRATOR ignores deny and returns all bits', () => { + // Must use a perm set that actually includes bit 30 (ADMINISTRATOR) + const base = OWNER_PERMS; // 0x7FFFFFFF includes ADMINISTRATOR + const deny = Permission.SEND_MESSAGES | Permission.MANAGE_SERVER; + const effective = computeEffective(base, 0, deny); + expect(effective).toBe(0x7FFFFFFF); + }); + + it('non-ADMINISTRATOR admin is affected by deny', () => { + // ADMIN_PERMS (0x3FFFFFFF) does NOT have ADMINISTRATOR bit + const deny = Permission.SEND_MESSAGES; + const effective = computeEffective(ADMIN_PERMS, 0, deny); + expect(effective & Permission.SEND_MESSAGES).toBe(0); + }); + + it('allow adds bits to base', () => { + const base = MEMBER_PERMS; + const allow = Permission.MANAGE_MESSAGES; + const effective = computeEffective(base, allow, 0); + expect(effective & Permission.MANAGE_MESSAGES).toBe(Permission.MANAGE_MESSAGES); + // original bits are preserved + expect(effective & Permission.SEND_MESSAGES).toBe(Permission.SEND_MESSAGES); + }); +}); + +describe('isAdministrator', () => { + it('true for owner with ADMINISTRATOR bit', () => { + expect(isAdministrator(OWNER_PERMS)).toBe(true); + }); + + it('false for admin without ADMINISTRATOR bit', () => { + // ADMIN_PERMS (0x3FFFFFFF) has bits 0-29 but NOT bit 30 + expect(isAdministrator(ADMIN_PERMS)).toBe(false); + }); + + it('false for member', () => { + expect(isAdministrator(MEMBER_PERMS)).toBe(false); + }); +}); diff --git a/Client/tauri-client/tests/unit/pinned-messages.test.ts b/Client/tauri-client/tests/unit/pinned-messages.test.ts new file mode 100644 index 00000000..7ac9a940 --- /dev/null +++ b/Client/tauri-client/tests/unit/pinned-messages.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createPinnedMessages } from "@components/PinnedMessages"; +import type { PinnedMessage, PinnedMessagesOptions } from "@components/PinnedMessages"; + +const samplePins: PinnedMessage[] = [ + { id: 1, content: "Hello world", author: "Alice", timestamp: "2024-01-01 12:00" }, + { id: 2, content: "Important notice", author: "Bob", timestamp: "2024-01-02 14:30" }, + { id: 3, content: "Reminder", author: "Charlie", timestamp: "2024-01-03 09:00" }, +]; + +describe("PinnedMessages", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function makePanel(overrides?: Partial<PinnedMessagesOptions>) { + const options: PinnedMessagesOptions = { + channelId: 1, + pinnedMessages: overrides?.pinnedMessages ?? samplePins, + onUnpin: overrides?.onUnpin ?? vi.fn(), + onJumpToMessage: overrides?.onJumpToMessage ?? vi.fn(), + onClose: overrides?.onClose ?? vi.fn(), + }; + const panel = createPinnedMessages(options); + panel.mount(container); + return { panel, options }; + } + + it("mounts with pinned-panel class", () => { + const { panel } = makePanel(); + expect(container.querySelector(".pinned-panel")).not.toBeNull(); + panel.destroy?.(); + }); + + it("renders header with title", () => { + const { panel } = makePanel(); + const title = container.querySelector("h3"); + expect(title).not.toBeNull(); + expect(title!.textContent).toBe("Pinned Messages"); + panel.destroy?.(); + }); + + it("renders close button", () => { + const onClose = vi.fn(); + const { panel } = makePanel({ onClose }); + const closeBtn = container.querySelector(".pinned-panel__close") as HTMLButtonElement; + expect(closeBtn).not.toBeNull(); + closeBtn.click(); + expect(onClose).toHaveBeenCalledOnce(); + panel.destroy?.(); + }); + + it("renders pinned message items", () => { + const { panel } = makePanel(); + const items = container.querySelectorAll(".pinned-msg"); + expect(items.length).toBe(3); + panel.destroy?.(); + }); + + it("shows author, content, and timestamp for each pin", () => { + const { panel } = makePanel(); + const authors = container.querySelectorAll(".pinned-msg__author"); + const contents = container.querySelectorAll(".pinned-msg__content"); + const times = container.querySelectorAll(".pinned-msg__time"); + + expect(authors[0]!.textContent).toBe("Alice"); + expect(contents[0]!.textContent).toBe("Hello world"); + expect(times[0]!.textContent).toBe("2024-01-01 12:00"); + panel.destroy?.(); + }); + + it("Jump button calls onJumpToMessage with message id", () => { + const onJumpToMessage = vi.fn(); + const { panel } = makePanel({ onJumpToMessage }); + + const jumpBtns = container.querySelectorAll(".pinned-msg__actions button"); + // Jump is the first button in each action group + (jumpBtns[0] as HTMLButtonElement).click(); + expect(onJumpToMessage).toHaveBeenCalledWith(1); + panel.destroy?.(); + }); + + it("Unpin button calls onUnpin with message id", () => { + const onUnpin = vi.fn(); + const { panel } = makePanel({ onUnpin }); + + const unpinBtns = container.querySelectorAll(".pinned-msg__actions button"); + // Unpin is the second button in each action group + (unpinBtns[1] as HTMLButtonElement).click(); + expect(onUnpin).toHaveBeenCalledWith(1); + panel.destroy?.(); + }); + + it("empty pinned messages shows empty state", () => { + const { panel } = makePanel({ pinnedMessages: [] }); + + const items = container.querySelectorAll(".pinned-msg"); + expect(items.length).toBe(0); + + const empty = container.querySelector(".pinned-panel__empty") as HTMLDivElement; + expect(empty).not.toBeNull(); + expect(empty.textContent).toBe("No pinned messages"); + // Empty div should be visible (display not "none") + expect(empty.style.display).not.toBe("none"); + + // List should be hidden + const list = container.querySelector(".pinned-panel__list") as HTMLDivElement; + expect(list.style.display).toBe("none"); + panel.destroy?.(); + }); + + it("with pinned messages, empty state is hidden", () => { + const { panel } = makePanel(); + + const empty = container.querySelector(".pinned-panel__empty") as HTMLDivElement; + expect(empty.style.display).toBe("none"); + + const list = container.querySelector(".pinned-panel__list") as HTMLDivElement; + expect(list.style.display).not.toBe("none"); + panel.destroy?.(); + }); + + it("stores message id in dataset", () => { + const { panel } = makePanel(); + const items = container.querySelectorAll(".pinned-msg"); + expect((items[0] as HTMLDivElement).dataset.messageId).toBe("1"); + expect((items[1] as HTMLDivElement).dataset.messageId).toBe("2"); + panel.destroy?.(); + }); + + it("destroy removes DOM", () => { + const { panel } = makePanel(); + expect(container.querySelector(".pinned-panel")).not.toBeNull(); + panel.destroy?.(); + expect(container.querySelector(".pinned-panel")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/profiles.test.ts b/Client/tauri-client/tests/unit/profiles.test.ts new file mode 100644 index 00000000..fa79fb83 --- /dev/null +++ b/Client/tauri-client/tests/unit/profiles.test.ts @@ -0,0 +1,557 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { + createProfileManager, + type PersistenceBackend, + type CreateProfileData, + type ServerProfile, + type FetchFn, + type HealthStatus, + type ProfilesState, +} from "@lib/profiles"; + +// --------------------------------------------------------------------------- +// Deterministic UUID stub +// --------------------------------------------------------------------------- + +let uuidCounter = 0; + +function nextUuid(): string { + uuidCounter++; + return `00000000-0000-0000-0000-${String(uuidCounter).padStart(12, "0")}`; +} + +// --------------------------------------------------------------------------- +// Mock persistence backend +// --------------------------------------------------------------------------- + +function createMockBackend(): PersistenceBackend & { + saved: Array<{ schemaVersion: number; profiles: readonly ServerProfile[] }>; +} { + let stored: { schemaVersion: number; profiles: readonly ServerProfile[] } | null = + null; + const saved: Array<{ schemaVersion: number; profiles: readonly ServerProfile[] }> = + []; + + return { + saved, + async load() { + return stored; + }, + async save(data) { + stored = data; + saved.push(data); + }, + }; +} + +// --------------------------------------------------------------------------- +// Mock fetch +// --------------------------------------------------------------------------- + +function createMockFetch( + handler: (url: string, init?: RequestInit) => Promise<Response>, +): FetchFn { + return handler as unknown as FetchFn; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const sampleData: CreateProfileData = { + name: "Dev Server", + host: "localhost:8443", + username: "alice", + color: "#ff5500", + autoConnect: false, + rememberPassword: false, +}; + +const sampleData2: CreateProfileData = { + name: "Prod Server", + host: "prod.example.com:443", + username: "bob", + color: "#00aaff", + autoConnect: true, + rememberPassword: false, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ProfileManager", () => { + let backend: ReturnType<typeof createMockBackend>; + let mockFetch: Mock; + + beforeEach(() => { + backend = createMockBackend(); + uuidCounter = 0; + vi.stubGlobal("crypto", { + randomUUID: vi.fn(() => nextUuid()), + }); + mockFetch = vi.fn(); + }); + + function mgr(fetchFn?: FetchFn) { + return createProfileManager(backend, fetchFn ?? (mockFetch as unknown as FetchFn)); + } + + // ── CRUD ───────────────────────────────────────────────── + + describe("CRUD operations", () => { + it("starts with an empty profile list", () => { + const m = mgr(); + expect(m.getAll()).toEqual([]); + }); + + it("adds a profile with a generated UUID", () => { + const m = mgr(); + const profile = m.addProfile(sampleData); + + expect(profile.id).toBe("00000000-0000-0000-0000-000000000001"); + expect(profile.name).toBe("Dev Server"); + expect(profile.host).toBe("localhost:8443"); + expect(profile.username).toBe("alice"); + expect(profile.color).toBe("#ff5500"); + expect(profile.autoConnect).toBe(false); + expect(profile.lastConnected).toBeNull(); + expect(m.getAll()).toHaveLength(1); + }); + + it("retrieves a profile by id", () => { + const m = mgr(); + const created = m.addProfile(sampleData); + + expect(m.getById(created.id)).toEqual(created); + expect(m.getById("nonexistent")).toBeNull(); + }); + + it("updates a profile immutably", () => { + const m = mgr(); + const original = m.addProfile(sampleData); + + const updated = m.updateProfile(original.id, { name: "Renamed" }); + + expect(updated).not.toBeNull(); + expect(updated!.name).toBe("Renamed"); + expect(updated!.host).toBe(original.host); + // Original object not mutated + expect(original.name).toBe("Dev Server"); + // Store has the updated version + expect(m.getById(original.id)!.name).toBe("Renamed"); + }); + + it("returns null when updating a nonexistent profile", () => { + const m = mgr(); + expect(m.updateProfile("missing", { name: "X" })).toBeNull(); + }); + + it("removes an existing profile", () => { + const m = mgr(); + const profile = m.addProfile(sampleData); + + expect(m.removeProfile(profile.id)).toBe(true); + expect(m.getAll()).toHaveLength(0); + expect(m.getById(profile.id)).toBeNull(); + }); + + it("returns false when removing a nonexistent profile", () => { + const m = mgr(); + expect(m.removeProfile("missing")).toBe(false); + }); + + it("sets lastConnected to current ISO timestamp", () => { + const m = mgr(); + const profile = m.addProfile(sampleData); + + const before = new Date().toISOString(); + m.setLastConnected(profile.id); + const after = new Date().toISOString(); + + const updated = m.getById(profile.id)!; + expect(updated.lastConnected).not.toBeNull(); + expect(updated.lastConnected! >= before).toBe(true); + expect(updated.lastConnected! <= after).toBe(true); + // Original not mutated + expect(profile.lastConnected).toBeNull(); + }); + + it("does nothing when setting lastConnected on nonexistent profile", () => { + const m = mgr(); + // Should not throw + m.setLastConnected("missing"); + }); + }); + + // ── Auto-connect ───────────────────────────────────────── + + describe("auto-connect", () => { + it("returns the first auto-connect profile", () => { + const m = mgr(); + m.addProfile(sampleData); // autoConnect: false + const autoProfile = m.addProfile(sampleData2); // autoConnect: true + + expect(m.getAutoConnectProfile()).toEqual(autoProfile); + }); + + it("returns null when no profiles have autoConnect", () => { + const m = mgr(); + m.addProfile(sampleData); + expect(m.getAutoConnectProfile()).toBeNull(); + }); + + it("returns null when no profiles exist", () => { + const m = mgr(); + expect(m.getAutoConnectProfile()).toBeNull(); + }); + }); + + // ── Health check ───────────────────────────────────────── + + describe("health checks", () => { + it("returns online status for a healthy server", async () => { + const fetchFn = createMockFetch(async () => + jsonResponse({ version: "1.2.3" }), + ); + const m = mgr(fetchFn); + const profile = m.addProfile(sampleData); + + const result = await m.checkHealth(profile.id); + + expect(result.status).toBe("online"); + expect(result.version).toBe("1.2.3"); + expect(typeof result.latencyMs).toBe("number"); + }); + + it("sets status to checking before resolving", async () => { + const states: Array<HealthStatus | undefined> = []; + let resolveReq!: () => void; + const pending = new Promise<void>((r) => { + resolveReq = r; + }); + + const fetchFn = createMockFetch(async () => { + await pending; + return jsonResponse({ version: "1.0.0" }); + }); + const m = mgr(fetchFn); + const profile = m.addProfile(sampleData); + + // Subscribe to capture the "checking" state + m.store.subscribe((state: ProfilesState) => { + states.push(state.healthStatuses.get(profile.id)); + }); + + const healthPromise = m.checkHealth(profile.id); + + // At this point, state should have been set to "checking" + const checkingState = m.store.getState().healthStatuses.get(profile.id); + expect(checkingState?.status).toBe("checking"); + + resolveReq(); + await healthPromise; + + const finalState = m.store.getState().healthStatuses.get(profile.id); + expect(finalState?.status).toBe("online"); + }); + + it("returns offline when fetch throws", async () => { + const fetchFn = createMockFetch(async () => { + throw new Error("network error"); + }); + const m = mgr(fetchFn); + const profile = m.addProfile(sampleData); + + const result = await m.checkHealth(profile.id); + + expect(result.status).toBe("offline"); + expect(result.latencyMs).toBeNull(); + expect(result.version).toBeNull(); + }); + + it("returns offline for non-OK response", async () => { + const fetchFn = createMockFetch(async () => + jsonResponse({ error: "bad" }, 500), + ); + const m = mgr(fetchFn); + const profile = m.addProfile(sampleData); + + const result = await m.checkHealth(profile.id); + + expect(result.status).toBe("offline"); + expect(typeof result.latencyMs).toBe("number"); + }); + + it("returns offline for nonexistent profile", async () => { + const m = mgr(); + const result = await m.checkHealth("nonexistent"); + expect(result.status).toBe("offline"); + }); + + it("pings the correct URL with /api/v1/health", async () => { + let capturedUrl = ""; + const fetchFn = createMockFetch(async (url) => { + capturedUrl = url; + return jsonResponse({ version: "1.0.0" }); + }); + const m = mgr(fetchFn); + const profile = m.addProfile(sampleData); + + await m.checkHealth(profile.id); + + expect(capturedUrl).toBe("https://localhost:8443/api/v1/health"); + }); + + it("uses AbortController signal in fetch call", async () => { + let capturedSignal: AbortSignal | undefined; + const fetchFn = createMockFetch(async (_url, init) => { + capturedSignal = init?.signal ?? undefined; + return jsonResponse({ version: "1.0.0" }); + }); + const m = mgr(fetchFn); + const profile = m.addProfile(sampleData); + + await m.checkHealth(profile.id); + + expect(capturedSignal).toBeInstanceOf(AbortSignal); + }); + + it("checkAllHealth pings all profiles in parallel", async () => { + const pingedHosts: string[] = []; + const fetchFn = createMockFetch(async (url) => { + pingedHosts.push(url); + return jsonResponse({ version: "2.0.0" }); + }); + const m = mgr(fetchFn); + const p1 = m.addProfile(sampleData); + const p2 = m.addProfile(sampleData2); + + const results = await m.checkAllHealth(); + + expect(results.size).toBe(2); + expect(results.get(p1.id)?.status).toBe("online"); + expect(results.get(p2.id)?.status).toBe("online"); + expect(pingedHosts).toHaveLength(2); + expect(pingedHosts).toContain("https://localhost:8443/api/v1/health"); + expect(pingedHosts).toContain( + "https://prod.example.com:443/api/v1/health", + ); + }); + + it("checkAllHealth returns empty map when no profiles", async () => { + const m = mgr(); + const results = await m.checkAllHealth(); + expect(results.size).toBe(0); + }); + }); + + // ── Export / Import ────────────────────────────────────── + + describe("export and import", () => { + it("round-trips profiles through export and import", () => { + const m1 = mgr(); + m1.addProfile(sampleData); + m1.addProfile(sampleData2); + + const exported = m1.exportProfiles(); + + const backend2 = createMockBackend(); + const m2 = createProfileManager( + backend2, + mockFetch as unknown as FetchFn, + ); + const result = m2.importProfiles(exported); + + expect(result.imported).toBe(2); + expect(result.skipped).toBe(0); + expect(m2.getAll()).toHaveLength(2); + + const hosts = m2.getAll().map((p) => p.host); + expect(hosts).toContain("localhost:8443"); + expect(hosts).toContain("prod.example.com:443"); + }); + + it("skips duplicate hosts during import", () => { + const m = mgr(); + m.addProfile(sampleData); + + const incoming: ServerProfile[] = [ + { + id: "ext-1", + name: "Duplicate", + host: "localhost:8443", + username: "charlie", + color: "#000000", + autoConnect: false, + rememberPassword: false, + lastConnected: null, + }, + { + id: "ext-2", + name: "New Server", + host: "new.example.com:443", + username: "dave", + color: "#ffffff", + autoConnect: false, + rememberPassword: false, + lastConnected: null, + }, + ]; + + const result = m.importProfiles(JSON.stringify(incoming)); + + expect(result.imported).toBe(1); + expect(result.skipped).toBe(1); + expect(m.getAll()).toHaveLength(2); + }); + + it("handles invalid JSON gracefully", () => { + const m = mgr(); + const result = m.importProfiles("not json"); + expect(result).toEqual({ imported: 0, skipped: 0 }); + }); + + it("handles non-array, non-envelope JSON gracefully", () => { + const m = mgr(); + const result = m.importProfiles(JSON.stringify({ foo: "bar" })); + expect(result).toEqual({ imported: 0, skipped: 0 }); + }); + + it("rejects import entries with invalid shape", () => { + const m = mgr(); + const badEntries = [ + { id: "x", name: "", host: "a", username: "b", color: "#000", autoConnect: false, rememberPassword: false, lastConnected: null }, + { id: "y", name: "Valid", host: "valid.com:443", username: "u", color: "#fff", autoConnect: false, rememberPassword: false, lastConnected: null }, + ]; + const result = m.importProfiles(JSON.stringify(badEntries)); + expect(result.imported).toBe(1); + expect(result.skipped).toBe(1); + }); + + it("exported data includes schema version", () => { + const m = mgr(); + m.addProfile(sampleData); + + const exported = JSON.parse(m.exportProfiles()); + expect(exported.schemaVersion).toBe(1); + expect(Array.isArray(exported.profiles)).toBe(true); + }); + + it("imports new UUIDs rather than keeping originals", () => { + const m1 = mgr(); + const created = m1.addProfile(sampleData); + const exported = m1.exportProfiles(); + + const backend2 = createMockBackend(); + const m2 = createProfileManager( + backend2, + mockFetch as unknown as FetchFn, + ); + m2.importProfiles(exported); + + const imported = m2.getAll(); + expect(imported).toHaveLength(1); + // The imported profile should have a NEW UUID + expect(imported[0]!.id).not.toBe(created.id); + }); + }); + + // ── Persistence ────────────────────────────────────────── + + describe("persistence", () => { + it("loadProfiles populates store from backend", async () => { + // Pre-seed the backend + await backend.save({ + schemaVersion: 1, + profiles: [ + { + id: "persisted-1", + name: "Saved Server", + host: "saved.example.com:443", + username: "eve", + color: "#112233", + autoConnect: false, + rememberPassword: false, + lastConnected: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const m = mgr(); + await m.loadProfiles(); + + expect(m.getAll()).toHaveLength(1); + expect(m.getAll()[0]!.name).toBe("Saved Server"); + }); + + it("saveProfiles writes current state with schema version to backend", async () => { + const m = mgr(); + m.addProfile(sampleData); + + await m.saveProfiles(); + + expect(backend.saved).toHaveLength(1); + expect(backend.saved[0]!.schemaVersion).toBe(1); + expect(backend.saved[0]!.profiles).toHaveLength(1); + expect(backend.saved[0]!.profiles[0]!.name).toBe("Dev Server"); + }); + + it("loadProfiles handles empty backend gracefully", async () => { + const m = mgr(); + await m.loadProfiles(); + expect(m.getAll()).toEqual([]); + }); + }); + + // ── Reactive store ─────────────────────────────────────── + + describe("reactive store", () => { + it("notifies subscribers on profile add", () => { + const m = mgr(); + const states: ProfilesState[] = []; + m.store.subscribe((s) => states.push(s)); + + m.addProfile(sampleData); + m.store.flush(); + + expect(states).toHaveLength(1); + expect(states[0]!.profiles).toHaveLength(1); + }); + + it("notifies subscribers on profile remove", () => { + const m = mgr(); + const profile = m.addProfile(sampleData); + + const states: ProfilesState[] = []; + m.store.subscribe((s) => states.push(s)); + + m.removeProfile(profile.id); + m.store.flush(); + + expect(states).toHaveLength(1); + expect(states[0]!.profiles).toHaveLength(0); + }); + + it("healthStatuses updates are visible via store", async () => { + const fetchFn = createMockFetch(async () => + jsonResponse({ version: "3.0.0" }), + ); + const m = mgr(fetchFn); + const profile = m.addProfile(sampleData); + + await m.checkHealth(profile.id); + + const statuses = m.store.getState().healthStatuses; + expect(statuses.get(profile.id)?.status).toBe("online"); + expect(statuses.get(profile.id)?.version).toBe("3.0.0"); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/quick-switcher.test.ts b/Client/tauri-client/tests/unit/quick-switcher.test.ts new file mode 100644 index 00000000..09c57984 --- /dev/null +++ b/Client/tauri-client/tests/unit/quick-switcher.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createQuickSwitcher } from "@components/QuickSwitcher"; +import type { QuickSwitcherOptions } from "@components/QuickSwitcher"; +import { channelsStore, setChannels } from "@stores/channels.store"; +import type { ReadyChannel } from "../../src/lib/types"; + +function resetStore(): void { + channelsStore.setState(() => ({ + channels: new Map(), + activeChannelId: null, + })); +} + +const testChannels: ReadyChannel[] = [ + { id: 1, name: "general", type: "text", category: "Text", position: 0, unread_count: 0 }, + { id: 2, name: "random", type: "text", category: "Text", position: 1, unread_count: 0 }, + { id: 3, name: "voice-lobby", type: "voice", category: "Voice", position: 2 }, + { id: 4, name: "announcements", type: "text", category: null, position: 3, unread_count: 0 }, +]; + +describe("QuickSwitcher", () => { + let container: HTMLDivElement; + let switcher: ReturnType<typeof createQuickSwitcher>; + let onSelectChannel: ReturnType<typeof vi.fn>; + let onClose: ReturnType<typeof vi.fn>; + + beforeEach(() => { + resetStore(); + setChannels(testChannels); + container = document.createElement("div"); + document.body.appendChild(container); + onSelectChannel = vi.fn(); + onClose = vi.fn(); + switcher = createQuickSwitcher({ onSelectChannel, onClose }); + }); + + afterEach(() => { + switcher.destroy?.(); + container.remove(); + }); + + it("mounts with quick-switcher-overlay class", () => { + switcher.mount(container); + const overlay = container.querySelector(".quick-switcher-overlay"); + expect(overlay).not.toBeNull(); + }); + + it("renders search input with placeholder", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.placeholder).toBe("Where do you want to go?"); + }); + + it("renders all channels initially", () => { + switcher.mount(container); + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items.length).toBe(4); + }); + + it("first item is active by default", () => { + switcher.mount(container); + const activeItem = container.querySelector(".quick-switcher__item--active"); + expect(activeItem).not.toBeNull(); + }); + + it("filters channels by search query", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + + input.value = "gen"; + input.dispatchEvent(new Event("input")); + + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items.length).toBe(1); + const name = items[0]!.querySelector(".quick-switcher__name"); + expect(name?.textContent).toBe("general"); + }); + + it("filters channels without calling external search (client-side only)", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + + input.value = "random"; + input.dispatchEvent(new Event("input")); + + // Filtering should work client-side + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items.length).toBe(1); + expect(items[0]!.querySelector(".quick-switcher__name")?.textContent).toBe("random"); + }); + + it("clicking a channel calls onSelectChannel and onClose", () => { + switcher.mount(container); + const firstItem = container.querySelector(".quick-switcher__item") as HTMLDivElement; + firstItem.click(); + + expect(onSelectChannel).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("Escape key calls onClose", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("ArrowDown moves active index", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items[1]!.classList.contains("quick-switcher__item--active")).toBe(true); + expect(items[0]!.classList.contains("quick-switcher__item--active")).toBe(false); + }); + + it("ArrowUp wraps around to last item", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items[3]!.classList.contains("quick-switcher__item--active")).toBe(true); + }); + + it("Enter selects the active channel", () => { + switcher.mount(container); + const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(onSelectChannel).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("shows voice icon for voice channels", () => { + switcher.mount(container); + const icons = container.querySelectorAll(".quick-switcher__icon"); + const iconTexts = Array.from(icons).map((i) => i.textContent); + + // voice-lobby should have speaker icon, text channels should have # + expect(iconTexts).toContain("#"); + expect(iconTexts).toContain("\ud83d\udd0a"); + }); + + it("shows category when present", () => { + switcher.mount(container); + const categories = container.querySelectorAll(".quick-switcher__category"); + const categoryTexts = Array.from(categories).map((c) => c.textContent); + + expect(categoryTexts).toContain("Text"); + expect(categoryTexts).toContain("Voice"); + }); + + it("clicking backdrop calls onClose", () => { + switcher.mount(container); + const overlay = container.querySelector(".quick-switcher-overlay") as HTMLDivElement; + // Simulate clicking on the overlay itself (not a child) + overlay.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("destroy removes DOM", () => { + switcher.mount(container); + expect(container.querySelector(".quick-switcher-overlay")).not.toBeNull(); + switcher.destroy?.(); + expect(container.querySelector(".quick-switcher-overlay")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/rate-limiter.test.ts b/Client/tauri-client/tests/unit/rate-limiter.test.ts new file mode 100644 index 00000000..9eee1bd2 --- /dev/null +++ b/Client/tauri-client/tests/unit/rate-limiter.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + RateLimiter, + createRateLimiter, + createRateLimiterSet, + createTypingLimiter, + createPresenceLimiter, + createReactionLimiter, + createVoiceLimiter, + createSoundboardLimiter, + createChatLimiter, + createVideoCameraLimiter, +} from "@lib/rate-limiter"; + +// --------------------------------------------------------------------------- +// Core RateLimiter behaviour +// --------------------------------------------------------------------------- + +describe("RateLimiter", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // -- Construction --------------------------------------------------------- + + it("throws when maxTokens < 1", () => { + expect(() => new RateLimiter({ maxTokens: 0, windowMs: 1_000 })).toThrow( + "maxTokens must be >= 1", + ); + }); + + it("throws when windowMs < 1", () => { + expect(() => new RateLimiter({ maxTokens: 1, windowMs: 0 })).toThrow( + "windowMs must be >= 1", + ); + }); + + // -- tryConsume ----------------------------------------------------------- + + it("allows requests under the limit", () => { + const limiter = createRateLimiter(3, 1_000); + expect(limiter.tryConsume("a")).toBe(true); + expect(limiter.tryConsume("a")).toBe(true); + expect(limiter.tryConsume("a")).toBe(true); + }); + + it("blocks rapid-fire requests that exceed the limit", () => { + const limiter = createRateLimiter(2, 1_000); + expect(limiter.tryConsume("a")).toBe(true); + expect(limiter.tryConsume("a")).toBe(true); + expect(limiter.tryConsume("a")).toBe(false); + expect(limiter.tryConsume("a")).toBe(false); + }); + + it("uses a default key when key is omitted", () => { + const limiter = createRateLimiter(1, 1_000); + expect(limiter.tryConsume()).toBe(true); + expect(limiter.tryConsume()).toBe(false); + }); + + // -- Per-key isolation ---------------------------------------------------- + + it("isolates different keys", () => { + const limiter = createRateLimiter(1, 1_000); + expect(limiter.tryConsume("key1")).toBe(true); + expect(limiter.tryConsume("key2")).toBe(true); + // Both should be individually exhausted + expect(limiter.tryConsume("key1")).toBe(false); + expect(limiter.tryConsume("key2")).toBe(false); + }); + + // -- Window expiry -------------------------------------------------------- + + it("allows new requests after window expires", () => { + const limiter = createRateLimiter(1, 1_000); + expect(limiter.tryConsume("a")).toBe(true); + expect(limiter.tryConsume("a")).toBe(false); + + vi.advanceTimersByTime(1_001); + + expect(limiter.tryConsume("a")).toBe(true); + }); + + it("sliding window allows staggered requests", () => { + const limiter = createRateLimiter(2, 1_000); + + // t=0: consume first + expect(limiter.tryConsume("a")).toBe(true); + + // t=500: consume second + vi.advanceTimersByTime(500); + expect(limiter.tryConsume("a")).toBe(true); + + // t=500: blocked (2 within window) + expect(limiter.tryConsume("a")).toBe(false); + + // t=1001: first request expired, slot opens + vi.advanceTimersByTime(501); + expect(limiter.tryConsume("a")).toBe(true); + }); + + // -- reset ---------------------------------------------------------------- + + it("reset(key) clears state for a specific key only", () => { + const limiter = createRateLimiter(1, 1_000); + expect(limiter.tryConsume("a")).toBe(true); + expect(limiter.tryConsume("b")).toBe(true); + expect(limiter.tryConsume("a")).toBe(false); + + limiter.reset("a"); + + expect(limiter.tryConsume("a")).toBe(true); + // "b" should still be blocked + expect(limiter.tryConsume("b")).toBe(false); + }); + + it("reset() without key clears the default key only", () => { + const limiter = createRateLimiter(1, 1_000); + expect(limiter.tryConsume()).toBe(true); + expect(limiter.tryConsume()).toBe(false); + + limiter.reset(); + + expect(limiter.tryConsume()).toBe(true); + }); + + // -- resetAll ------------------------------------------------------------- + + it("resetAll() clears all keys", () => { + const limiter = createRateLimiter(1, 1_000); + expect(limiter.tryConsume("a")).toBe(true); + expect(limiter.tryConsume("b")).toBe(true); + expect(limiter.tryConsume("a")).toBe(false); + expect(limiter.tryConsume("b")).toBe(false); + + limiter.resetAll(); + + expect(limiter.tryConsume("a")).toBe(true); + expect(limiter.tryConsume("b")).toBe(true); + }); + + // -- getRemainingMs ------------------------------------------------------- + + it("getRemainingMs returns 0 when under limit", () => { + const limiter = createRateLimiter(5, 1_000); + expect(limiter.getRemainingMs("a")).toBe(0); + }); + + it("getRemainingMs returns positive value when blocked", () => { + const limiter = createRateLimiter(1, 1_000); + limiter.tryConsume("a"); + + const remaining = limiter.getRemainingMs("a"); + expect(remaining).toBeGreaterThan(0); + expect(remaining).toBeLessThanOrEqual(1_000); + }); + + it("getRemainingMs uses default key when omitted", () => { + const limiter = createRateLimiter(1, 1_000); + limiter.tryConsume(); + + expect(limiter.getRemainingMs()).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Factory functions +// --------------------------------------------------------------------------- + +describe("createRateLimiter", () => { + it("creates a limiter with the specified config", () => { + const limiter = createRateLimiter(3, 500); + expect(limiter).toBeInstanceOf(RateLimiter); + // Verify the config by consuming exactly 3 tokens + expect(limiter.tryConsume()).toBe(true); + expect(limiter.tryConsume()).toBe(true); + expect(limiter.tryConsume()).toBe(true); + expect(limiter.tryConsume()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Pre-configured protocol limiters +// --------------------------------------------------------------------------- + +describe("Pre-configured limiters", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("createChatLimiter: 10 per 1s", () => { + const limiter = createChatLimiter(); + for (let i = 0; i < 10; i++) { + expect(limiter.tryConsume("user:1")).toBe(true); + } + expect(limiter.tryConsume("user:1")).toBe(false); + + vi.advanceTimersByTime(1_001); + expect(limiter.tryConsume("user:1")).toBe(true); + }); + + it("createTypingLimiter: 1 per 3s", () => { + const limiter = createTypingLimiter(); + expect(limiter.tryConsume("chan:5")).toBe(true); + expect(limiter.tryConsume("chan:5")).toBe(false); + + // Still blocked just before 3s + vi.advanceTimersByTime(2_999); + expect(limiter.tryConsume("chan:5")).toBe(false); + + // Allowed after 3s + vi.advanceTimersByTime(2); + expect(limiter.tryConsume("chan:5")).toBe(true); + }); + + it("createPresenceLimiter: 1 per 10s", () => { + const limiter = createPresenceLimiter(); + expect(limiter.tryConsume()).toBe(true); + expect(limiter.tryConsume()).toBe(false); + + vi.advanceTimersByTime(10_001); + expect(limiter.tryConsume()).toBe(true); + }); + + it("createReactionLimiter: 5 per 1s", () => { + const limiter = createReactionLimiter(); + for (let i = 0; i < 5; i++) { + expect(limiter.tryConsume()).toBe(true); + } + expect(limiter.tryConsume()).toBe(false); + + vi.advanceTimersByTime(1_001); + expect(limiter.tryConsume()).toBe(true); + }); + + it("createVoiceLimiter: 20 per 1s", () => { + const limiter = createVoiceLimiter(); + for (let i = 0; i < 20; i++) { + expect(limiter.tryConsume()).toBe(true); + } + expect(limiter.tryConsume()).toBe(false); + + vi.advanceTimersByTime(1_001); + expect(limiter.tryConsume()).toBe(true); + }); + + it("createVideoCameraLimiter: 2 per 1s", () => { + const limiter = createVideoCameraLimiter(); + expect(limiter.tryConsume()).toBe(true); + expect(limiter.tryConsume()).toBe(true); + expect(limiter.tryConsume()).toBe(false); + }); + + it("createSoundboardLimiter: 1 per 3s", () => { + const limiter = createSoundboardLimiter(); + expect(limiter.tryConsume()).toBe(true); + expect(limiter.tryConsume()).toBe(false); + + vi.advanceTimersByTime(3_001); + expect(limiter.tryConsume()).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// RateLimiterSet +// --------------------------------------------------------------------------- + +describe("createRateLimiterSet", () => { + it("returns all expected limiter keys", () => { + const set = createRateLimiterSet(); + const expectedKeys = [ + "chat", + "typing", + "presence", + "reactions", + "voice", + "voiceVideo", + "soundboard", + ] as const; + + for (const key of expectedKeys) { + expect(set[key]).toBeInstanceOf(RateLimiter); + } + }); + + it("returns frozen object", () => { + const set = createRateLimiterSet(); + expect(Object.isFrozen(set)).toBe(true); + }); +}); diff --git a/Client/tauri-client/tests/unit/renderers.test.ts b/Client/tauri-client/tests/unit/renderers.test.ts new file mode 100644 index 00000000..78b93a0e --- /dev/null +++ b/Client/tauri-client/tests/unit/renderers.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + formatTime, + formatFullDate, + isSameDay, + shouldGroup, + renderDayDivider, + renderMessage, + renderMentions, + GROUP_THRESHOLD_MS, +} from "../../src/components/message-list/renderers"; +import type { Message } from "../../src/stores/messages.store"; +import { membersStore } from "../../src/stores/members.store"; +import type { MessageListOptions } from "../../src/components/MessageList"; + +function resetStores(): void { + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function makeMessage(overrides: Partial<Message> = {}): Message { + return { + id: 1, + channelId: 1, + user: { id: 10, username: "Alice", avatar: null }, + content: "Hello world", + replyTo: null, + attachments: [], + reactions: [], + editedAt: null, + deleted: false, + timestamp: "2025-01-15T12:30:00Z", + ...overrides, + }; +} + +function makeOpts(overrides: Partial<MessageListOptions> = {}): MessageListOptions { + return { + channelId: 1, + currentUserId: 10, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + ...overrides, + }; +} + +describe("renderers", () => { + let container: HTMLDivElement; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + describe("formatTime", () => { + it("formats ISO timestamp to HH:MM", () => { + const result = formatTime("2025-01-15T09:05:00Z"); + // Result depends on timezone but should be formatted as HH:MM + expect(result).toMatch(/^\d{2}:\d{2}$/); + }); + }); + + describe("formatFullDate", () => { + it("formats ISO timestamp to full date string", () => { + const result = formatFullDate("2025-01-15T12:00:00Z"); + expect(result).toContain("2025"); + expect(result).toContain("January"); + }); + }); + + describe("isSameDay", () => { + it("returns true for timestamps on the same day", () => { + expect(isSameDay("2025-01-15T08:00:00Z", "2025-01-15T20:00:00Z")).toBe(true); + }); + + it("returns false for timestamps on different days", () => { + expect(isSameDay("2025-01-15T08:00:00Z", "2025-01-16T08:00:00Z")).toBe(false); + }); + }); + + describe("shouldGroup", () => { + it("returns true for same user within threshold", () => { + const prev = makeMessage({ timestamp: "2025-01-15T12:00:00Z" }); + const curr = makeMessage({ id: 2, timestamp: "2025-01-15T12:04:00Z" }); + expect(shouldGroup(prev, curr)).toBe(true); + }); + + it("returns false for different users", () => { + const prev = makeMessage({ user: { id: 10, username: "Alice", avatar: null } }); + const curr = makeMessage({ + id: 2, + user: { id: 20, username: "Bob", avatar: null }, + timestamp: "2025-01-15T12:31:00Z", + }); + expect(shouldGroup(prev, curr)).toBe(false); + }); + + it("returns false when time difference exceeds threshold", () => { + const prev = makeMessage({ timestamp: "2025-01-15T12:00:00Z" }); + const curr = makeMessage({ + id: 2, + timestamp: "2025-01-15T12:06:00Z", + }); + expect(shouldGroup(prev, curr)).toBe(false); + }); + + it("returns false when either message is deleted", () => { + const prev = makeMessage({ deleted: true }); + const curr = makeMessage({ id: 2, timestamp: "2025-01-15T12:31:00Z" }); + expect(shouldGroup(prev, curr)).toBe(false); + }); + }); + + describe("renderDayDivider", () => { + it("creates a day divider element with formatted date", () => { + const divider = renderDayDivider("2025-01-15T12:00:00Z"); + container.appendChild(divider); + + expect(divider.classList.contains("msg-day-divider")).toBe(true); + const dateEl = divider.querySelector(".date"); + expect(dateEl).not.toBeNull(); + expect(dateEl!.textContent).toContain("January"); + expect(dateEl!.textContent).toContain("2025"); + }); + + it("includes line elements", () => { + const divider = renderDayDivider("2025-01-15T12:00:00Z"); + const lines = divider.querySelectorAll(".line"); + expect(lines.length).toBe(2); + }); + }); + + describe("renderMentions", () => { + it("wraps @mentions in span with mention class", () => { + const fragment = renderMentions("Hello @alice how are you?"); + container.appendChild(fragment); + + const mention = container.querySelector(".mention"); + expect(mention).not.toBeNull(); + expect(mention!.textContent).toBe("@alice"); + }); + + it("renders plain text without mentions", () => { + const fragment = renderMentions("Hello world"); + container.appendChild(fragment); + + expect(container.querySelector(".mention")).toBeNull(); + expect(container.textContent).toBe("Hello world"); + }); + + it("handles multiple mentions", () => { + const fragment = renderMentions("@alice and @bob"); + container.appendChild(fragment); + + const mentions = container.querySelectorAll(".mention"); + expect(mentions.length).toBe(2); + }); + }); + + describe("renderMessage", () => { + it("renders a basic message with author and content", () => { + const msg = makeMessage(); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + expect(el.getAttribute("data-testid")).toBe("message-1"); + expect(container.querySelector(".msg-author")?.textContent).toBe("Alice"); + expect(container.querySelector(".msg-text")?.textContent).toBe("Hello world"); + + ac.abort(); + }); + + it("renders grouped messages with grouped class", () => { + const msg = makeMessage(); + const ac = new AbortController(); + const el = renderMessage(msg, true, [msg], makeOpts(), ac.signal); + + expect(el.classList.contains("grouped")).toBe(true); + + ac.abort(); + }); + + it("renders deleted message with italic text", () => { + const msg = makeMessage({ deleted: true }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const text = container.querySelector(".msg-text"); + expect(text?.textContent).toBe("[message deleted]"); + expect((text as HTMLElement)?.style.fontStyle).toBe("italic"); + + ac.abort(); + }); + + it("shows (edited) tag for edited messages", () => { + const msg = makeMessage({ editedAt: "2025-01-15T13:00:00Z" }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const edited = container.querySelector(".msg-edited"); + expect(edited).not.toBeNull(); + expect(edited!.textContent).toBe("(edited)"); + + ac.abort(); + }); + + it("renders system messages differently", () => { + const msg = makeMessage({ + user: { id: 0, username: "System", avatar: null }, + content: "Alice joined the server", + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + expect(container.querySelector(".system-msg")).not.toBeNull(); + + ac.abort(); + }); + + it("renders reply reference when replyTo is set", () => { + const original = makeMessage({ id: 1, content: "Original message" }); + const reply = makeMessage({ id: 2, replyTo: 1, content: "This is a reply" }); + const ac = new AbortController(); + const el = renderMessage(reply, false, [original, reply], makeOpts(), ac.signal); + container.appendChild(el); + + const replyRef = container.querySelector(".msg-reply-ref"); + expect(replyRef).not.toBeNull(); + expect(replyRef!.querySelector(".rr-author")?.textContent).toBe("Alice"); + + ac.abort(); + }); + + it("shows action buttons for non-deleted messages", () => { + const msg = makeMessage(); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const actionsBar = container.querySelector(".msg-actions-bar"); + expect(actionsBar).not.toBeNull(); + + ac.abort(); + }); + + it("does not show action buttons for deleted messages", () => { + const msg = makeMessage({ deleted: true }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const actionsBar = container.querySelector(".msg-actions-bar"); + expect(actionsBar).toBeNull(); + + ac.abort(); + }); + + it("renders reactions when present", () => { + const msg = makeMessage({ + reactions: [ + { emoji: "\uD83D\uDC4D", count: 3, me: false }, + { emoji: "\u2764\uFE0F", count: 1, me: true }, + ], + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const reactionChips = container.querySelectorAll(".reaction-chip:not(.add-reaction)"); + expect(reactionChips.length).toBe(2); + + ac.abort(); + }); + + it("renders attachments for image types", () => { + const msg = makeMessage({ + attachments: [ + { id: "1", filename: "photo.png", size: 1024, mime: "image/png", url: "/uploads/photo.png" }, + ], + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + expect(container.querySelector(".msg-image")).not.toBeNull(); + + ac.abort(); + }); + + it("renders attachments for file types", () => { + const msg = makeMessage({ + attachments: [ + { id: "1", filename: "doc.pdf", size: 2048, mime: "application/pdf", url: "/uploads/doc.pdf" }, + ], + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + expect(container.querySelector(".msg-file")).not.toBeNull(); + expect(container.querySelector(".msg-file-name")?.textContent).toBe("doc.pdf"); + + ac.abort(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/router.test.ts b/Client/tauri-client/tests/unit/router.test.ts new file mode 100644 index 00000000..dacb89a5 --- /dev/null +++ b/Client/tauri-client/tests/unit/router.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from "vitest"; +import { createRouter } from "../../src/lib/router"; +import type { PageId } from "../../src/lib/router"; + +describe("Router", () => { + it("starts on the initial page", () => { + const router = createRouter("connect"); + expect(router.getCurrentPage()).toBe("connect"); + }); + + it("navigate changes the current page", () => { + const router = createRouter("connect"); + router.navigate("main"); + expect(router.getCurrentPage()).toBe("main"); + }); + + it("notifies listeners on navigate", () => { + const router = createRouter("connect"); + const pages: PageId[] = []; + router.onNavigate((p) => pages.push(p)); + + router.navigate("main"); + router.navigate("connect"); + + expect(pages).toEqual(["main", "connect"]); + }); + + it("unsubscribe stops notifications", () => { + const router = createRouter("connect"); + const pages: PageId[] = []; + const unsub = router.onNavigate((p) => pages.push(p)); + + router.navigate("main"); + unsub(); + router.navigate("connect"); + + expect(pages).toEqual(["main"]); + }); + + it("does not notify if navigating to same page", () => { + const router = createRouter("connect"); + const listener = vi.fn(); + router.onNavigate(listener); + + router.navigate("connect"); // same page + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/safe-render.test.ts b/Client/tauri-client/tests/unit/safe-render.test.ts new file mode 100644 index 00000000..d9841bfe --- /dev/null +++ b/Client/tauri-client/tests/unit/safe-render.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + safeMount, + installGlobalErrorHandlers, + type MountableComponent, +} from "../../src/lib/safe-render"; + +describe("safeMount", () => { + beforeEach(() => { + vi.restoreAllMocks(); + // Suppress console output during tests + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "info").mockImplementation(() => {}); + }); + + it("mounts a working component", () => { + const container = document.createElement("div"); + const component: MountableComponent = { + mount(el: Element) { + el.textContent = "Hello"; + }, + }; + + safeMount(component, container); + expect(container.textContent).toBe("Hello"); + }); + + it("shows fallback UI when component throws", () => { + const container = document.createElement("div"); + const component: MountableComponent = { + mount() { + throw new Error("Render failed"); + }, + }; + + safeMount(component, container); + expect(container.textContent).toContain("Something went wrong"); + expect(container.textContent).toContain("Render failed"); + }); + + it("shows fallback without error details for non-Error throws", () => { + const container = document.createElement("div"); + const component: MountableComponent = { + mount() { + throw "string error"; + }, + }; + + safeMount(component, container); + expect(container.textContent).toContain("Something went wrong"); + }); + + it("clears container before showing fallback", () => { + const container = document.createElement("div"); + container.textContent = "existing content"; + + const component: MountableComponent = { + mount() { + throw new Error("fail"); + }, + }; + + safeMount(component, container); + expect(container.textContent).not.toContain("existing content"); + }); +}); + +describe("installGlobalErrorHandlers", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "info").mockImplementation(() => {}); + }); + + it("registers window error and unhandledrejection listeners", () => { + const addEventSpy = vi.spyOn(window, "addEventListener"); + + installGlobalErrorHandlers(); + + const eventTypes = addEventSpy.mock.calls.map((call) => call[0]); + expect(eventTypes).toContain("error"); + expect(eventTypes).toContain("unhandledrejection"); + }); +}); diff --git a/Client/tauri-client/tests/unit/server-banner.test.ts b/Client/tauri-client/tests/unit/server-banner.test.ts new file mode 100644 index 00000000..6e2ea243 --- /dev/null +++ b/Client/tauri-client/tests/unit/server-banner.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createServerBanner } from "@components/ServerBanner"; + +describe("ServerBanner", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("creates element with reconnecting-banner class", () => { + const banner = createServerBanner(); + expect(banner.element.classList.contains("reconnecting-banner")).toBe(true); + banner.destroy(); + }); + + it("showRestart adds visible class and shows countdown text", () => { + const banner = createServerBanner(); + banner.showRestart(5); + + expect(banner.element.classList.contains("visible")).toBe(true); + expect(banner.element.textContent).toBe("Server restarting in 5 seconds..."); + + banner.destroy(); + }); + + it('showReconnecting adds visible class with "Reconnecting..." text', () => { + const banner = createServerBanner(); + banner.showReconnecting(); + + expect(banner.element.classList.contains("visible")).toBe(true); + expect(banner.element.textContent).toBe("Reconnecting..."); + + banner.destroy(); + }); + + it("hide removes visible class", () => { + const banner = createServerBanner(); + banner.showReconnecting(); + expect(banner.element.classList.contains("visible")).toBe(true); + + banner.hide(); + expect(banner.element.classList.contains("visible")).toBe(false); + + banner.destroy(); + }); + + it("countdown decrements every second", () => { + const banner = createServerBanner(); + banner.showRestart(3); + + expect(banner.element.textContent).toBe("Server restarting in 3 seconds..."); + + vi.advanceTimersByTime(1000); + expect(banner.element.textContent).toBe("Server restarting in 2 seconds..."); + + vi.advanceTimersByTime(1000); + expect(banner.element.textContent).toBe("Server restarting in 1 seconds..."); + + banner.destroy(); + }); + + it('countdown transitions to "Reconnecting..." at 0', () => { + const banner = createServerBanner(); + banner.showRestart(2); + + vi.advanceTimersByTime(1000); // remaining = 1 + vi.advanceTimersByTime(1000); // remaining = 0 → showReconnecting + + expect(banner.element.textContent).toBe("Reconnecting..."); + + banner.destroy(); + }); + + it("destroy removes element from DOM", () => { + const banner = createServerBanner(); + const parent = document.createElement("div"); + parent.appendChild(banner.element); + + expect(parent.contains(banner.element)).toBe(true); + + banner.destroy(); + expect(parent.contains(banner.element)).toBe(false); + }); +}); diff --git a/Client/tauri-client/tests/unit/server-strip.test.ts b/Client/tauri-client/tests/unit/server-strip.test.ts new file mode 100644 index 00000000..ea08f14f --- /dev/null +++ b/Client/tauri-client/tests/unit/server-strip.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createServerStrip } from "@components/ServerStrip"; + +describe("ServerStrip", () => { + let container: HTMLDivElement; + let comp: ReturnType<typeof createServerStrip>; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + comp?.destroy?.(); + container.remove(); + }); + + it("mounts with server-strip class", () => { + comp = createServerStrip(); + comp.mount(container); + + expect(container.querySelector(".server-strip")).not.toBeNull(); + }); + + it('renders home icon with "O"', () => { + comp = createServerStrip(); + comp.mount(container); + + const icons = container.querySelectorAll(".server-icon"); + const homeIcon = icons[0]; + expect(homeIcon).not.toBeUndefined(); + expect(homeIcon?.textContent).toBe("O"); + }); + + it("renders separator", () => { + comp = createServerStrip(); + comp.mount(container); + + expect(container.querySelector(".server-separator")).not.toBeNull(); + }); + + it('renders add icon with "+"', () => { + comp = createServerStrip(); + comp.mount(container); + + const addIcon = container.querySelector(".server-icon.add"); + expect(addIcon).not.toBeNull(); + expect(addIcon?.textContent).toBe("+"); + }); + + it("home icon has active class", () => { + comp = createServerStrip(); + comp.mount(container); + + const icons = container.querySelectorAll(".server-icon"); + const homeIcon = icons[0]; + expect(homeIcon?.classList.contains("active")).toBe(true); + }); + + it("destroy removes DOM", () => { + comp = createServerStrip(); + comp.mount(container); + + expect(container.querySelector(".server-strip")).not.toBeNull(); + + comp.destroy?.(); + expect(container.querySelector(".server-strip")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/settings-helpers.test.ts b/Client/tauri-client/tests/unit/settings-helpers.test.ts new file mode 100644 index 00000000..77ec490a --- /dev/null +++ b/Client/tauri-client/tests/unit/settings-helpers.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + loadPref, + savePref, + applyTheme, + STORAGE_PREFIX, + THEMES, +} from "../../src/components/settings/helpers"; +import type { ThemeName } from "../../src/components/settings/helpers"; + +describe("settings/helpers", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + localStorage.clear(); + }); + + afterEach(() => { + container.remove(); + localStorage.clear(); + }); + + describe("STORAGE_PREFIX", () => { + it("has the correct prefix value", () => { + expect(STORAGE_PREFIX).toBe("owncord:settings:"); + }); + }); + + describe("loadPref", () => { + it("returns fallback when key does not exist", () => { + const result = loadPref("nonexistent", "default"); + expect(result).toBe("default"); + }); + + it("returns stored value when key exists", () => { + localStorage.setItem(STORAGE_PREFIX + "theme", JSON.stringify("midnight")); + const result = loadPref("theme", "dark"); + expect(result).toBe("midnight"); + }); + + it("returns fallback on invalid JSON", () => { + localStorage.setItem(STORAGE_PREFIX + "broken", "not-valid-json"); + const result = loadPref("broken", "fallback"); + expect(result).toBe("fallback"); + }); + + it("handles boolean values", () => { + localStorage.setItem(STORAGE_PREFIX + "notifications", JSON.stringify(true)); + expect(loadPref("notifications", false)).toBe(true); + }); + + it("handles numeric values", () => { + localStorage.setItem(STORAGE_PREFIX + "volume", JSON.stringify(75)); + expect(loadPref("volume", 50)).toBe(75); + }); + + it("handles object values", () => { + const obj = { fontSize: 14, compact: true }; + localStorage.setItem(STORAGE_PREFIX + "display", JSON.stringify(obj)); + const result = loadPref("display", {}); + expect(result).toEqual(obj); + }); + }); + + describe("savePref", () => { + it("stores value with correct prefix", () => { + savePref("theme", "midnight"); + const raw = localStorage.getItem(STORAGE_PREFIX + "theme"); + expect(raw).toBe(JSON.stringify("midnight")); + }); + + it("stores boolean values", () => { + savePref("notifications", true); + const raw = localStorage.getItem(STORAGE_PREFIX + "notifications"); + expect(raw).toBe("true"); + }); + + it("stores numeric values", () => { + savePref("volume", 80); + const raw = localStorage.getItem(STORAGE_PREFIX + "volume"); + expect(raw).toBe("80"); + }); + + it("stores object values", () => { + const obj = { a: 1, b: "two" }; + savePref("config", obj); + const raw = localStorage.getItem(STORAGE_PREFIX + "config"); + expect(JSON.parse(raw!)).toEqual(obj); + }); + + it("overwrites existing values", () => { + savePref("theme", "dark"); + savePref("theme", "light"); + expect(loadPref("theme", "dark")).toBe("light"); + }); + }); + + describe("applyTheme", () => { + it("sets CSS custom properties for dark theme", () => { + applyTheme("dark"); + const root = document.documentElement; + expect(root.style.getPropertyValue("--bg-primary")).toBe("#313338"); + expect(root.style.getPropertyValue("--bg-secondary")).toBe("#2b2d31"); + expect(root.style.getPropertyValue("--bg-tertiary")).toBe("#1e1f22"); + expect(root.style.getPropertyValue("--text-normal")).toBe("#dbdee1"); + }); + + it("sets CSS custom properties for midnight theme", () => { + applyTheme("midnight"); + const root = document.documentElement; + expect(root.style.getPropertyValue("--bg-primary")).toBe("#1a1a2e"); + }); + + it("sets CSS custom properties for light theme", () => { + applyTheme("light"); + const root = document.documentElement; + expect(root.style.getPropertyValue("--bg-primary")).toBe("#ffffff"); + expect(root.style.getPropertyValue("--text-normal")).toBe("#313338"); + }); + + it("overwrites previous theme variables", () => { + applyTheme("dark"); + applyTheme("light"); + const root = document.documentElement; + expect(root.style.getPropertyValue("--bg-primary")).toBe("#ffffff"); + }); + }); + + describe("THEMES", () => { + it("contains dark, midnight, and light themes", () => { + const themeNames = Object.keys(THEMES); + expect(themeNames).toContain("dark"); + expect(themeNames).toContain("midnight"); + expect(themeNames).toContain("light"); + }); + + it("each theme has required CSS variables", () => { + for (const [, vars] of Object.entries(THEMES)) { + expect(vars).toHaveProperty("--bg-primary"); + expect(vars).toHaveProperty("--bg-secondary"); + expect(vars).toHaveProperty("--bg-tertiary"); + expect(vars).toHaveProperty("--text-normal"); + } + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/settings-overlay.test.ts b/Client/tauri-client/tests/unit/settings-overlay.test.ts new file mode 100644 index 00000000..63551ab6 --- /dev/null +++ b/Client/tauri-client/tests/unit/settings-overlay.test.ts @@ -0,0 +1,385 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createSettingsOverlay } from "@components/SettingsOverlay"; + +// Mock logger +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + getLogBuffer: () => [], + clearLogBuffer: vi.fn(), + addLogListener: () => () => {}, + setLogLevel: vi.fn(), +})); + +// Mock stores +const mockSetTheme = vi.fn(); +vi.mock("@stores/ui.store", () => ({ + uiStore: { + getState: () => ({ settingsOpen: false }), + subscribe: () => () => {}, + }, + setTheme: (...args: unknown[]) => mockSetTheme(...args), +})); + +vi.mock("@lib/voiceSession", () => ({ + switchInputDevice: vi.fn().mockResolvedValue(undefined), + switchOutputDevice: vi.fn().mockResolvedValue(undefined), + setVoiceSensitivity: vi.fn(), +})); + +vi.mock("@stores/auth.store", () => ({ + authStore: { + getState: () => ({ + user: { id: 1, username: "testuser" }, + }), + }, +})); + +function clickEl(el: Element | null): void { + expect(el).not.toBeNull(); + (el as HTMLElement).click(); +} + +function getTab(container: HTMLDivElement, index: number): HTMLElement { + const tabs = container.querySelectorAll(".settings-sidebar > button.settings-nav-item"); + const tab = tabs[index]; + expect(tab).toBeDefined(); + return tab as HTMLElement; +} + +describe("SettingsOverlay", () => { + let container: HTMLDivElement; + + const defaultOptions = { + onClose: vi.fn(), + onChangePassword: vi.fn().mockResolvedValue(undefined), + onUpdateProfile: vi.fn().mockResolvedValue(undefined), + onLogout: vi.fn(), + }; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + localStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + container.remove(); + }); + + it("mounts with all tabs", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const tabs = container.querySelectorAll(".settings-sidebar > button.settings-nav-item"); + const tabNames = Array.from(tabs).map((t) => t.textContent); + expect(tabNames).toEqual([ + "Account", + "Appearance", + "Notifications", + "Voice & Audio", + "Keybinds", + "Logs", + ]); + + overlay.destroy?.(); + }); + + it("starts on Account tab", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const activeTab = container.querySelector(".settings-sidebar > button.settings-nav-item.active"); + expect(activeTab?.textContent).toBe("Account"); + + overlay.destroy?.(); + }); + + it("switches tabs on click", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const appearanceTab = getTab(container, 1); + appearanceTab.click(); + + expect(appearanceTab.classList.contains("active")).toBe(true); + const prevActive = getTab(container, 0); + expect(prevActive.classList.contains("active")).toBe(false); + + overlay.destroy?.(); + }); + + it("renders close button that calls onClose", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + clickEl(container.querySelector(".settings-close-btn")); + expect(defaultOptions.onClose).toHaveBeenCalled(); + + overlay.destroy?.(); + }); + + it("closes on Escape key", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + overlay.open(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(defaultOptions.onClose).toHaveBeenCalled(); + + overlay.destroy?.(); + }); + + // --- Appearance tab tests --- + + it("applies theme on click", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + getTab(container, 1).click(); + + const themeOptions = container.querySelectorAll(".theme-opt"); + expect(themeOptions.length).toBe(3); + + const midnight = themeOptions[1] as HTMLElement; + midnight.click(); + + expect(midnight.classList.contains("active")).toBe(true); + expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#1a1a2e"); + expect(localStorage.getItem("owncord:settings:theme")).toBe('"midnight"'); + expect(mockSetTheme).toHaveBeenCalledWith("midnight"); + + overlay.destroy?.(); + }); + + it("persists and restores font size", () => { + localStorage.setItem("owncord:settings:fontSize", "18"); + + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + getTab(container, 1).click(); + + const slider = container.querySelector(".settings-slider") as HTMLInputElement; + expect(slider.value).toBe("18"); + expect(document.documentElement.style.getPropertyValue("--font-size")).toBe("18px"); + + overlay.destroy?.(); + }); + + it("changes font size via slider", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + getTab(container, 1).click(); + + const slider = container.querySelector(".settings-slider") as HTMLInputElement; + slider.value = "14"; + slider.dispatchEvent(new Event("input")); + + expect(document.documentElement.style.getPropertyValue("--font-size")).toBe("14px"); + expect(localStorage.getItem("owncord:settings:fontSize")).toBe("14"); + + overlay.destroy?.(); + }); + + it("toggles compact mode", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + getTab(container, 1).click(); + + const toggle = container.querySelector(".toggle") as HTMLElement; + expect(toggle).not.toBeNull(); + toggle.click(); + + expect(toggle.classList.contains("on")).toBe(true); + expect(document.documentElement.classList.contains("compact-mode")).toBe(true); + expect(localStorage.getItem("owncord:settings:compactMode")).toBe("true"); + + overlay.destroy?.(); + }); + + // --- Notifications tab tests --- + + it("renders notification toggles", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + getTab(container, 2).click(); + + const toggles = container.querySelectorAll(".toggle"); + expect(toggles.length).toBe(4); + + overlay.destroy?.(); + }); + + it("persists notification toggle state", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + getTab(container, 2).click(); + + const toggles = container.querySelectorAll(".toggle"); + const suppressToggle = toggles[2] as HTMLElement; + suppressToggle.click(); + + expect(suppressToggle.classList.contains("on")).toBe(true); + expect(localStorage.getItem("owncord:settings:suppressEveryone")).toBe("true"); + + overlay.destroy?.(); + }); + + // --- Voice & Audio tab tests --- + + it("renders Voice & Audio tab with device selectors", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + getTab(container, 3).click(); + + const selects = container.querySelectorAll("select.form-input"); + expect(selects.length).toBe(2); + + const sliders = container.querySelectorAll(".settings-slider"); + expect(sliders.length).toBeGreaterThanOrEqual(1); + + const toggles = container.querySelectorAll(".toggle"); + // 5 toggles: echo cancellation, noise suppression, auto gain control, + // enhanced noise suppression (RNNoise), silence suppression + expect(toggles.length).toBe(5); + + overlay.destroy?.(); + }); + + it("persists voice sensitivity setting", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + getTab(container, 3).click(); + + const slider = container.querySelector(".settings-slider") as HTMLInputElement; + slider.value = "75"; + slider.dispatchEvent(new Event("input")); + + expect(localStorage.getItem("owncord:settings:voiceSensitivity")).toBe("75"); + + overlay.destroy?.(); + }); + + it("persists audio device selection on change", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + getTab(container, 3).click(); + + const selects = container.querySelectorAll("select.form-input"); + const inputSelect = selects[0] as HTMLSelectElement; + inputSelect.dispatchEvent(new Event("change")); + + expect(localStorage.getItem("owncord:settings:audioInputDevice")).toBe('""'); + + overlay.destroy?.(); + }); + + it("toggles echo cancellation", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + getTab(container, 3).click(); + + const toggles = container.querySelectorAll(".toggle"); + const echoToggle = toggles[0] as HTMLElement; + + // Default is on + expect(echoToggle.classList.contains("on")).toBe(true); + echoToggle.click(); + expect(echoToggle.classList.contains("on")).toBe(false); + expect(localStorage.getItem("owncord:settings:echoCancellation")).toBe("false"); + + overlay.destroy?.(); + }); + + // --- Account tab tests --- + + it("shows current username", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const acName = container.querySelector(".ac-name"); + expect(acName?.textContent).toBe("testuser"); + + overlay.destroy?.(); + }); + + it("calls onLogout when logout button clicked", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + clickEl(container.querySelector(".settings-nav-item.danger")); + expect(defaultOptions.onLogout).toHaveBeenCalled(); + + overlay.destroy?.(); + }); + + it("validates password change requires minimum length", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const inputs = container.querySelectorAll("input[type='password']"); + (inputs[0] as HTMLInputElement).value = "oldpass123"; + (inputs[1] as HTMLInputElement).value = "short"; + (inputs[2] as HTMLInputElement).value = "short"; + + const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")) + .find((b) => b.textContent === "Change Password") as HTMLElement; + changePwBtn.click(); + + expect(defaultOptions.onChangePassword).not.toHaveBeenCalled(); + + overlay.destroy?.(); + }); + + it("validates password confirmation matches", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const inputs = container.querySelectorAll("input[type='password']"); + (inputs[0] as HTMLInputElement).value = "oldpass123"; + (inputs[1] as HTMLInputElement).value = "newpassword123"; + (inputs[2] as HTMLInputElement).value = "differentpassword"; + + const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")) + .find((b) => b.textContent === "Change Password") as HTMLElement; + changePwBtn.click(); + + expect(defaultOptions.onChangePassword).not.toHaveBeenCalled(); + + overlay.destroy?.(); + }); + + // --- Open/Close --- + + it("open() adds .open class, close() removes it", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + const root = container.querySelector(".settings-overlay"); + expect(root?.classList.contains("open")).toBe(false); + + overlay.open(); + expect(root?.classList.contains("open")).toBe(true); + + overlay.close(); + expect(root?.classList.contains("open")).toBe(false); + + overlay.destroy?.(); + }); + + // --- Cleanup --- + + it("destroy removes root from DOM", () => { + const overlay = createSettingsOverlay(defaultOptions); + overlay.mount(container); + + expect(container.querySelector(".settings-overlay")).not.toBeNull(); + overlay.destroy?.(); + expect(container.querySelector(".settings-overlay")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/soundboard.test.ts b/Client/tauri-client/tests/unit/soundboard.test.ts new file mode 100644 index 00000000..1d01b88b --- /dev/null +++ b/Client/tauri-client/tests/unit/soundboard.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createSoundboard } from "../../src/components/Soundboard"; +import type { SoundItem } from "../../src/components/Soundboard"; + +const testSounds: SoundItem[] = [ + { id: 1, name: "Airhorn", durationMs: 2500 }, + { id: 2, name: "Rimshot", durationMs: 1200 }, + { id: 3, name: "Sad Trombone", durationMs: 3800 }, +]; + +describe("Soundboard", () => { + let container: HTMLDivElement; + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + vi.useRealTimers(); + container.remove(); + }); + + it("renders empty state when no sounds", () => { + const board = createSoundboard({ + sounds: [], + onPlaySound: vi.fn(), + }); + board.mount(container); + + const empty = container.querySelector(".soundboard__empty"); + expect(empty).not.toBeNull(); + expect(empty!.textContent).toBe("No sounds available"); + + board.destroy?.(); + }); + + it("renders sound buttons with names and durations", () => { + const board = createSoundboard({ + sounds: testSounds, + onPlaySound: vi.fn(), + }); + board.mount(container); + + const buttons = container.querySelectorAll(".sound-btn"); + expect(buttons.length).toBe(3); + + const names = Array.from(container.querySelectorAll(".sound-btn__name")).map( + (el) => el.textContent, + ); + expect(names).toEqual(["Airhorn", "Rimshot", "Sad Trombone"]); + + const durations = Array.from(container.querySelectorAll(".sound-btn__duration")).map( + (el) => el.textContent, + ); + expect(durations).toEqual(["2.5s", "1.2s", "3.8s"]); + + board.destroy?.(); + }); + + it("calls onPlaySound with correct id when button is clicked", () => { + const onPlaySound = vi.fn(); + const board = createSoundboard({ + sounds: testSounds, + onPlaySound, + }); + board.mount(container); + + const buttons = container.querySelectorAll(".sound-btn") as NodeListOf<HTMLButtonElement>; + buttons[1]!.click(); + expect(onPlaySound).toHaveBeenCalledWith(2); + + board.destroy?.(); + }); + + it("disables all buttons during cooldown", () => { + const board = createSoundboard({ + sounds: testSounds, + onPlaySound: vi.fn(), + }); + board.mount(container); + + const buttons = container.querySelectorAll(".sound-btn") as NodeListOf<HTMLButtonElement>; + buttons[0]!.click(); + + // All buttons should be disabled + for (const btn of buttons) { + expect(btn.disabled).toBe(true); + expect(btn.classList.contains("sound-btn--cooldown")).toBe(true); + } + + board.destroy?.(); + }); + + it("re-enables buttons after cooldown period", () => { + const board = createSoundboard({ + sounds: testSounds, + onPlaySound: vi.fn(), + }); + board.mount(container); + + const buttons = container.querySelectorAll(".sound-btn") as NodeListOf<HTMLButtonElement>; + buttons[0]!.click(); + + // Advance past cooldown (3000ms) + vi.advanceTimersByTime(3000); + + for (const btn of buttons) { + expect(btn.disabled).toBe(false); + expect(btn.classList.contains("sound-btn--cooldown")).toBe(false); + } + + board.destroy?.(); + }); + + it("does not fire onPlaySound when button is disabled", () => { + const onPlaySound = vi.fn(); + const board = createSoundboard({ + sounds: testSounds, + onPlaySound, + }); + board.mount(container); + + const buttons = container.querySelectorAll(".sound-btn") as NodeListOf<HTMLButtonElement>; + buttons[0]!.click(); // first click triggers cooldown + onPlaySound.mockClear(); + + buttons[1]!.click(); // should not fire since disabled + expect(onPlaySound).not.toHaveBeenCalled(); + + board.destroy?.(); + }); + + it("cleans up on destroy", () => { + const board = createSoundboard({ + sounds: testSounds, + onPlaySound: vi.fn(), + }); + board.mount(container); + + expect(container.querySelector(".soundboard")).not.toBeNull(); + + board.destroy?.(); + expect(container.querySelector(".soundboard")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/store.test.ts b/Client/tauri-client/tests/unit/store.test.ts new file mode 100644 index 00000000..fb29d5a3 --- /dev/null +++ b/Client/tauri-client/tests/unit/store.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createStore } from '../../src/lib/store'; + +interface TestState { + count: number; + name: string; +} + +const initialState: TestState = { count: 0, name: 'test' }; + +function freshStore() { + return createStore<TestState>({ ...initialState }); +} + +describe('createStore', () => { + it('getState returns initial state', () => { + const store = freshStore(); + expect(store.getState()).toEqual({ count: 0, name: 'test' }); + }); + + it('setState updates state via updater function', () => { + const store = freshStore(); + store.setState((prev) => ({ ...prev, count: prev.count + 1 })); + expect(store.getState()).toEqual({ count: 1, name: 'test' }); + }); + + it('setState calls all subscribers with new state', () => { + const store = freshStore(); + const listener1 = vi.fn(); + const listener2 = vi.fn(); + store.subscribe(listener1); + store.subscribe(listener2); + + store.setState((prev) => ({ ...prev, count: 5 })); + store.flush(); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener1).toHaveBeenCalledWith({ count: 5, name: 'test' }); + expect(listener2).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledWith({ count: 5, name: 'test' }); + }); + + it('subscribe returns unsubscribe function that works', () => { + const store = freshStore(); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + + store.setState((prev) => ({ ...prev, count: 1 })); + store.flush(); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + + store.setState((prev) => ({ ...prev, count: 2 })); + store.flush(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('multiple subscribers all get called', () => { + const store = freshStore(); + const calls: number[] = []; + store.subscribe(() => calls.push(1)); + store.subscribe(() => calls.push(2)); + store.subscribe(() => calls.push(3)); + + store.setState((prev) => ({ ...prev, count: 10 })); + store.flush(); + + expect(calls).toEqual([1, 2, 3]); + }); + + it('unsubscribed listener does not get called', () => { + const store = freshStore(); + const kept = vi.fn(); + const removed = vi.fn(); + + store.subscribe(kept); + const unsub = store.subscribe(removed); + unsub(); + + store.setState((prev) => ({ ...prev, count: 99 })); + store.flush(); + + expect(kept).toHaveBeenCalledTimes(1); + expect(removed).not.toHaveBeenCalled(); + }); + + it('select derives value from state', () => { + const store = freshStore(); + store.setState((prev) => ({ ...prev, count: 42 })); + + const count = store.select((s) => s.count); + const name = store.select((s) => s.name); + + expect(count).toBe(42); + expect(name).toBe('test'); + }); + + it('setState does NOT mutate previous state reference', () => { + const store = freshStore(); + const before = store.getState(); + + store.setState((prev) => ({ ...prev, count: prev.count + 1 })); + const after = store.getState(); + + expect(before).toEqual({ count: 0, name: 'test' }); + expect(after).toEqual({ count: 1, name: 'test' }); + expect(before).not.toBe(after); + }); + + it('subscriber receives new state not old state', () => { + const store = freshStore(); + const received: TestState[] = []; + store.subscribe((s) => received.push(s)); + + store.setState((prev) => ({ ...prev, count: 7 })); + store.flush(); + store.setState((prev) => ({ ...prev, name: 'updated' })); + store.flush(); + + expect(received).toEqual([ + { count: 7, name: 'test' }, + { count: 7, name: 'updated' }, + ]); + }); + + it('no subscribers means setState still works without crash', () => { + const store = freshStore(); + expect(() => { + store.setState((prev) => ({ ...prev, count: 100 })); + }).not.toThrow(); + expect(store.getState().count).toBe(100); + }); +}); diff --git a/Client/tauri-client/tests/unit/toast.test.ts b/Client/tauri-client/tests/unit/toast.test.ts new file mode 100644 index 00000000..8ecb27e8 --- /dev/null +++ b/Client/tauri-client/tests/unit/toast.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + createToastContainer, + type ToastContainer, +} from "../../src/components/Toast"; + +describe("ToastContainer", () => { + let container: HTMLDivElement; + let toast: ToastContainer; + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement("div"); + toast = createToastContainer(); + toast.mount(container); + }); + + afterEach(() => { + toast.destroy?.(); + vi.useRealTimers(); + }); + + it("show adds a toast to the container", () => { + toast.show("Hello world"); + + const toastEl = container.querySelector(".toast"); + expect(toastEl).not.toBeNull(); + expect(toastEl!.textContent).toBe("Hello world"); + }); + + it("auto-dismiss removes toast after duration", () => { + toast.show("Temporary", "info", 3000); + + expect(container.querySelectorAll(".toast").length).toBe(1); + + // Advance past dismiss timer (3000ms) + transition fallback (400ms) + vi.advanceTimersByTime(3400); + + expect(container.querySelectorAll(".toast").length).toBe(0); + }); + + it("max 5 toasts — oldest removed when exceeded", () => { + for (let i = 0; i < 6; i++) { + toast.show(`Toast ${i}`); + } + + // Advance past the transition fallback so evicted toasts are removed from DOM + vi.advanceTimersByTime(400); + + const toasts = container.querySelectorAll(".toast"); + expect(toasts.length).toBe(5); + + // The oldest (Toast 0) should have been evicted; Toast 1 should be first + expect(toasts[0]!.textContent).toBe("Toast 1"); + expect(toasts[4]!.textContent).toBe("Toast 5"); + }); + + it("clear removes all toasts", () => { + toast.show("One"); + toast.show("Two"); + toast.show("Three"); + + expect(container.querySelectorAll(".toast").length).toBe(3); + + toast.clear(); + // Advance past transition fallback so DOM elements are removed + vi.advanceTimersByTime(400); + + expect(container.querySelectorAll(".toast").length).toBe(0); + }); + + it("different types get correct CSS class", () => { + toast.show("Error msg", "error"); + toast.show("Info msg", "info"); + toast.show("Success msg", "success"); + + expect(container.querySelector(".toast-error")).not.toBeNull(); + expect(container.querySelector(".toast-info")).not.toBeNull(); + expect(container.querySelector(".toast-success")).not.toBeNull(); + }); + + it("defaults to info type when type is omitted", () => { + toast.show("Default type"); + + const toastEl = container.querySelector(".toast-info"); + expect(toastEl).not.toBeNull(); + }); + + it("defaults to 5000ms duration when omitted", () => { + toast.show("Default duration"); + + vi.advanceTimersByTime(4999); + expect(container.querySelectorAll(".toast").length).toBe(1); + + // Advance past dismiss timer (1ms remaining) + transition fallback (400ms) + vi.advanceTimersByTime(401); + expect(container.querySelectorAll(".toast").length).toBe(0); + }); + + it("destroy clears all toasts and removes root", () => { + toast.show("Will be destroyed"); + toast.destroy?.(); + + expect(container.querySelector(".toast-container")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/types.test.ts b/Client/tauri-client/tests/unit/types.test.ts new file mode 100644 index 00000000..a801a603 --- /dev/null +++ b/Client/tauri-client/tests/unit/types.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from "vitest"; +import type { + ServerMessage, + ClientMessage, + VoiceConfigPayload, + VoiceSpeakersPayload, + ReadyPayload, + ChatMessagePayload, + Permission, +} from "../../src/lib/types"; +import { Permission as P } from "../../src/lib/types"; + +// Sample PROTOCOL.md JSON payloads for parsing validation +const sampleAuthOk = { + type: "auth_ok" as const, + payload: { + user: { id: 1, username: "alex", avatar: "uuid.png", role: "admin" }, + server_name: "My Server", + motd: "Welcome!", + }, +}; + +const sampleReady = { + type: "ready" as const, + payload: { + channels: [ + { + id: 1, name: "general", type: "text" as const, + category: "Main", position: 0, unread_count: 3, last_message_id: 1040, + }, + { + id: 10, name: "voice-chat", type: "voice" as const, + category: "Main", position: 1, + }, + ], + members: [ + { id: 1, username: "alex", avatar: "uuid.png", role: "admin", status: "online" as const }, + { id: 2, username: "jordan", avatar: null, role: "member", status: "idle" as const }, + ], + voice_states: [ + { channel_id: 10, user_id: 2, muted: false, deafened: false }, + ], + roles: [ + { id: 1, name: "Owner", color: "#E74C3C", permissions: 2147483647 }, + { id: 2, name: "Admin", color: "#F39C12", permissions: 1073741823 }, + { id: 3, name: "Member", color: null, permissions: 1049601 }, + ], + }, +}; + +const sampleChatMessage = { + type: "chat_message" as const, + payload: { + id: 1042, channel_id: 5, + user: { id: 1, username: "alex", avatar: "uuid.png" }, + content: "Hello everyone!", + reply_to: null, + attachments: [{ + id: "upload-uuid-1", filename: "photo.jpg", + size: 204800, mime: "image/jpeg", url: "/files/upload-uuid-1", + }], + timestamp: "2026-03-14T10:30:00Z", + }, +}; + +const sampleVoiceConfig = { + type: "voice_config" as const, + payload: { + channel_id: 10, quality: "medium" as const, bitrate: 64000, + threshold_mode: "forwarding" as const, mixing_threshold: 10, + top_speakers: 3, max_users: 50, + }, +}; + +const sampleVoiceSpeakers = { + type: "voice_speakers" as const, + payload: { + channel_id: 10, + speakers: [1, 5, 12], + threshold_mode: "forwarding" as const, + }, +}; + +describe("ServerMessage discriminated union", () => { + it("parses auth_ok with role as string", () => { + const msg: ServerMessage = sampleAuthOk; + if (msg.type === "auth_ok") { + expect(msg.payload.user.role).toBe("admin"); + expect(typeof msg.payload.user.role).toBe("string"); + } + }); + + it("parses ready payload with all nested objects", () => { + const msg: ServerMessage = sampleReady; + if (msg.type === "ready") { + const payload: ReadyPayload = msg.payload; + expect(payload.channels).toHaveLength(2); + expect(payload.members).toHaveLength(2); + expect(payload.voice_states).toHaveLength(1); + expect(payload.roles).toHaveLength(3); + expect(payload.channels[0]?.unread_count).toBe(3); + expect(payload.channels[0]?.last_message_id).toBe(1040); + expect(payload.members[0]?.role).toBe("admin"); + expect(payload.members[1]?.status).toBe("idle"); + } + }); + + it("parses chat_message with attachments", () => { + const msg: ServerMessage = sampleChatMessage; + if (msg.type === "chat_message") { + const payload: ChatMessagePayload = msg.payload; + expect(payload.id).toBe(1042); + expect(payload.attachments).toHaveLength(1); + expect(payload.attachments[0]?.mime).toBe("image/jpeg"); + } + }); +}); + +describe("AUDIT Critical: threshold_mode (CRIT-2, CRIT-3)", () => { + it("VoiceConfigPayload uses threshold_mode NOT mode", () => { + const config: VoiceConfigPayload = sampleVoiceConfig.payload; + expect(config.threshold_mode).toBe("forwarding"); + // TypeScript compile-time check: "mode" does not exist on VoiceConfigPayload + // @ts-expect-error — mode is not a valid field + expect(config.mode).toBeUndefined(); + }); + + it("VoiceSpeakersPayload uses threshold_mode NOT mode", () => { + const speakers: VoiceSpeakersPayload = sampleVoiceSpeakers.payload; + expect(speakers.threshold_mode).toBe("forwarding"); + // @ts-expect-error — mode is not a valid field + expect(speakers.mode).toBeUndefined(); + }); + + it("voice_config ServerMessage carries threshold_mode", () => { + const msg: ServerMessage = sampleVoiceConfig; + if (msg.type === "voice_config") { + expect(msg.payload.threshold_mode).toBeDefined(); + expect(["forwarding", "selective"]).toContain(msg.payload.threshold_mode); + } + }); + + it("voice_speakers ServerMessage carries threshold_mode", () => { + const msg: ServerMessage = sampleVoiceSpeakers; + if (msg.type === "voice_speakers") { + expect(msg.payload.threshold_mode).toBeDefined(); + } + }); +}); + +describe("AUDIT Critical: no channel_focus message type", () => { + it("ServerMessage union does not include channel_focus", () => { + // This test documents that channel_focus is intentionally excluded. + // If someone accidentally adds it, this comment serves as a warning. + const validTypes = [ + "auth_ok", "auth_error", "ready", "chat_message", "chat_send_ok", + "chat_edited", "chat_deleted", "reaction_update", "typing", "presence", + "channel_create", "channel_update", "channel_delete", + "voice_state", "voice_leave", "voice_config", "voice_speakers", + "voice_offer", "voice_answer", "voice_ice", + "member_join", "member_leave", "member_update", "member_ban", + "server_restart", "error", + ]; + expect(validTypes).not.toContain("channel_focus"); + }); +}); + +describe("ClientMessage types", () => { + it("includes all outgoing message types", () => { + const chatSend: ClientMessage = { + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }; + expect(chatSend.type).toBe("chat_send"); + + const reactionAdd: ClientMessage = { + type: "reaction_add", + payload: { message_id: 1, emoji: "👍" }, + }; + expect(reactionAdd.type).toBe("reaction_add"); + + const soundboard: ClientMessage = { + type: "soundboard_play", + payload: { sound_id: "uuid-123" }, + }; + expect(soundboard.type).toBe("soundboard_play"); + }); + + it("includes voice signaling types", () => { + const offer: ClientMessage = { + type: "voice_offer", + payload: { channel_id: 10, sdp: "v=0..." }, + }; + expect(offer.type).toBe("voice_offer"); + }); +}); + +describe("Permission bitfield", () => { + it("has correct bit values from SCHEMA.md", () => { + expect(P.SEND_MESSAGES).toBe(0x1); + expect(P.READ_MESSAGES).toBe(0x2); + expect(P.ATTACH_FILES).toBe(0x20); + expect(P.ADD_REACTIONS).toBe(0x40); + expect(P.USE_SOUNDBOARD).toBe(0x100); + expect(P.CONNECT_VOICE).toBe(0x200); + expect(P.SPEAK_VOICE).toBe(0x400); + expect(P.USE_VIDEO).toBe(0x800); + expect(P.SHARE_SCREEN).toBe(0x1000); + expect(P.MANAGE_MESSAGES).toBe(0x10000); + expect(P.MANAGE_CHANNELS).toBe(0x20000); + expect(P.KICK_MEMBERS).toBe(0x40000); + expect(P.BAN_MEMBERS).toBe(0x80000); + expect(P.MUTE_MEMBERS).toBe(0x100000); + expect(P.MANAGE_ROLES).toBe(0x1000000); + expect(P.MANAGE_SERVER).toBe(0x2000000); + expect(P.MANAGE_INVITES).toBe(0x4000000); + expect(P.VIEW_AUDIT_LOG).toBe(0x8000000); + expect(P.ADMINISTRATOR).toBe(0x40000000); + }); + + it("ADMINISTRATOR bit can be checked with bitwise AND", () => { + const ownerPerms = 0x7FFFFFFF; + expect(ownerPerms & P.ADMINISTRATOR).toBeTruthy(); + + const memberPerms = 0x00000663; + expect(memberPerms & P.ADMINISTRATOR).toBeFalsy(); + }); + + it("Member default permissions match SCHEMA.md", () => { + const memberPerms = 0x00000663; + expect(memberPerms & P.SEND_MESSAGES).toBeTruthy(); + expect(memberPerms & P.READ_MESSAGES).toBeTruthy(); + expect(memberPerms & P.ATTACH_FILES).toBeTruthy(); + expect(memberPerms & P.ADD_REACTIONS).toBeTruthy(); + expect(memberPerms & P.CONNECT_VOICE).toBeTruthy(); + expect(memberPerms & P.SPEAK_VOICE).toBeTruthy(); + expect(memberPerms & P.MANAGE_MESSAGES).toBeFalsy(); + expect(memberPerms & P.ADMINISTRATOR).toBeFalsy(); + }); +}); diff --git a/Client/tauri-client/tests/unit/typing-indicator.test.ts b/Client/tauri-client/tests/unit/typing-indicator.test.ts new file mode 100644 index 00000000..76ad87a7 --- /dev/null +++ b/Client/tauri-client/tests/unit/typing-indicator.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +let storeCallback: (() => void) | null = null; +let typingUsers: Array<{ id: number; username: string }> = []; + +vi.mock("@stores/members.store", () => ({ + membersStore: { + subscribe: vi.fn((cb: () => void) => { + storeCallback = cb; + return () => { + storeCallback = null; + }; + }), + }, + getTypingUsers: vi.fn(() => typingUsers), +})); + +import { createTypingIndicator } from "@components/TypingIndicator"; + +function setTypingUsers(users: Array<{ id: number; username: string }>): void { + typingUsers = users; + storeCallback?.(); +} + +describe("TypingIndicator", () => { + let container: HTMLDivElement; + let comp: ReturnType<typeof createTypingIndicator>; + + beforeEach(() => { + typingUsers = []; + storeCallback = null; + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + comp?.destroy?.(); + container.remove(); + }); + + it("mounts with typing-bar class", () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + expect(container.querySelector(".typing-bar")).not.toBeNull(); + }); + + it("shows nothing when no one is typing", () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + expect(bar.children.length).toBe(0); + expect(bar.textContent).toBe(""); + }); + + it('shows "X is typing..." for one user', () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + setTypingUsers([{ id: 1, username: "alice" }]); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + expect(bar.textContent).toContain("alice is typing..."); + }); + + it('shows "X and Y are typing..." for two users', () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + setTypingUsers([ + { id: 1, username: "alice" }, + { id: 2, username: "bob" }, + ]); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + expect(bar.textContent).toContain("alice and bob are typing..."); + }); + + it('shows "Several people are typing..." for 3+ users', () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 100 }); + comp.mount(container); + + setTypingUsers([ + { id: 1, username: "alice" }, + { id: 2, username: "bob" }, + { id: 3, username: "charlie" }, + ]); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + expect(bar.textContent).toContain("Several people are typing..."); + }); + + it("filters out current user from typing list", () => { + comp = createTypingIndicator({ channelId: 1, currentUserId: 1 }); + comp.mount(container); + + // Only the current user is typing + setTypingUsers([{ id: 1, username: "me" }]); + + const bar = container.querySelector(".typing-bar") as HTMLDivElement; + // Should show nothing since current user is filtered + expect(bar.children.length).toBe(0); + }); +}); diff --git a/Client/tauri-client/tests/unit/ui.store.test.ts b/Client/tauri-client/tests/unit/ui.store.test.ts new file mode 100644 index 00000000..4ba2fea9 --- /dev/null +++ b/Client/tauri-client/tests/unit/ui.store.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + uiStore, + toggleSidebar, + toggleMemberList, + openSettings, + closeSettings, + openModal, + closeModal, + setTheme, + toggleCategory, + isCategoryCollapsed, +} from "../../src/stores/ui.store"; + +function resetStore(): void { + uiStore.setState(() => ({ + sidebarCollapsed: false, + memberListVisible: true, + settingsOpen: false, + activeModal: null, + theme: "dark" as const, + connectionStatus: "disconnected" as const, + transientError: null, + persistentError: null, + collapsedCategories: new Set<string>(), + })); +} + +describe("ui store", () => { + beforeEach(() => { + resetStore(); + }); + + describe("initial state", () => { + it("has dark theme", () => { + expect(uiStore.getState().theme).toBe("dark"); + }); + + it("has sidebar not collapsed", () => { + expect(uiStore.getState().sidebarCollapsed).toBe(false); + }); + + it("has member list visible", () => { + expect(uiStore.getState().memberListVisible).toBe(true); + }); + + it("has settings closed", () => { + expect(uiStore.getState().settingsOpen).toBe(false); + }); + + it("has no active modal", () => { + expect(uiStore.getState().activeModal).toBeNull(); + }); + + it("has no collapsed categories", () => { + expect(uiStore.getState().collapsedCategories.size).toBe(0); + }); + }); + + describe("toggleSidebar", () => { + it("collapses sidebar when expanded", () => { + toggleSidebar(); + expect(uiStore.getState().sidebarCollapsed).toBe(true); + }); + + it("expands sidebar when collapsed", () => { + toggleSidebar(); + toggleSidebar(); + expect(uiStore.getState().sidebarCollapsed).toBe(false); + }); + + it("produces a new state object", () => { + const before = uiStore.getState(); + toggleSidebar(); + expect(uiStore.getState()).not.toBe(before); + }); + }); + + describe("toggleMemberList", () => { + it("hides member list when visible", () => { + toggleMemberList(); + expect(uiStore.getState().memberListVisible).toBe(false); + }); + + it("shows member list when hidden", () => { + toggleMemberList(); + toggleMemberList(); + expect(uiStore.getState().memberListVisible).toBe(true); + }); + }); + + describe("openSettings / closeSettings", () => { + it("openSettings sets settingsOpen to true", () => { + openSettings(); + expect(uiStore.getState().settingsOpen).toBe(true); + }); + + it("closeSettings sets settingsOpen to false", () => { + openSettings(); + closeSettings(); + expect(uiStore.getState().settingsOpen).toBe(false); + }); + + it("closeSettings is safe when already closed", () => { + closeSettings(); + expect(uiStore.getState().settingsOpen).toBe(false); + }); + }); + + describe("openModal / closeModal", () => { + it("openModal sets activeModal to given name", () => { + openModal("invite"); + expect(uiStore.getState().activeModal).toBe("invite"); + }); + + it("openModal overwrites existing modal", () => { + openModal("invite"); + openModal("confirm-delete"); + expect(uiStore.getState().activeModal).toBe("confirm-delete"); + }); + + it("closeModal clears activeModal", () => { + openModal("invite"); + closeModal(); + expect(uiStore.getState().activeModal).toBeNull(); + }); + + it("closeModal is safe when no modal is open", () => { + closeModal(); + expect(uiStore.getState().activeModal).toBeNull(); + }); + }); + + describe("setTheme", () => { + it("sets theme to light", () => { + setTheme("light"); + expect(uiStore.getState().theme).toBe("light"); + }); + + it("sets theme back to dark", () => { + setTheme("light"); + setTheme("dark"); + expect(uiStore.getState().theme).toBe("dark"); + }); + }); + + describe("toggleCategory / isCategoryCollapsed", () => { + it("collapses a category that is expanded", () => { + toggleCategory("general"); + expect(isCategoryCollapsed("general")).toBe(true); + }); + + it("expands a category that is collapsed", () => { + toggleCategory("general"); + toggleCategory("general"); + expect(isCategoryCollapsed("general")).toBe(false); + }); + + it("supports multiple independent categories", () => { + toggleCategory("general"); + toggleCategory("voice"); + expect(isCategoryCollapsed("general")).toBe(true); + expect(isCategoryCollapsed("voice")).toBe(true); + expect(isCategoryCollapsed("other")).toBe(false); + }); + + it("produces a new Set on each toggle", () => { + const before = uiStore.getState().collapsedCategories; + toggleCategory("general"); + const after = uiStore.getState().collapsedCategories; + expect(before).not.toBe(after); + }); + }); + + describe("subscribe", () => { + it("notifies on state changes", () => { + const listener = vi.fn(); + const unsub = uiStore.subscribe(listener); + toggleSidebar(); + uiStore.flush(); + expect(listener).toHaveBeenCalledTimes(1); + unsub(); + }); + + it("does not notify after unsubscribe", () => { + const listener = vi.fn(); + const unsub = uiStore.subscribe(listener); + unsub(); + toggleSidebar(); + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/user-bar.test.ts b/Client/tauri-client/tests/unit/user-bar.test.ts new file mode 100644 index 00000000..bbd17948 --- /dev/null +++ b/Client/tauri-client/tests/unit/user-bar.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { authStore } from "@stores/auth.store"; +import { openSettings } from "@stores/ui.store"; +import { createUserBar } from "@components/UserBar"; + +vi.mock("@stores/ui.store", () => ({ + openSettings: vi.fn(), + uiStore: { getState: () => ({}), subscribe: () => () => {} }, +})); + +function setAuthState( + user: { username: string } | null, + isAuthenticated: boolean, +): void { + authStore.setState(() => ({ + token: isAuthenticated ? "tok" : null, + user: user !== null + ? { id: 1, username: user.username, avatar: null, role: "member" } + : null, + serverName: "TestServer", + motd: null, + isAuthenticated, + })); +} + +describe("UserBar", () => { + let container: HTMLDivElement; + let comp: ReturnType<typeof createUserBar>; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + vi.clearAllMocks(); + }); + + afterEach(() => { + comp?.destroy?.(); + container.remove(); + // Reset auth store + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + }); + + it("mounts with user-bar class", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + expect(container.querySelector(".user-bar")).not.toBeNull(); + }); + + it("shows username from authStore", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + const name = container.querySelector(".ub-name"); + expect(name?.textContent).toBe("alice"); + }); + + it("shows first letter as avatar", () => { + setAuthState({ username: "bob" }, true); + comp = createUserBar(); + comp.mount(container); + + const avatar = container.querySelector(".ub-avatar span"); + expect(avatar?.textContent).toBe("B"); + }); + + it('shows "Online" when authenticated', () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + const status = container.querySelector(".ub-status"); + expect(status?.textContent).toBe("Online"); + }); + + it('shows "Offline" when not authenticated', () => { + setAuthState(null, false); + comp = createUserBar(); + comp.mount(container); + + const status = container.querySelector(".ub-status"); + expect(status?.textContent).toBe("Offline"); + }); + + it("settings button calls openSettings", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + const settingsBtn = container.querySelector('[title="Settings"]') as HTMLButtonElement; + settingsBtn.click(); + + expect(openSettings).toHaveBeenCalledOnce(); + }); + + it("does not render mute or deafen buttons", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + expect(container.querySelector('[title="Mute"]')).toBeNull(); + expect(container.querySelector('[title="Deafen"]')).toBeNull(); + }); + + it("destroy removes DOM and unsubscribes", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + expect(container.querySelector(".user-bar")).not.toBeNull(); + + comp.destroy?.(); + + expect(container.querySelector(".user-bar")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/vad.test.ts b/Client/tauri-client/tests/unit/vad.test.ts new file mode 100644 index 00000000..0caa4471 --- /dev/null +++ b/Client/tauri-client/tests/unit/vad.test.ts @@ -0,0 +1,35 @@ +/** + * Unit tests for VAD pure functions. + */ +import { describe, it, expect } from "vitest"; +import { sensitivityToThreshold } from "@lib/vad"; + +describe("sensitivityToThreshold", () => { + it("maps 0% sensitivity to max threshold (0.15)", () => { + expect(sensitivityToThreshold(0)).toBeCloseTo(0.15); + }); + + it("maps 100% sensitivity to zero threshold", () => { + expect(sensitivityToThreshold(100)).toBeCloseTo(0); + }); + + it("maps 50% sensitivity to half max threshold", () => { + expect(sensitivityToThreshold(50)).toBeCloseTo(0.075); + }); + + it("maps 75% sensitivity to quarter max threshold", () => { + expect(sensitivityToThreshold(75)).toBeCloseTo(0.0375); + }); + + it("is monotonically decreasing (higher sensitivity = lower threshold)", () => { + const t0 = sensitivityToThreshold(0); + const t25 = sensitivityToThreshold(25); + const t50 = sensitivityToThreshold(50); + const t75 = sensitivityToThreshold(75); + const t100 = sensitivityToThreshold(100); + expect(t0).toBeGreaterThan(t25); + expect(t25).toBeGreaterThan(t50); + expect(t50).toBeGreaterThan(t75); + expect(t75).toBeGreaterThan(t100); + }); +}); diff --git a/Client/tauri-client/tests/unit/voice-channel.test.ts b/Client/tauri-client/tests/unit/voice-channel.test.ts new file mode 100644 index 00000000..fc581813 --- /dev/null +++ b/Client/tauri-client/tests/unit/voice-channel.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createVoiceChannel } from "../../src/components/VoiceChannel"; +import { voiceStore } from "../../src/stores/voice.store"; +import { membersStore } from "../../src/stores/members.store"; +import type { VoiceUser } from "../../src/stores/voice.store"; + +function resetStores(): void { + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function setVoiceUsers(channelId: number, users: VoiceUser[]): void { + const userMap = new Map<number, VoiceUser>(); + for (const u of users) { + userMap.set(u.userId, u); + } + voiceStore.setState((prev) => { + const voiceUsers = new Map(prev.voiceUsers); + voiceUsers.set(channelId, userMap); + return { ...prev, voiceUsers }; + }); +} + +describe("VoiceChannel", () => { + let container: HTMLDivElement; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders channel name and voice icon", () => { + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const name = result.element.querySelector(".ch-name"); + expect(name?.textContent).toBe("Voice Lobby"); + + const icon = result.element.querySelector(".ch-icon"); + expect(icon).not.toBeNull(); + + result.destroy(); + }); + + it("calls onJoin when channel item is clicked", () => { + const onJoin = vi.fn(); + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin, + }); + container.appendChild(result.element); + + const channelItem = result.element.querySelector(".channel-item") as HTMLElement; + channelItem.click(); + expect(onJoin).toHaveBeenCalledOnce(); + + result.destroy(); + }); + + it("renders voice users from store", () => { + membersStore.setState((prev) => { + const members = new Map(prev.members); + members.set(10, { + id: 10, + username: "Alice", + avatar: null, + role: "member", + status: "online", + }); + return { ...prev, members }; + }); + + setVoiceUsers(1, [ + { + userId: 10, + username: "Alice", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ]); + + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const userItems = result.element.querySelectorAll(".voice-user-item"); + expect(userItems.length).toBe(1); + + const userName = result.element.querySelector(".vu-name"); + expect(userName?.textContent).toBe("Alice"); + + result.destroy(); + }); + + it("marks channel active when users are present", () => { + setVoiceUsers(1, [ + { + userId: 10, + username: "Alice", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ]); + + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const channelItem = result.element.querySelector(".channel-item"); + expect(channelItem!.classList.contains("active")).toBe(true); + + result.destroy(); + }); + + it("shows muted icon for muted users", () => { + setVoiceUsers(1, [ + { + userId: 10, + username: "Alice", + muted: true, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ]); + + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const mutedIcon = result.element.querySelector(".vu-muted"); + expect(mutedIcon).not.toBeNull(); + + result.destroy(); + }); + + it("shows speaking class for speaking users", () => { + setVoiceUsers(1, [ + { + userId: 10, + username: "Alice", + muted: false, + deafened: false, + speaking: true, + camera: false, + screenshare: false, + }, + ]); + + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const userItem = result.element.querySelector(".voice-user-item"); + expect(userItem!.classList.contains("speaking")).toBe(true); + + result.destroy(); + }); + + it("shows no users when channel is empty", () => { + const result = createVoiceChannel({ + channelId: 1, + channelName: "Voice Lobby", + onJoin: vi.fn(), + }); + container.appendChild(result.element); + + const userItems = result.element.querySelectorAll(".voice-user-item"); + expect(userItems.length).toBe(0); + + const channelItem = result.element.querySelector(".channel-item"); + expect(channelItem!.classList.contains("active")).toBe(false); + + result.destroy(); + }); +}); diff --git a/Client/tauri-client/tests/unit/voice-disconnect.test.ts b/Client/tauri-client/tests/unit/voice-disconnect.test.ts new file mode 100644 index 00000000..9568dc57 --- /dev/null +++ b/Client/tauri-client/tests/unit/voice-disconnect.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for voice channel disconnect behavior: + * - VoiceWidget disconnect button sends voice_leave to server + * - Logout sends voice_leave before disconnecting WS + * - beforeunload sends voice_leave when in voice channel + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { voiceStore, joinVoiceChannel, leaveVoiceChannel } from "../../src/stores/voice.store"; +import { authStore } from "../../src/stores/auth.store"; +import { channelsStore } from "../../src/stores/channels.store"; +import { membersStore } from "../../src/stores/members.store"; +import { uiStore } from "../../src/stores/ui.store"; +import { createVoiceWidget } from "../../src/components/VoiceWidget"; + +function resetStores(): void { + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, + })); + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + channelsStore.setState(() => ({ + channels: new Map(), + activeChannelId: null, + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); + uiStore.setState(() => ({ + sidebarCollapsed: false, + memberListVisible: true, + settingsOpen: false, + activeModal: null, + theme: "dark" as const, + connectionStatus: "disconnected" as const, + transientError: null, + persistentError: null, + collapsedCategories: new Set<string>(), + })); +} + +describe("Voice disconnect — VoiceWidget", () => { + let container: HTMLDivElement; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + it("calls onDisconnect when disconnect button is clicked", () => { + const onDisconnect = vi.fn(); + joinVoiceChannel(42); + + const widget = createVoiceWidget({ + onDisconnect, + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const disconnectBtn = container.querySelector('button[aria-label="Disconnect"]'); + expect(disconnectBtn).not.toBeNull(); + disconnectBtn!.dispatchEvent(new Event("click")); + + expect(onDisconnect).toHaveBeenCalledTimes(1); + widget.destroy?.(); + }); + + it("MainPage onDisconnect pattern sends voice_leave to server", () => { + // Simulate the MainPage wiring: onDisconnect should call leaveVoiceChannel + ws.send + const wsSend = vi.fn(); + + // Set up voice state — user is in a voice channel + authStore.setState((prev) => ({ + ...prev, + user: { id: 1, username: "testuser", avatar: null, role: "member" }, + isAuthenticated: true, + })); + joinVoiceChannel(42); + + // Simulate the MainPage onDisconnect callback + const onDisconnect = () => { + leaveVoiceChannel(); + wsSend({ type: "voice_leave", payload: {} }); + }; + + onDisconnect(); + + expect(wsSend).toHaveBeenCalledWith({ type: "voice_leave", payload: {} }); + expect(voiceStore.getState().currentChannelId).toBeNull(); + }); +}); + +describe("Voice disconnect — logout cleanup", () => { + beforeEach(() => { + resetStores(); + }); + + it("sends voice_leave before ws.disconnect on logout when in voice channel", () => { + const wsSend = vi.fn(); + const wsDisconnect = vi.fn(); + const callOrder: string[] = []; + + wsSend.mockImplementation(() => { callOrder.push("send"); }); + wsDisconnect.mockImplementation(() => { callOrder.push("disconnect"); }); + + // User is authenticated and in a voice channel + authStore.setState((prev) => ({ + ...prev, + user: { id: 1, username: "testuser", avatar: null, role: "member" }, + isAuthenticated: true, + })); + joinVoiceChannel(42); + + // Simulate the main.ts logout handler + const voice = voiceStore.getState(); + if (voice.currentChannelId !== null) { + wsSend({ type: "voice_leave", payload: {} }); + leaveVoiceChannel(); + } + wsDisconnect(); + + expect(wsSend).toHaveBeenCalledWith({ type: "voice_leave", payload: {} }); + expect(wsDisconnect).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(["send", "disconnect"]); + expect(voiceStore.getState().currentChannelId).toBeNull(); + }); + + it("does not send voice_leave on logout when not in voice channel", () => { + const wsSend = vi.fn(); + const wsDisconnect = vi.fn(); + + authStore.setState((prev) => ({ + ...prev, + isAuthenticated: true, + })); + + // Not in a voice channel + const voice = voiceStore.getState(); + if (voice.currentChannelId !== null) { + wsSend({ type: "voice_leave", payload: {} }); + leaveVoiceChannel(); + } + wsDisconnect(); + + expect(wsSend).not.toHaveBeenCalled(); + expect(wsDisconnect).toHaveBeenCalledTimes(1); + }); +}); + +describe("Voice disconnect — beforeunload", () => { + beforeEach(() => { + resetStores(); + }); + + it("sends voice_leave on beforeunload when in voice channel", () => { + const wsSend = vi.fn(); + + joinVoiceChannel(42); + + // Simulate the beforeunload handler from main.ts + const voice = voiceStore.getState(); + if (voice.currentChannelId !== null) { + wsSend({ type: "voice_leave", payload: {} }); + } + + expect(wsSend).toHaveBeenCalledWith({ type: "voice_leave", payload: {} }); + }); + + it("does not send voice_leave on beforeunload when not in voice channel", () => { + const wsSend = vi.fn(); + + // Not in a voice channel + const voice = voiceStore.getState(); + if (voice.currentChannelId !== null) { + wsSend({ type: "voice_leave", payload: {} }); + } + + expect(wsSend).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/voice-widget.test.ts b/Client/tauri-client/tests/unit/voice-widget.test.ts new file mode 100644 index 00000000..236f1cad --- /dev/null +++ b/Client/tauri-client/tests/unit/voice-widget.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createVoiceWidget } from "../../src/components/VoiceWidget"; +import { voiceStore } from "../../src/stores/voice.store"; +import { channelsStore } from "../../src/stores/channels.store"; +import { membersStore } from "../../src/stores/members.store"; +import type { VoiceUser } from "../../src/stores/voice.store"; + +function resetStores(): void { + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, + })); + channelsStore.setState(() => ({ + channels: new Map(), + activeChannelId: null, + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); +} + +function setVoiceChannel(channelId: number, users: VoiceUser[]): void { + const userMap = new Map<number, VoiceUser>(); + for (const u of users) { + userMap.set(u.userId, u); + } + const voiceUsers = new Map<number, ReadonlyMap<number, VoiceUser>>(); + voiceUsers.set(channelId, userMap); + + voiceStore.setState((prev) => ({ + ...prev, + currentChannelId: channelId, + voiceUsers, + })); +} + +describe("VoiceWidget", () => { + let container: HTMLDivElement; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders hidden when not connected to a voice channel", () => { + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const root = container.querySelector('[data-testid="voice-widget"]'); + expect(root).not.toBeNull(); + expect(root!.classList.contains("visible")).toBe(false); + + widget.destroy?.(); + }); + + it("shows visible when connected to a voice channel", () => { + channelsStore.setState((prev) => { + const channels = new Map(prev.channels); + channels.set(1, { + id: 1, + name: "Voice Lobby", + type: "voice", + category: null, + position: 0, + unreadCount: 0, + lastMessageId: null, + }); + return { ...prev, channels }; + }); + + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const root = container.querySelector('[data-testid="voice-widget"]'); + expect(root!.classList.contains("visible")).toBe(true); + + widget.destroy?.(); + }); + + it("displays channel name", () => { + channelsStore.setState((prev) => { + const channels = new Map(prev.channels); + channels.set(1, { + id: 1, + name: "Voice Lobby", + type: "voice", + category: null, + position: 0, + unreadCount: 0, + lastMessageId: null, + }); + return { ...prev, channels }; + }); + + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const channelName = container.querySelector(".vw-channel"); + expect(channelName?.textContent).toBe("Voice Lobby"); + + widget.destroy?.(); + }); + + it("does not render voice users (users only shown in sidebar)", () => { + channelsStore.setState((prev) => { + const channels = new Map(prev.channels); + channels.set(1, { + id: 1, + name: "Voice Lobby", + type: "voice", + category: null, + position: 0, + unreadCount: 0, + lastMessageId: null, + }); + return { ...prev, channels }; + }); + + setVoiceChannel(1, [ + { + userId: 10, + username: "Alice", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ]); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const userItems = container.querySelectorAll(".voice-user-item"); + expect(userItems.length).toBe(0); + + widget.destroy?.(); + }); + + it("calls onMuteToggle when mute button is clicked", () => { + const onMuteToggle = vi.fn(); + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle, + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement; + expect(muteBtn).not.toBeNull(); + muteBtn.click(); + expect(onMuteToggle).toHaveBeenCalledOnce(); + + widget.destroy?.(); + }); + + it("calls onDisconnect when disconnect button is clicked", () => { + const onDisconnect = vi.fn(); + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect, + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const disconnectBtn = container.querySelector('[aria-label="Disconnect"]') as HTMLButtonElement; + expect(disconnectBtn).not.toBeNull(); + disconnectBtn.click(); + expect(onDisconnect).toHaveBeenCalledOnce(); + + widget.destroy?.(); + }); + + it("toggles mute active state based on store", () => { + setVoiceChannel(1, []); + voiceStore.setState((prev) => ({ ...prev, localMuted: true })); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement; + expect(muteBtn.classList.contains("active-ctrl")).toBe(true); + + widget.destroy?.(); + }); + + it("cleans up on destroy", () => { + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + const root = container.querySelector('[data-testid="voice-widget"]'); + expect(root).not.toBeNull(); + + widget.destroy?.(); + expect(container.querySelector('[data-testid="voice-widget"]')).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/voice.store.test.ts b/Client/tauri-client/tests/unit/voice.store.test.ts new file mode 100644 index 00000000..06ff0892 --- /dev/null +++ b/Client/tauri-client/tests/unit/voice.store.test.ts @@ -0,0 +1,407 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + voiceStore, + setVoiceStates, + updateVoiceState, + removeVoiceUser, + joinVoiceChannel, + leaveVoiceChannel, + setLocalMuted, + setLocalDeafened, + setLocalCamera, + setLocalScreenshare, + setLocalSpeaking, + setSpeakers, + getChannelVoiceUsers, +} from "../../src/stores/voice.store"; +import type { + ReadyVoiceState, + VoiceStatePayload, + VoiceLeavePayload, +} from "../../src/lib/types"; +import { authStore } from "../../src/stores/auth.store"; + +function resetStore(): void { + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + localCamera: false, + localScreenshare: false, + })); +} + +const VOICE_STATE_1: ReadyVoiceState = { + channel_id: 10, + user_id: 1, + muted: false, + deafened: false, +}; + +const VOICE_STATE_2: ReadyVoiceState = { + channel_id: 10, + user_id: 2, + muted: true, + deafened: false, +}; + +const VOICE_STATE_3: ReadyVoiceState = { + channel_id: 20, + user_id: 3, + muted: false, + deafened: true, +}; + +const FULL_VOICE_PAYLOAD: VoiceStatePayload = { + channel_id: 10, + user_id: 5, + username: "dave", + muted: false, + deafened: false, + speaking: true, + camera: false, + screenshare: false, +}; + +describe("voice store", () => { + beforeEach(() => { + resetStore(); + }); + + describe("initial state", () => { + it("has null currentChannelId", () => { + expect(voiceStore.getState().currentChannelId).toBeNull(); + }); + + it("has empty voiceUsers map", () => { + expect(voiceStore.getState().voiceUsers.size).toBe(0); + }); + + it("has localMuted false", () => { + expect(voiceStore.getState().localMuted).toBe(false); + }); + + it("has localDeafened false", () => { + expect(voiceStore.getState().localDeafened).toBe(false); + }); + + it("has localCamera false", () => { + expect(voiceStore.getState().localCamera).toBe(false); + }); + + it("has localScreenshare false", () => { + expect(voiceStore.getState().localScreenshare).toBe(false); + }); + }); + + describe("setVoiceStates", () => { + it("populates voice users grouped by channel", () => { + setVoiceStates([VOICE_STATE_1, VOICE_STATE_2, VOICE_STATE_3]); + const state = voiceStore.getState(); + expect(state.voiceUsers.size).toBe(2); // 2 channels + expect(state.voiceUsers.get(10)?.size).toBe(2); + expect(state.voiceUsers.get(20)?.size).toBe(1); + }); + + it("maps muted/deafened from ready payload", () => { + setVoiceStates([VOICE_STATE_2]); + const user = voiceStore.getState().voiceUsers.get(10)?.get(2); + expect(user?.muted).toBe(true); + expect(user?.deafened).toBe(false); + }); + + it("sets default false for speaking, camera, screenshare", () => { + setVoiceStates([VOICE_STATE_1]); + const user = voiceStore.getState().voiceUsers.get(10)?.get(1); + expect(user?.speaking).toBe(false); + expect(user?.camera).toBe(false); + expect(user?.screenshare).toBe(false); + }); + + it("replaces existing voice states entirely", () => { + setVoiceStates([VOICE_STATE_1, VOICE_STATE_2]); + setVoiceStates([VOICE_STATE_3]); + const state = voiceStore.getState(); + expect(state.voiceUsers.size).toBe(1); + expect(state.voiceUsers.has(10)).toBe(false); + expect(state.voiceUsers.has(20)).toBe(true); + }); + }); + + describe("updateVoiceState", () => { + it("adds a new user to a channel", () => { + updateVoiceState(FULL_VOICE_PAYLOAD); + const user = voiceStore.getState().voiceUsers.get(10)?.get(5); + expect(user).toEqual({ + userId: 5, + username: "dave", + muted: false, + deafened: false, + speaking: true, + camera: false, + screenshare: false, + }); + }); + + it("updates an existing user in the same channel", () => { + updateVoiceState(FULL_VOICE_PAYLOAD); + updateVoiceState({ ...FULL_VOICE_PAYLOAD, muted: true, speaking: false }); + const user = voiceStore.getState().voiceUsers.get(10)?.get(5); + expect(user?.muted).toBe(true); + expect(user?.speaking).toBe(false); + }); + + it("does not affect other channels", () => { + setVoiceStates([VOICE_STATE_3]); + updateVoiceState(FULL_VOICE_PAYLOAD); + expect(voiceStore.getState().voiceUsers.get(20)?.size).toBe(1); + }); + + it("produces a new state object", () => { + const before = voiceStore.getState(); + updateVoiceState(FULL_VOICE_PAYLOAD); + expect(voiceStore.getState()).not.toBe(before); + }); + }); + + describe("removeVoiceUser", () => { + it("removes a user from a channel", () => { + setVoiceStates([VOICE_STATE_1, VOICE_STATE_2]); + const payload: VoiceLeavePayload = { channel_id: 10, user_id: 1 }; + removeVoiceUser(payload); + expect(voiceStore.getState().voiceUsers.get(10)?.has(1)).toBe(false); + expect(voiceStore.getState().voiceUsers.get(10)?.size).toBe(1); + }); + + it("removes channel entry when last user leaves", () => { + setVoiceStates([VOICE_STATE_3]); + removeVoiceUser({ channel_id: 20, user_id: 3 }); + expect(voiceStore.getState().voiceUsers.has(20)).toBe(false); + }); + + it("is a no-op for non-existent user", () => { + setVoiceStates([VOICE_STATE_1]); + const before = voiceStore.getState(); + removeVoiceUser({ channel_id: 10, user_id: 999 }); + expect(voiceStore.getState()).toBe(before); + }); + + it("is a no-op for non-existent channel", () => { + const before = voiceStore.getState(); + removeVoiceUser({ channel_id: 999, user_id: 1 }); + expect(voiceStore.getState()).toBe(before); + }); + }); + + describe("joinVoiceChannel / leaveVoiceChannel", () => { + it("joinVoiceChannel sets currentChannelId", () => { + joinVoiceChannel(42); + expect(voiceStore.getState().currentChannelId).toBe(42); + }); + + it("joinVoiceChannel overwrites previous channel", () => { + joinVoiceChannel(42); + joinVoiceChannel(99); + expect(voiceStore.getState().currentChannelId).toBe(99); + }); + + it("leaveVoiceChannel clears currentChannelId", () => { + joinVoiceChannel(42); + leaveVoiceChannel(); + expect(voiceStore.getState().currentChannelId).toBeNull(); + }); + + it("leaveVoiceChannel is safe when not in a channel", () => { + leaveVoiceChannel(); + expect(voiceStore.getState().currentChannelId).toBeNull(); + }); + }); + + describe("setLocalMuted / setLocalDeafened", () => { + it("setLocalMuted sets muted to true", () => { + setLocalMuted(true); + expect(voiceStore.getState().localMuted).toBe(true); + }); + + it("setLocalMuted sets muted to false", () => { + setLocalMuted(true); + setLocalMuted(false); + expect(voiceStore.getState().localMuted).toBe(false); + }); + + it("setLocalDeafened sets deafened to true", () => { + setLocalDeafened(true); + expect(voiceStore.getState().localDeafened).toBe(true); + }); + + it("setLocalDeafened sets deafened to false", () => { + setLocalDeafened(true); + setLocalDeafened(false); + expect(voiceStore.getState().localDeafened).toBe(false); + }); + }); + + describe("setLocalCamera / setLocalScreenshare", () => { + it("setLocalCamera sets camera to true", () => { + setLocalCamera(true); + expect(voiceStore.getState().localCamera).toBe(true); + }); + + it("setLocalCamera sets camera to false", () => { + setLocalCamera(true); + setLocalCamera(false); + expect(voiceStore.getState().localCamera).toBe(false); + }); + + it("setLocalScreenshare sets screenshare to true", () => { + setLocalScreenshare(true); + expect(voiceStore.getState().localScreenshare).toBe(true); + }); + + it("setLocalScreenshare sets screenshare to false", () => { + setLocalScreenshare(true); + setLocalScreenshare(false); + expect(voiceStore.getState().localScreenshare).toBe(false); + }); + }); + + describe("setLocalSpeaking", () => { + it("updates speaking state for current user in active channel", () => { + // Set up: current user (id=1) in channel 10 + authStore.setState(() => ({ + token: "t", + user: { id: 1, username: "me", avatar: "", role: "member" }, + serverName: "s", + motd: "", + isAuthenticated: true, + })); + setVoiceStates([VOICE_STATE_1]); + joinVoiceChannel(10); + + setLocalSpeaking(true); + const user = voiceStore.getState().voiceUsers.get(10)?.get(1); + expect(user?.speaking).toBe(true); + + setLocalSpeaking(false); + const userAfter = voiceStore.getState().voiceUsers.get(10)?.get(1); + expect(userAfter?.speaking).toBe(false); + + // Cleanup + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + }); + + it("is a no-op when not in a voice channel", () => { + const before = voiceStore.getState(); + setLocalSpeaking(true); + expect(voiceStore.getState()).toBe(before); + }); + }); + + describe("getChannelVoiceUsers", () => { + it("returns all voice users for a channel", () => { + setVoiceStates([VOICE_STATE_1, VOICE_STATE_2]); + const users = getChannelVoiceUsers(10); + expect(users).toHaveLength(2); + expect(users.map((u) => u.userId).sort()).toEqual([1, 2]); + }); + + it("returns empty array for unknown channel", () => { + expect(getChannelVoiceUsers(999)).toHaveLength(0); + }); + + it("returns empty array when no voice states exist", () => { + expect(getChannelVoiceUsers(10)).toHaveLength(0); + }); + }); + + describe("setSpeakers", () => { + beforeEach(() => { + // Set up: user 1 (local) and user 2 (remote) in channel 10 + authStore.setState(() => ({ + token: "t", + user: { id: 1, username: "me", avatar: "", role: "member" }, + serverName: "s", + motd: "", + isAuthenticated: true, + })); + setVoiceStates([VOICE_STATE_1, VOICE_STATE_2]); + joinVoiceChannel(10); + }); + + afterEach(() => { + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + }); + + it("does NOT overwrite local user's speaking state", () => { + // Local VAD says we're speaking + setLocalSpeaking(true); + expect(voiceStore.getState().voiceUsers.get(10)?.get(1)?.speaking).toBe(true); + + // Server says we're NOT speaking — local user should be unchanged + setSpeakers({ channel_id: 10, speakers: [2], threshold_mode: "forwarding" }); + expect(voiceStore.getState().voiceUsers.get(10)?.get(1)?.speaking).toBe(true); + }); + + it("updates remote users' speaking state from server", () => { + // Server says user 2 is speaking + setSpeakers({ channel_id: 10, speakers: [2], threshold_mode: "forwarding" }); + expect(voiceStore.getState().voiceUsers.get(10)?.get(2)?.speaking).toBe(true); + + // Server says nobody is speaking — remote user updated, local unchanged + setSpeakers({ channel_id: 10, speakers: [], threshold_mode: "forwarding" }); + expect(voiceStore.getState().voiceUsers.get(10)?.get(2)?.speaking).toBe(false); + }); + }); + + describe("clearAuth voice cleanup", () => { + it("calls leaveVoice to clean up session state", async () => { + // We test indirectly: clearAuth should call leaveVoice(false) which + // is idempotent, and then resetVoiceStore which clears the store. + const { clearAuth } = await import("../../src/stores/auth.store"); + + joinVoiceChannel(42); + setLocalMuted(true); + expect(voiceStore.getState().currentChannelId).toBe(42); + + clearAuth(); + + // After clearAuth, voice store should be fully reset + expect(voiceStore.getState().currentChannelId).toBeNull(); + expect(voiceStore.getState().localMuted).toBe(false); + expect(voiceStore.getState().voiceUsers.size).toBe(0); + }); + }); + + describe("subscribe", () => { + it("notifies on state changes", () => { + const listener = vi.fn(); + const unsub = voiceStore.subscribe(listener); + joinVoiceChannel(42); + voiceStore.flush(); + expect(listener).toHaveBeenCalledTimes(1); + unsub(); + }); + + it("does not notify after unsubscribe", () => { + const listener = vi.fn(); + const unsub = voiceStore.subscribe(listener); + unsub(); + joinVoiceChannel(42); + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/webrtc.test.ts b/Client/tauri-client/tests/unit/webrtc.test.ts new file mode 100644 index 00000000..c361684d --- /dev/null +++ b/Client/tauri-client/tests/unit/webrtc.test.ts @@ -0,0 +1,247 @@ +/** + * Unit tests for WebRTC SDP munging (applyOpusSettings) and + * replaceTrack logic via mocked RTCPeerConnection. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createWebRtcService } from "../../src/lib/webrtc"; + +// The applyOpusSettings function is module-private, so we test the +// SDP manipulation patterns it implements as string transformations. +// This validates the core logic without needing a real PeerConnection. + +describe("SDP Opus settings", () => { + const baseSdp = [ + "v=0", + "o=- 0 0 IN IP4 127.0.0.1", + "s=-", + "t=0 0", + "m=audio 9 UDP/TLS/RTP/SAVPF 111", + "a=rtpmap:111 opus/48000/2", + "a=fmtp:111 minptime=10;usedtx=1", + "a=mid:0", + "", + ].join("\r\n"); + + // Replicate applyOpusSettings logic for testing + function applyOpusSettings(sdp: string, bitrate: number | undefined): string { + const lines = sdp.split("\r\n"); + const result: string[] = []; + let inAudioSection = false; + let bitrateInserted = false; + + for (const line of lines) { + let out = line; + if (out.startsWith("m=audio")) { + inAudioSection = true; + bitrateInserted = false; + } else if (out.startsWith("m=")) { + inAudioSection = false; + } + if (out.startsWith("a=fmtp:111 ")) { + if (!out.includes("useinbandfec=")) { + out += ";useinbandfec=1"; + } + } + result.push(out); + if (inAudioSection && !bitrateInserted && bitrate !== undefined && out.startsWith("m=audio")) { + result.push(`b=AS:${Math.round(bitrate / 1000)}`); + bitrateInserted = true; + } + } + return result.join("\r\n"); + } + + it("adds useinbandfec to Opus fmtp line", () => { + const result = applyOpusSettings(baseSdp, undefined); + expect(result).toContain("a=fmtp:111 minptime=10;usedtx=1;useinbandfec=1"); + }); + + it("does not duplicate useinbandfec if already present", () => { + const sdpWithFec = baseSdp.replace( + "a=fmtp:111 minptime=10;usedtx=1", + "a=fmtp:111 minptime=10;usedtx=1;useinbandfec=1", + ); + const result = applyOpusSettings(sdpWithFec, undefined); + const matches = result.match(/useinbandfec/g); + expect(matches).toHaveLength(1); + }); + + it("inserts b=AS bandwidth line after m=audio", () => { + const result = applyOpusSettings(baseSdp, 64000); + const lines = result.split("\r\n"); + const mAudioIdx = lines.findIndex((l) => l.startsWith("m=audio")); + expect(lines[mAudioIdx + 1]).toBe("b=AS:64"); + }); + + it("calculates b=AS correctly for different bitrates", () => { + expect(applyOpusSettings(baseSdp, 32000)).toContain("b=AS:32"); + expect(applyOpusSettings(baseSdp, 128000)).toContain("b=AS:128"); + }); + + it("does not insert b=AS when bitrate is undefined", () => { + const result = applyOpusSettings(baseSdp, undefined); + expect(result).not.toContain("b=AS:"); + }); + + it("handles multi-section SDP (audio + video)", () => { + const multiSdp = [ + "v=0", + "o=- 0 0 IN IP4 127.0.0.1", + "s=-", + "t=0 0", + "m=audio 9 UDP/TLS/RTP/SAVPF 111", + "a=fmtp:111 minptime=10", + "m=video 9 UDP/TLS/RTP/SAVPF 96", + "a=rtpmap:96 VP8/90000", + "", + ].join("\r\n"); + const result = applyOpusSettings(multiSdp, 64000); + // b=AS should appear after m=audio, not after m=video + const lines = result.split("\r\n"); + const audioIdx = lines.findIndex((l) => l.startsWith("m=audio")); + const videoIdx = lines.findIndex((l) => l.startsWith("m=video")); + const basIdx = lines.findIndex((l) => l.startsWith("b=AS:")); + expect(basIdx).toBeGreaterThan(audioIdx); + expect(basIdx).toBeLessThan(videoIdx); + }); +}); + +// --------------------------------------------------------------------------- +// replaceTrack tests — uses mocked RTCPeerConnection +// --------------------------------------------------------------------------- + +/** Create a minimal mock MediaStreamTrack. */ +function mockTrack(id = "track-1"): MediaStreamTrack { + return { + id, + kind: "audio", + enabled: true, + stop: vi.fn(), + readyState: "live", + } as unknown as MediaStreamTrack; +} + +/** Create a minimal mock MediaStream. */ +function mockStream(tracks: MediaStreamTrack[] = [mockTrack()]): MediaStream { + return { + id: "stream-1", + getTracks: () => [...tracks], + getAudioTracks: () => tracks.filter((t) => t.kind === "audio"), + getVideoTracks: () => [], + addTrack: vi.fn(), + removeTrack: vi.fn(), + clone: vi.fn(), + active: true, + } as unknown as MediaStream; +} + +describe("replaceTrack", () => { + let originalRTCPeerConnection: typeof RTCPeerConnection; + let mockReplaceTrack: ReturnType<typeof vi.fn>; + let mockAddTrack: ReturnType<typeof vi.fn>; + let mockRemoveTrack: ReturnType<typeof vi.fn>; + let mockSender: RTCRtpSender; + + beforeEach(() => { + originalRTCPeerConnection = globalThis.RTCPeerConnection; + + mockReplaceTrack = vi.fn().mockResolvedValue(undefined); + mockSender = { + track: mockTrack(), + replaceTrack: mockReplaceTrack, + getParameters: vi.fn().mockReturnValue({}), + setParameters: vi.fn(), + } as unknown as RTCRtpSender; + + mockAddTrack = vi.fn().mockReturnValue(mockSender); + mockRemoveTrack = vi.fn(); + + const MockPeerConnection = vi.fn().mockImplementation(() => ({ + addTrack: mockAddTrack, + removeTrack: mockRemoveTrack, + close: vi.fn(), + signalingState: "stable", + connectionState: "new", + iceConnectionState: "new", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + createOffer: vi.fn().mockResolvedValue({ type: "offer", sdp: "v=0\r\n" }), + createAnswer: vi.fn().mockResolvedValue({ type: "answer", sdp: "v=0\r\n" }), + setLocalDescription: vi.fn().mockResolvedValue(undefined), + setRemoteDescription: vi.fn().mockResolvedValue(undefined), + addIceCandidate: vi.fn().mockResolvedValue(undefined), + })); + + globalThis.RTCPeerConnection = MockPeerConnection as unknown as typeof RTCPeerConnection; + }); + + afterEach(() => { + globalThis.RTCPeerConnection = originalRTCPeerConnection; + }); + + it("swaps track on existing sender via sender.replaceTrack", async () => { + const service = createWebRtcService(); + const stream1 = mockStream(); + service.createConnection({ iceServers: [] }); + + // Initial attach — sets up senders + service.setLocalStream(stream1); + expect(mockAddTrack).toHaveBeenCalledTimes(1); + + // Replace with new stream — should use replaceTrack, NOT removeTrack+addTrack + const newTrack = mockTrack("track-2"); + const stream2 = mockStream([newTrack]); + await service.replaceTrack(stream2); + + expect(mockReplaceTrack).toHaveBeenCalledWith(newTrack); + expect(mockRemoveTrack).not.toHaveBeenCalled(); + // addTrack should still be 1 (from initial setLocalStream, not from replaceTrack) + expect(mockAddTrack).toHaveBeenCalledTimes(1); + + service.destroy(); + }); + + it("falls back to addTrack when no senders exist", async () => { + const service = createWebRtcService(); + service.createConnection({ iceServers: [] }); + + // No setLocalStream — no existing senders + const newTrack = mockTrack("track-new"); + const stream = mockStream([newTrack]); + await service.replaceTrack(stream); + + // Should fall back to addTrack + expect(mockAddTrack).toHaveBeenCalledTimes(1); + expect(mockReplaceTrack).not.toHaveBeenCalled(); + + service.destroy(); + }); + + it("applies mute+silence state to new track after replaceTrack", async () => { + const service = createWebRtcService(); + // Create a track that the mock sender will reference + const senderTrack = mockTrack("track-sender"); + Object.defineProperty(mockSender, "track", { value: senderTrack, writable: true, configurable: true }); + + const stream1 = mockStream([mockTrack("track-1")]); + service.createConnection({ iceServers: [] }); + service.setLocalStream(stream1); + + // Mute the stream — operates on sender.track + service.setMuted(true); + expect(senderTrack.enabled).toBe(false); + + // Replace track — the new track should also get mute state applied + const track2 = mockTrack("track-2"); + track2.enabled = true; // starts enabled + const stream2 = mockStream([track2]); + // After replaceTrack, the sender's track reference updates + Object.defineProperty(mockSender, "track", { value: track2, writable: true, configurable: true }); + await service.replaceTrack(stream2); + + // applyTrackEnabled runs after replaceTrack — should mute the new track + expect(track2.enabled).toBe(false); + + service.destroy(); + }); +}); diff --git a/Client/tauri-client/tests/unit/window-state.test.ts b/Client/tauri-client/tests/unit/window-state.test.ts new file mode 100644 index 00000000..3fb08ae2 --- /dev/null +++ b/Client/tauri-client/tests/unit/window-state.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock logger +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +// Mock Tauri APIs as unavailable by default +vi.mock("@tauri-apps/api/core", () => { + throw new Error("Not in Tauri"); +}); + +vi.mock("@tauri-apps/api/window", () => { + throw new Error("Not in Tauri"); +}); + +describe("window-state", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("initWindowState returns a cleanup function when Tauri unavailable", async () => { + const { initWindowState } = await import("@lib/window-state"); + const cleanup = await initWindowState(); + expect(typeof cleanup).toBe("function"); + // Should be a no-op + cleanup(); + }); +}); diff --git a/Client/tauri-client/tests/unit/ws.test.ts b/Client/tauri-client/tests/unit/ws.test.ts new file mode 100644 index 00000000..aecff2cf --- /dev/null +++ b/Client/tauri-client/tests/unit/ws.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { ConnectionState } from "../../src/lib/ws"; + +// Mock Tauri APIs — vi.hoisted ensures availability when vi.mock runs +const { mockInvoke, mockListen, eventHandlers } = vi.hoisted(() => { + const handlers = new Map<string, Array<(e: { payload: unknown }) => void>>(); + return { + mockInvoke: vi.fn(), + mockListen: vi.fn(async (event: string, handler: (e: { payload: unknown }) => void) => { + if (!handlers.has(event)) handlers.set(event, []); + handlers.get(event)!.push(handler); + return () => { + const arr = handlers.get(event); + if (arr) { + const idx = arr.indexOf(handler); + if (idx >= 0) arr.splice(idx, 1); + } + }; + }), + eventHandlers: handlers, + }; +}); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mockInvoke, +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: mockListen, +})); + +// Mock crypto.randomUUID +vi.stubGlobal("crypto", { + randomUUID: () => "test-uuid-1234", +}); + +// Suppress console output +vi.spyOn(console, "debug").mockImplementation(() => {}); +vi.spyOn(console, "info").mockImplementation(() => {}); +vi.spyOn(console, "warn").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); + +// Import after mocks are set up +import { createWsClient } from "../../src/lib/ws"; + +/** Simulate Tauri emitting an event to JS */ +function emitTauriEvent(event: string, payload: unknown): void { + const handlers = eventHandlers.get(event); + if (handlers) { + for (const h of handlers) { + h({ payload }); + } + } +} + +describe("WebSocket Client (Tauri proxy)", () => { + let client: ReturnType<typeof createWsClient>; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + vi.useRealTimers(); + }); + + it("starts in disconnected state", () => { + expect(client.getState()).toBe("disconnected"); + }); + + it("transitions to connecting on connect", async () => { + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + client.connect({ host: "localhost:8443", token: "test-token" }); + await vi.advanceTimersByTimeAsync(10); + expect(states).toContain("connecting"); + }); + + it("calls ws_connect with correct URL", async () => { + client.connect({ host: "localhost:8443", token: "test-token" }); + await vi.advanceTimersByTimeAsync(10); + expect(mockInvoke).toHaveBeenCalledWith("ws_connect", { + url: "wss://localhost:8443/api/v1/ws", + }); + }); + + it("sends auth message when Rust reports open", async () => { + client.connect({ host: "localhost:8443", token: "test-token" }); + await vi.advanceTimersByTimeAsync(10); + + // Simulate Rust reporting connection open + emitTauriEvent("ws-state", "open"); + + // Should call ws_send with auth message + expect(mockInvoke).toHaveBeenCalledWith( + "ws_send", + expect.objectContaining({ + message: expect.stringContaining('"type":"auth"'), + }), + ); + }); + + it("transitions to connected on auth_ok", async () => { + client.connect({ host: "localhost:8443", token: "test-token" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + + emitTauriEvent("ws-message", JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "alex", avatar: null, role: "admin" }, + server_name: "Test", + motd: "Hello", + }, + })); + + expect(states).toContain("connected"); + }); + + it("dispatches messages to typed listeners", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (payload) => messages.push(payload)); + + emitTauriEvent("ws-message", JSON.stringify({ + type: "chat_message", + payload: { + id: 1, channel_id: 5, + user: { id: 1, username: "alex", avatar: null }, + content: "Hello", + reply_to: null, attachments: [], + timestamp: "2026-03-14T10:00:00Z", + }, + })); + + expect(messages).toHaveLength(1); + }); + + it("unsubscribe removes listener", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + const unsub = client.on("chat_message", (payload) => messages.push(payload)); + unsub(); + + emitTauriEvent("ws-message", JSON.stringify({ + type: "chat_message", + payload: { + id: 1, channel_id: 5, + user: { id: 1, username: "alex", avatar: null }, + content: "Hello", + reply_to: null, attachments: [], + timestamp: "2026-03-14T10:00:00Z", + }, + })); + + expect(messages).toHaveLength(0); + }); + + it("auth_error does NOT trigger reconnect", async () => { + client.connect({ host: "localhost:8443", token: "bad-token" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const authErrors: unknown[] = []; + client.on("auth_error", (payload) => authErrors.push(payload)); + + emitTauriEvent("ws-message", JSON.stringify({ + type: "auth_error", + payload: { message: "Invalid token" }, + })); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(authErrors).toHaveLength(1); + expect(client.getState()).toBe("disconnected"); + }); + + it("reconnects on unexpected close with backoff", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent("ws-message", JSON.stringify({ + type: "auth_ok", + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", motd: "", + }, + })); + + const states: ConnectionState[] = []; + client.onStateChange((s) => states.push(s)); + + // Simulate connection closed by Rust proxy + emitTauriEvent("ws-state", "closed"); + + expect(states).toContain("reconnecting"); + + // After 1s backoff, should call ws_connect again + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1100); + expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything()); + }); + + it("send returns correlation ID", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const id = client.send({ + type: "chat_send", + payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] }, + }); + + expect(id).toBe("test-uuid-1234"); + }); + + it("drops oversized messages", async () => { + client.connect({ + host: "localhost:8443", + token: "t", + maxMessageSizeBytes: 50, + }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + const bigData = JSON.stringify({ + type: "chat_message", + payload: { + id: 1, channel_id: 1, + user: { id: 1, username: "a", avatar: null }, + content: "x".repeat(100), + reply_to: null, attachments: [], + timestamp: "2026-01-01T00:00:00Z", + }, + }); + + emitTauriEvent("ws-message", bigData); + expect(messages).toHaveLength(0); + }); + + it("drops malformed JSON", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const messages: unknown[] = []; + client.on("chat_message", (p) => messages.push(p)); + + emitTauriEvent("ws-message", "not-json{{{"); + expect(messages).toHaveLength(0); + }); + + it("disconnect prevents reconnect", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + + client.disconnect(); + + await vi.advanceTimersByTimeAsync(60_000); + expect(client.getState()).toBe("disconnected"); + }); +}); diff --git a/Client/tauri-client/tsconfig.json b/Client/tauri-client/tsconfig.json new file mode 100644 index 00000000..b7e3492c --- /dev/null +++ b/Client/tauri-client/tsconfig.json @@ -0,0 +1,45 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "lib": [ + "ES2021", + "DOM", + "DOM.Iterable" + ], + "paths": { + "@lib/*": [ + "./src/lib/*" + ], + "@stores/*": [ + "./src/stores/*" + ], + "@components/*": [ + "./src/components/*" + ], + "@pages/*": [ + "./src/pages/*" + ], + "@styles/*": [ + "./src/styles/*" + ] + }, + "baseUrl": "." + }, + "include": [ + "src", + "tests" + ], + "exclude": [ + "tests/e2e" + ] +} diff --git a/Client/tauri-client/vite.config.ts b/Client/tauri-client/vite.config.ts new file mode 100644 index 00000000..6f58351a --- /dev/null +++ b/Client/tauri-client/vite.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, type Plugin } from "vite"; +import { resolve } from "path"; + +const host = process.env.TAURI_DEV_HOST; + +/** Strip crossorigin attributes — Tauri serves via custom protocol. */ +function stripCrossOrigin(): Plugin { + return { + name: "strip-crossorigin", + transformIndexHtml(html) { + return html.replace(/\s+crossorigin/g, ""); + }, + }; +} + +export default defineConfig({ + plugins: [stripCrossOrigin()], + build: { + modulePreload: { polyfill: false }, + cssCodeSplit: false, + }, + resolve: { + alias: { + "@lib": resolve(__dirname, "src/lib"), + "@stores": resolve(__dirname, "src/stores"), + "@components": resolve(__dirname, "src/components"), + "@pages": resolve(__dirname, "src/pages"), + "@styles": resolve(__dirname, "src/styles"), + }, + }, + clearScreen: false, + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { protocol: "ws", host, port: 1421 } + : undefined, + }, +}); diff --git a/Client/tauri-client/vitest.config.ts b/Client/tauri-client/vitest.config.ts new file mode 100644 index 00000000..64675595 --- /dev/null +++ b/Client/tauri-client/vitest.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from "vitest/config"; +import { resolve } from "path"; + +export default defineConfig({ + resolve: { + alias: { + "@lib": resolve(__dirname, "src/lib"), + "@stores": resolve(__dirname, "src/stores"), + "@components": resolve(__dirname, "src/components"), + "@pages": resolve(__dirname, "src/pages"), + "@styles": resolve(__dirname, "src/styles"), + }, + }, + test: { + environment: "jsdom", + include: ["tests/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: [ + "src/main.ts", + "src/**/*.d.ts", + "src/lib/window-state.ts", + "src/lib/credentials.ts", + "src/lib/audio.ts", + "src/lib/vad.ts", + "src/lib/webrtc.ts", + "src/lib/voiceSession.ts", + "src/lib/noise-suppression.ts", + "src/lib/updater.ts", + "src/pages/MainPage.ts", + "src/components/UpdateNotifier.ts", + ], + thresholds: { + statements: 75, + branches: 75, + functions: 75, + lines: 75, + }, + }, + }, +}); diff --git a/Client/ui-mockup.html b/Client/ui-mockup.html new file mode 100644 index 00000000..51ee925f --- /dev/null +++ b/Client/ui-mockup.html @@ -0,0 +1,2247 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>OwnCord — Interactive Prototype + + + + +
+ +
+ + +
+

+
+
+
+ Voice Connected + +
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+
+ + +
+
+ # + + + +
+ + + +
+
+
+
+
+
+ Replying to + +
+
+
+
+ + + + +
+
+
+ + +
+ + + + + + +
+ + +
+
+
+
+
+
+
+
Member Since
+
+
+
+ +
+
+ + +
+
+ +
+
+
+ + +
+ +
+
+
+ + +
+ + + + diff --git a/PROMPTS.md b/PROMPTS.md deleted file mode 100644 index 9ff5b726..00000000 --- a/PROMPTS.md +++ /dev/null @@ -1,546 +0,0 @@ -# Claude Code Prompt Playbook - -Feed these prompts to Claude Code in order. Each step builds on the previous one. Don't move to the next step until the current one compiles, runs, and works. - -Test each step by actually running the exe and trying it yourself. - ---- - -## Milestone 1: Two Exes That Connect (Week 1) - -The goal: `chatserver.exe` runs, `chatclient.exe` connects to it, you see proof of connection on both sides. - -### Prompt 1.1 — Server skeleton - -``` -@CLAUDE.md @SCHEMA.md - -Create the server project in the server/ folder. - -- Go module with the folder structure from the go-server skill -- SQLite database that creates itself on first run with the users, sessions, channels, and messages tables from SCHEMA.md -- config.yaml generated on first run with defaults (port 8443, server name "My Server") -- A single REST endpoint: GET /api/health that returns {"status":"ok","version":"0.1.0"} -- TLS using a self-signed certificate generated automatically on first run -- Compiles to chatserver.exe - -I want to run chatserver.exe and hit https://localhost:8443/api/health in my browser and see the JSON response. That's the only goal for now. -``` - -### Prompt 1.2 — Registration and login - -``` -@CLAUDE.md @API.md @SCHEMA.md - -Add auth to the server: - -- POST /api/auth/register — takes username, password, invite_code. Hashes password with bcrypt. Returns a session token. -- POST /api/auth/login — validates credentials, returns session token. -- Auth middleware that validates the session token from a cookie or Authorization header. -- On first server run, auto-generate one invite code and print it to the console so I can use it to register the first account. -- Rate limiting on login: 5 attempts per minute per IP. - -Don't build any other endpoints yet. I just want to be able to register and login using curl or Postman and get back a valid session token. -``` - -### Prompt 1.3 — WebSocket with auth - -``` -@CLAUDE.md @PROTOCOL.md - -Add the WebSocket endpoint to the server: - -- GET /ws — upgrades to WebSocket -- Client must send an "auth" message with their session token as the first message -- Server responds with "auth_ok" containing the user info, or "auth_error" and closes the connection -- After auth, server sends a "ready" message with the list of channels and online members -- Implement the Hub pattern: register/unregister clients, track who's connected -- Ping/pong heartbeat every 30 seconds -- On first run, create a #general text channel automatically - -Test: I should be able to connect with a WebSocket client (like websocat or a browser console), send auth, and get back auth_ok + ready. -``` - -### Prompt 1.4 — Client app skeleton - -``` -@CLAUDE.md - -Now create the client application in the client/ folder. Choose the best language and framework for a native Windows desktop app based on the requirements in CHATSERVER.md. - -For now, build just: -- A connection dialog window: server address field, port field, username field, password field, a "Login" button and a "Register" button (with an invite code field that shows when Register is selected) -- On login/register success: store the session token securely (Windows Credential Manager) -- Connect to the server's WebSocket endpoint with the token -- On auth_ok: show a basic main window that just says "Connected to [server name]" and lists the online members from the ready payload -- On disconnect: show "Disconnected" with a reconnect button - -This is the absolute minimum — just prove the client can connect and authenticate with the server. No chat UI yet. Compiles to chatclient.exe with a simple build command. -``` - -### Prompt 1.5 — Send and receive messages - -``` -@CLAUDE.md @PROTOCOL.md - -Now wire up basic text chat between server and client. - -Server: -- Handle "chat_send" WebSocket messages: validate permissions, sanitize with bluemonday, store in SQLite, broadcast "chat_message" to all clients in the channel -- Handle GET /api/channels/{id}/messages for paginated history (50 messages, before cursor) - -Client: -- Replace the "Connected" placeholder with a real chat UI: channel name at top, scrollable message area in the center, text input at the bottom with a Send button -- Display incoming messages in real-time as they arrive over WebSocket -- Load message history from the REST endpoint when opening a channel -- Show username, message content, and timestamp for each message -- Basic markdown: **bold** and *italic* only for now - -Test with two instances of the client connecting to the same server. Type a message in one, it should appear in the other instantly. -``` - ---- - -## Milestone 2: Usable Chat App (Week 2-3) - -At this point you have two exes that connect and chat works. Now make it actually usable. - -### Prompt 2.1 — Multiple channels - -``` -@CLAUDE.md @PROTOCOL.md @SCHEMA.md - -Add multi-channel support: - -Server: -- Handle channel subscriptions in the hub — only broadcast messages to clients viewing that channel -- Send channel_create/channel_update/channel_delete events -- On first run, create #general, #random, and #announcements channels - -Client: -- Add a channel list sidebar on the left showing all channels -- Click a channel to switch to it and load its message history -- Show the active channel name at the top -- Unread indicator (bold channel name) when a channel has new messages you haven't seen -- Remember which channel was last open when switching back -``` - -### Prompt 2.2 — Invite system + roles - -``` -@CLAUDE.md @API.md @SCHEMA.md - -Build the invite and role system: - -Server: -- POST /api/invites to generate invite codes (admin only). Support max_uses and expires_in_hours. -- Invite codes required for registration — no open signup. -- Implement the role system from SCHEMA.md: Owner, Admin, Moderator, Member. -- First registered user becomes Owner automatically. -- Permission checks on all existing endpoints using the bitfield system. - -Client: -- When registering, require an invite code. -- Show role names/colors next to usernames in the member list. -- The Owner should see a small admin indicator somewhere. - -No admin panel yet — just the backend enforcement. -``` - -### Prompt 2.3 — Message features - -``` -@CLAUDE.md @PROTOCOL.md - -Add message features: - -Server: -- Handle chat_edit (own messages only), chat_delete (own or moderator+) -- Handle reaction_add, reaction_remove -- Handle replies (reply_to field) -- Typing indicator: typing_start broadcast to channel - -Client: -- Right-click a message: Reply, Edit (own only), Delete (own or mod), Copy Text -- Edit mode: press up arrow to edit last message, or right-click > Edit. Shows original text in input. -- Reply: click Reply, show a small preview above the input, send with reply_to -- Reactions: hover a message to see a small emoji button, click to add reaction. Show reaction badges below messages. -- "X is typing..." indicator below the message input -``` - -### Prompt 2.4 — File uploads - -``` -@CLAUDE.md @API.md - -Add file sharing: - -Server: -- POST /api/uploads: multipart upload, validate magic bytes, reject executables (.exe, .bat, .ps1, .cmd, .scr, .msi), strip EXIF from images, store with UUID filename, configurable size limit from config (default 25MB) -- GET /api/files/{uuid}: serve file with auth check -- Link attachments to messages via the attachments table - -Client: -- Drag and drop files onto the message area or input to upload -- Paste images from clipboard (Ctrl+V) -- Show upload progress bar -- Display uploaded images inline in the message (thumbnail, click to open full size) -- Non-image files show as a download link with filename and size -``` - -### Prompt 2.5 — Presence + system tray + notifications - -``` -@CLAUDE.md @PROTOCOL.md - -Add presence, tray, and notifications: - -Server: -- Track presence: online, idle, dnd, offline -- Auto-set idle after 10 minutes of no WebSocket activity -- Broadcast presence changes to all clients - -Client: -- Member list shows online status icons (green dot, yellow, red, grey) -- Sort member list: online first, then idle, then offline -- System tray icon — minimize to tray on close, left-click to restore -- Unread badge count on the tray icon -- Windows toast notification when a message arrives and the window is unfocused -- Status selector in the bottom bar: online, idle, DnD, invisible -``` - -### Prompt 2.6 — Search + settings - -``` -@CLAUDE.md @API.md - -Add search and user settings: - -Server: -- GET /api/search with FTS5 query, scoped to channels the user can read -- PUT /api/users/me/password for password changes - -Client: -- Search bar (Ctrl+K): type to search messages across all channels. Show results with channel name, author, snippet, timestamp. Click to jump to message. -- Settings window with tabs: - - Account: change password, change avatar (upload) - - Appearance: light/dark theme toggle, font size - - Notifications: enable/disable, per-channel mute -``` - ---- - -## Milestone 3: Voice Chat (Week 4-6) - -Text chat is solid. Now add voice. - -### Prompt 3.1 — Voice channel UI + signaling - -``` -@CLAUDE.md @PROTOCOL.md - -Add voice channel infrastructure — signaling only, no actual audio yet: - -Server: -- Voice channel type in the database -- Handle voice_join, voice_leave WebSocket events -- Track voice states (who's in which channel) -- Broadcast voice_state updates to all clients -- Create one voice channel called "Voice Chat" on first run - -Client: -- Show voice channels in the channel list with a speaker icon -- Click to join (sends voice_join), click again to leave -- Show connected users in the voice channel with their names -- Show a "Connected to Voice" bar at the bottom when in a voice channel with a disconnect button -- Mute and deafen buttons (just UI for now, send voice_mute/voice_deafen events) - -No actual audio — just the UI and signaling to prove the voice state tracking works. -``` - -### Prompt 3.2 — WebRTC audio - -``` -@CLAUDE.md Read the webrtc-voice skill for architecture patterns. - -Add actual voice audio: - -Server: -- Integrate Pion as an SFU: one PeerConnection per client in a voice channel -- Forward audio tracks between clients (don't decode, just forward RTP) -- Built-in TURN relay with time-limited credentials (GET /api/voice/credentials) -- Handle voice_offer, voice_answer, voice_ice signaling messages - -Client: -- On voice_join: request TURN credentials, create WebRTC PeerConnection -- Capture microphone audio, add as audio track -- Handle incoming audio tracks — play through speakers -- Audio device selection in settings (input/output dropdowns) -- Mute actually stops the audio track, deafen stops playback -- Speaking indicator: green highlight on users who are transmitting - -Test: two clients in the same voice channel should hear each other talk. -``` - -### Prompt 3.3 — Push-to-talk + noise suppression - -``` -@CLAUDE.md - -Add push-to-talk and noise suppression: - -Client: -- Push-to-talk mode: configurable global hotkey (default: ` backtick key) -- Global keyboard hook that works even when the app is not focused (fullscreen games) -- Toggle between push-to-talk and voice activation in settings -- Voice activation mode: configurable sensitivity threshold with a live meter in settings -- Integrate RNNoise for noise suppression — toggle in audio settings -- Visual indicator when transmitting (PTT held or voice active) -``` - -### Prompt 3.4 — Screen sharing + video - -``` -@CLAUDE.md - -Add screen sharing and video: - -Server: -- Forward video tracks through the SFU same as audio - -Client: -- "Share Screen" button in the voice channel bar -- Capture screen via DXGI Desktop Duplication, send as video track -- Cap at 720p -- When someone is sharing: show a video panel in the voice area -- Pop-out button to open in a resizable window -- "Stop Sharing" button -- Optional webcam video: toggle camera on/off, small preview -``` - -### Prompt 3.5 — Soundboard - -``` -@CLAUDE.md @SCHEMA.md - -Add soundboard: - -Server: -- sounds table from SCHEMA.md -- POST /api/sounds to upload (admin/mod only, <10s, <1MB) -- Handle soundboard_play WebSocket event: validate permissions, enforce 3-second cooldown -- Mix the sound into the voice channel - -Client: -- Soundboard panel accessible from the voice channel bar -- Grid of sound buttons with names -- Click or hotkey to play -- Show cooldown indicator after playing -``` - ---- - -## Milestone 4: Admin Panel + Polish (Week 7-8) - -### Prompt 4.1 — Admin panel - -``` -@CLAUDE.md @API.md - -Build the web-based admin panel served at /admin: - -- Simple HTML/CSS/JS (no framework), embedded in the server binary -- Login page using existing auth -- Dashboard: connected users count, total messages, disk usage, uptime -- User management: list all users, change roles, ban/unban, reset password, force logout -- Channel management: create, rename, reorder, delete -- Invite management: generate codes with expiry/max uses, view active, revoke -- Server settings: server name, MOTD, max upload size - -Keep it functional and clean — this is a tool for the server admin, not a showcase. -``` - -### Prompt 4.2 — Moderation tools - -``` -@CLAUDE.md @SCHEMA.md - -Add moderation: - -Server: -- Kick (disconnect, can rejoin), ban (permanent, by account), temp ban (auto-expires), IP ban -- Slow mode per channel (seconds between messages per user) -- Server mute (prevent sending messages) -- Word filter: configurable blocklist with action (delete message / warn / mute) -- Audit log: every mod action logged with who, what, when, why - -Client: -- Right-click user in member list: Kick, Ban, Mute (for mods+) -- Show "slow mode enabled" indicator in channels with slow mode -- Show "[message deleted by moderator]" for mod-deleted messages - -Admin panel: -- Word filter configuration page -- Audit log viewer with filters -``` - -### Prompt 4.3 — Backups - -``` -@CLAUDE.md - -Add backup system: - -Server: -- POST /api/admin/backup to trigger manual backup -- Backup = SQLite VACUUM INTO + zip of uploads folder → data/backups/timestamp.zip -- Scheduled backups: configurable in config.yaml (default daily at 3 AM) -- Retention: keep N most recent (default 7) -- GET /api/admin/backups to list available backups -- POST /api/admin/backups/{id}/restore to restore -- CLI: chatserver.exe --restore backup.zip - -Admin panel: -- Backup page: trigger manual backup button, list backups with dates and sizes, restore button -``` - ---- - -## Milestone 5: Customization + Quality of Life (Week 8-9) - -### Prompt 5.1 — Custom emoji + reactions - -``` -@CLAUDE.md @SCHEMA.md - -Add custom emoji: - -Server: -- emoji table from SCHEMA.md -- POST /api/emoji — admin uploads image + shortcode -- GET /api/emoji — list all, included in the ready payload -- Serve emoji images from uploads/emoji/ - -Client: -- Emoji picker: show built-in unicode emoji + custom server emoji -- Type :shortcode: in a message to auto-replace with emoji -- Autocomplete dropdown when typing : -- Custom emoji show inline in messages -``` - -### Prompt 5.2 — Threads + pins + DMs - -``` -@CLAUDE.md @PROTOCOL.md - -Add threads, pins, and direct messages: - -Server: -- Threads: messages with reply_to form a thread, endpoint to get thread messages -- Pins: POST/DELETE /api/channels/{id}/pins/{msg_id}, max 50 per channel -- DMs: private channels between two users, created on first DM - -Client: -- Click "View Thread" on a reply to open thread panel on the right -- Pin icon on pinned messages, "View Pins" button to see all pinned in a channel -- DM section in the channel list above server channels -- Click a user in the member list > "Send Message" to open/create DM -``` - -### Prompt 5.3 — Multi-server support - -``` -@CLAUDE.md - -Add multi-server support to the client: - -- Server list sidebar on the far left (vertical strip of server icons, like Discord/TeamSpeak) -- Each server is a separate WebSocket connection -- "+" button to add a new server (opens connection dialog) -- Right-click server icon: Edit, Remove, Copy Address -- Unread badge on server icons that have unread messages -- Switch between servers by clicking their icon — switches the channel list and message area -- Store server profiles locally in a config file next to the exe -``` - ---- - -## Milestone 6: Distribution (Week 9-10) - -### Prompt 6.1 — Server systray + service mode - -``` -@CLAUDE.md - -Add system tray and service mode to the server: - -- System tray icon using getlantern/systray -- Right-click menu: Open Admin Panel (launches browser to /admin), View Logs, Restart, Quit -- Tray icon shows green when running, yellow when starting up -- chatserver.exe --service install: register as a Windows Service -- chatserver.exe --service uninstall: remove the service -- When running as a service, no tray icon (headless mode) -``` - -### Prompt 6.2 — Client installer - -``` -@CLAUDE.md - -Create an NSIS installer for the client: - -- Installs to C:\Program Files\ChatServer Client\ -- Creates Start Menu shortcut -- Optional desktop shortcut -- Optional auto-start on boot (registry key) -- Registers chatserver:// protocol handler so invite links open the client -- Uninstaller that removes everything cleanly -- Installer size should be ~20-40MB -``` - -### Prompt 6.3 — Auto-update system - -``` -@CLAUDE.md - -Add update checking to both server and client: - -Server: -- GET /api/admin/update-check queries GitHub Releases API for newer version -- Admin panel shows "Update available: v1.1.0" with download button -- Download new exe, verify SHA256, replace, restart - -Client: -- On launch, check GitHub Releases for new client version -- If available, show a non-blocking notification: "Update available. Download now?" -- Download installer, verify SHA256, launch installer, close current client -``` - -### Prompt 6.4 — First-run setup wizard - -``` -@CLAUDE.md - -Add a first-run setup wizard to the server: - -When chatserver.exe starts and no database exists: -- Open browser to https://localhost:8443/setup -- Step 1: Create admin account (username + password) -- Step 2: Name your server, upload an icon (optional) -- Step 3: Choose network mode: "Local network only" / "Port forwarding" / "Tailscale/VPN" -- Step 4: TLS — auto-configure based on network choice -- Step 5: Generate first invite link, show it with a copy button -- After completing setup, redirect to the admin panel - -This replaces the "print invite code to console" from prompt 1.2. -``` - ---- - -## Tips - -- **Test after every prompt.** Build, run, try it. Don't stack three prompts before testing. -- **If something breaks**, paste the error into Claude Code: "This error happens when I try to [action]. Fix it." -- **If you want to tweak something**, just tell Claude Code what you want changed. The spec files give it context. -- **The milestones are roughly weekly.** Don't rush — a working app at each milestone is better than a broken app that has "more features." -- **Milestone 1 is the most important.** Once two exes connect and chat, everything else is incremental. diff --git a/PROTOCOL.md b/PROTOCOL.md deleted file mode 100644 index 23a507d1..00000000 --- a/PROTOCOL.md +++ /dev/null @@ -1,275 +0,0 @@ -# WebSocket Protocol Spec - -All client-server communication (except file uploads and admin panel) happens over a single WebSocket connection. Messages are JSON with a `type` and `payload`. - -## Message Format - -```json -{ - "type": "message_type", - "id": "unique-request-id", - "payload": { } -} -``` - -- `type` — string, required. Determines how payload is interpreted. -- `id` — string, optional. Client-generated UUID for request/response correlation. -- `payload` — object, required. Contents vary by type. - -Server responses to client requests include the same `id` for correlation. - ---- - -## Authentication - -### Client → Server - -```json -{ "type": "auth", "payload": { "token": "session-token-here" } } -``` - -### Server → Client (success) - -```json -{ "type": "auth_ok", "payload": { "user": { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin" }, "server_name": "My Server", "motd": "Welcome!" } } -``` - -### Server → Client (failure) - -```json -{ "type": "auth_error", "payload": { "message": "Invalid or expired token" } } -``` - -Connection is closed by server after auth_error. - ---- - -## Chat Messages - -### Send Message (Client → Server) - -```json -{ "type": "chat_send", "id": "req-uuid", "payload": { "channel_id": 5, "content": "Hello everyone!", "reply_to": null, "attachments": ["upload-uuid-1"] } } -``` - -### Message Broadcast (Server → Client) - -```json -{ "type": "chat_message", "payload": { "id": 1042, "channel_id": 5, "user": { "id": 1, "username": "alex", "avatar": "uuid.png" }, "content": "Hello everyone!", "reply_to": null, "attachments": [{ "id": "upload-uuid-1", "filename": "photo.jpg", "size": 204800, "mime": "image/jpeg", "url": "/files/upload-uuid-1" }], "timestamp": "2026-03-14T10:30:00Z" } } -``` - -### Send Ack (Server → Client) - -```json -{ "type": "chat_send_ok", "id": "req-uuid", "payload": { "message_id": 1042, "timestamp": "2026-03-14T10:30:00Z" } } -``` - -### Edit Message (Client → Server) - -```json -{ "type": "chat_edit", "id": "req-uuid", "payload": { "message_id": 1042, "content": "Hello everyone! (edited)" } } -``` - -### Edit Broadcast (Server → Client) - -```json -{ "type": "chat_edited", "payload": { "message_id": 1042, "channel_id": 5, "content": "Hello everyone! (edited)", "edited_at": "2026-03-14T10:31:00Z" } } -``` - -### Delete Message (Client → Server) - -```json -{ "type": "chat_delete", "id": "req-uuid", "payload": { "message_id": 1042 } } -``` - -### Delete Broadcast (Server → Client) - -```json -{ "type": "chat_deleted", "payload": { "message_id": 1042, "channel_id": 5 } } -``` - -### Reaction Add/Remove (Client → Server) - -```json -{ "type": "reaction_add", "payload": { "message_id": 1042, "emoji": "👍" } } -{ "type": "reaction_remove", "payload": { "message_id": 1042, "emoji": "👍" } } -``` - -### Reaction Broadcast (Server → Client) - -```json -{ "type": "reaction_update", "payload": { "message_id": 1042, "channel_id": 5, "emoji": "👍", "user_id": 1, "action": "add" } } -``` - ---- - -## Typing Indicators - -### Client → Server (throttle to 1 per 3 seconds) - -```json -{ "type": "typing_start", "payload": { "channel_id": 5 } } -``` - -### Server → Client (broadcast to channel members) - -```json -{ "type": "typing", "payload": { "channel_id": 5, "user_id": 1, "username": "alex" } } -``` - -Client-side: show indicator for 5 seconds, reset on new typing event from same user. - ---- - -## Presence - -### Client → Server - -```json -{ "type": "presence_update", "payload": { "status": "online" } } -``` - -Status values: `online`, `idle`, `dnd`, `offline` - -### Server → Client (broadcast) - -```json -{ "type": "presence", "payload": { "user_id": 1, "status": "online" } } -``` - -Server auto-sets `idle` after 10 minutes of no WebSocket activity. - ---- - -## Channel Updates - -### Server → Client (on channel created/edited/deleted/reordered) - -```json -{ "type": "channel_create", "payload": { "id": 8, "name": "gaming", "type": "text", "category": "Hangout", "position": 3 } } -{ "type": "channel_update", "payload": { "id": 8, "name": "gaming-talk", "position": 4 } } -{ "type": "channel_delete", "payload": { "id": 8 } } -``` - -Channel types: `text`, `voice`, `announcement` - ---- - -## Voice Signaling - -### Join Voice Channel (Client → Server) - -```json -{ "type": "voice_join", "payload": { "channel_id": 10 } } -``` - -### Server → Client (voice state updates, broadcast to channel) - -```json -{ "type": "voice_state", "payload": { "channel_id": 10, "user_id": 1, "username": "alex", "muted": false, "deafened": false, "speaking": false } } -``` - -### Voice User Left (Server → Client) - -```json -{ "type": "voice_leave", "payload": { "channel_id": 10, "user_id": 1 } } -``` - -### WebRTC Signaling (bidirectional) - -```json -{ "type": "voice_offer", "payload": { "channel_id": 10, "sdp": "..." } } -{ "type": "voice_answer", "payload": { "channel_id": 10, "sdp": "..." } } -{ "type": "voice_ice", "payload": { "channel_id": 10, "candidate": "..." } } -``` - -### Voice Control (Client → Server) - -```json -{ "type": "voice_mute", "payload": { "muted": true } } -{ "type": "voice_deafen", "payload": { "deafened": true } } -``` - -### Soundboard (Client → Server) - -```json -{ "type": "soundboard_play", "payload": { "sound_id": "uuid" } } -``` - ---- - -## Member Updates - -### Server → Client - -```json -{ "type": "member_join", "payload": { "user": { "id": 5, "username": "newuser", "avatar": null, "role": "member" } } } -{ "type": "member_leave", "payload": { "user_id": 5 } } -{ "type": "member_update", "payload": { "user_id": 5, "role": "moderator" } } -{ "type": "member_ban", "payload": { "user_id": 5 } } -``` - ---- - -## Initial State (sent after auth_ok) - -### Server → Client - -```json -{ - "type": "ready", - "payload": { - "channels": [ - { "id": 1, "name": "general", "type": "text", "category": "Main", "position": 0, "unread_count": 3, "last_message_id": 1040 }, - { "id": 10, "name": "voice-chat", "type": "voice", "category": "Main", "position": 1 } - ], - "members": [ - { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin", "status": "online" }, - { "id": 2, "username": "jordan", "avatar": null, "role": "member", "status": "idle" } - ], - "voice_states": [ - { "channel_id": 10, "user_id": 2, "muted": false, "deafened": false } - ], - "roles": [ - { "id": 1, "name": "Owner", "color": "#E74C3C", "permissions": 2147483647 }, - { "id": 2, "name": "Admin", "color": "#F39C12", "permissions": 1073741823 }, - { "id": 3, "name": "Member", "color": null, "permissions": 1049601 } - ] - } -} -``` - ---- - -## Message History (REST, not WebSocket) - -Fetched via REST API, not WebSocket, to keep the WS connection lean. - -``` -GET /api/channels/{id}/messages?before={message_id}&limit=50 -``` - ---- - -## Error Format - -Any request that fails returns: - -```json -{ "type": "error", "id": "original-req-uuid", "payload": { "code": "FORBIDDEN", "message": "You don't have permission to post in this channel" } } -``` - -Error codes: `FORBIDDEN`, `NOT_FOUND`, `RATE_LIMITED`, `INVALID_INPUT`, `SERVER_ERROR` - ---- - -## Rate Limits - -- Chat messages: 10/sec per user -- Typing events: 1/3sec per user per channel -- Presence updates: 1/10sec per user -- Reactions: 5/sec per user -- Voice signaling: 20/sec per user -- Soundboard: 1/3sec per user - -Server sends `rate_limited` error with `retry_after` in seconds. diff --git a/README.md b/README.md index b7e97bf7..ddc024e6 100644 --- a/README.md +++ b/README.md @@ -1 +1,240 @@ # OwnCord + +A self-hosted Windows chat platform with real-time messaging, +voice/video, file sharing, and a web admin panel. Run your own +server and keep everything under your control. + +## Features + +### Chat + +- Real-time text messaging over WebSocket +- Message editing, deletion, and replies +- Emoji reactions with per-message counts +- Typing indicators +- Full-text message search (SQLite FTS5) +- Pinned messages per channel +- Rich link previews with Open Graph metadata +- YouTube embed support with cached titles + +### Voice & Video + +- Voice channels with WebRTC (Pion SFU) +- Mute, deafen, camera, and screenshare controls +- Per-user volume control (right-click user in voice channel) +- NAT traversal via Google STUN + configurable external IP +- RNNoise ML noise suppression (AudioWorklet + fallback) +- Voice activity detection with configurable sensitivity +- Silence suppression to save bandwidth +- Configurable audio quality (low/medium/high) + +### Channels & Organization + +- Text and voice channels organized by categories +- Create, edit, delete, and reorder channels +- Unread message indicators +- Quick channel switcher (Ctrl+K) + +### File Sharing + +- Drag-and-drop and clipboard paste uploads +- Inline image previews with persistent caching (IndexedDB) +- File download with native save dialog +- Configurable max upload size + +### Users & Permissions + +- Invite-only registration with invite codes +- Role-based permissions with custom roles +- Member list with online/offline presence +- User profiles with status (online, idle, dnd, offline) + +### Administration + +- Web-based admin panel at `/admin` (Discord-style dark theme) +- Dashboard with server stats and recent activity +- User management (ban, kick, role assignment) with modals +- Channel management (create, edit, delete) +- Settings management (server name, MOTD, limits, security) +- Live server log streaming via SSE with level filters, + search, auto-scroll, pause/resume, copy, and clear +- Audit log with search, action type filter, copy, and CSV export +- Database backup and restore with pre-restore safety backups +- Server update checker and one-click apply (GitHub Releases) + +### Security + +- TLS encryption (self-signed, Let's Encrypt, or custom cert) +- Trust-on-first-use certificate pinning in the client +- Ed25519-signed client auto-updates +- Rate limiting on all endpoints +- CSRF protection and security headers + +### Desktop Client + +- Native Windows app built with Tauri v2 +- System tray integration +- In-app auto-update with progress notification +- Credential storage via Windows Credential Manager +- Custom emoji picker and soundboard + +## Quick Start + +1. Download the latest release from + [GitHub Releases](https://github.com/J3vb/OwnCord/releases) +2. Run `chatserver.exe` — generates `config.yaml` on first run +3. Open `https://localhost:8443/admin` to access the admin panel +4. Generate an invite code and share it with friends +5. Friends download the client installer and connect + using your server address + +## Architecture + +Two components: a **Go server** and a **Tauri v2 client** +(Rust + TypeScript). + +```text ++---------------------+ +---------------------+ +| OwnCord Client | | OwnCord Server | +| (Tauri v2) | | (Go) | +| | | | +| +---------------+ | WSS | +---------------+ | +| | Chat UI |--+------->| | WebSocket Hub| | +| +---------------+ | | +---------------+ | +| +---------------+ | HTTPS | +---------------+ | +| | REST Client |--+------->| | REST API | | +| +---------------+ | | +---------------+ | +| +---------------+ | WebRTC | +---------------+ | +| | Voice/Video |--+------->| | SFU (Pion) | | +| +---------------+ | | +---------------+ | ++---------------------+ | +---------------+ | + | | SQLite DB | | + | +---------------+ | + +---------------------+ +``` + +- **WebSocket** — chat messages, typing, presence, voice signaling +- **REST API** — message history, file uploads, channel management, auth +- **WebRTC** — voice and video via Pion SFU with Google STUN for NAT traversal + +## Project Structure + +```text +OwnCord/ +├── Server/ # Go server +│ ├── api/ # REST handlers + middleware +│ ├── ws/ # WebSocket hub + SFU +│ ├── db/ # SQLite queries + migrations +│ ├── auth/ # Authentication + rate limiting +│ ├── config/ # YAML config loading +│ ├── updater/ # GitHub Releases update checker +│ ├── admin/ # Web admin panel (static SPA) +│ └── storage/ # File upload storage +├── Client/ +│ └── tauri-client/ # Tauri v2 desktop client +│ ├── src-tauri/ # Rust backend (plugins, commands) +│ ├── src/ # TypeScript frontend +│ │ ├── lib/ # Core services (API, WS, WebRTC, updater) +│ │ ├── stores/ # Reactive state (auth, channels, messages, voice) +│ │ ├── components/ # UI components (34 modules) +│ │ ├── pages/ # Page layouts +│ │ └── styles/ # CSS +│ └── tests/ # Unit, integration, and E2E tests +└── docs/ # Project documentation (Obsidian vault) +``` + +## Building from Source + +### Prerequisites + +- Go 1.25+ +- Node.js 20+ +- Rust (stable) +- Windows 10/11 + +### Server + +```bash +cd Server +go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . +``` + +### Client + +```bash +cd Client/tauri-client +npm install +npm run tauri build +``` + +The installer is output to +`Client/tauri-client/src-tauri/target/release/bundle/nsis/`. + +### Running Tests + +```bash +# Server +cd Server && go test ./... + +# Client +cd Client/tauri-client +npm test # unit tests (vitest) +npm run test:e2e # Playwright E2E tests +npm run test:coverage # coverage report +``` + +## Configuration + +The server generates a `config.yaml` on first run. Key settings: + +| Setting | Default | Description | +| ------- | ------- | ----------- | +| `server.port` | `8443` | HTTPS port | +| `server.name` | `OwnCord Server` | Display name | +| `tls.mode` | `selfsigned` | TLS mode (see docs) | +| `upload.max_size_mb` | `10` | Max upload size | +| `voice.quality` | `medium` | `low`, `medium`, `high` | +| `voice.external_ip` | — | Public IP for NAT traversal | +| `voice.turn_enabled` | `true` | Enable TURN relay (requires coturn) | +| `github.token` | — | Token for update checks | + +## Auto-Updates + +The client checks for updates after connecting to the server. +Updates are Ed25519-signed and verified before install. + +To enable signed releases in CI, add these GitHub repository secrets: + +- `TAURI_SIGNING_PRIVATE_KEY` — Ed25519 private key + (via `npx tauri signer generate`) +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — key password + +## Documentation + +Detailed docs live in the `docs/brain/` Obsidian vault: + +- [Quick Start Guide](docs/brain/08-Guides/quick-start.md) +- [Port Forwarding Guide](docs/brain/08-Guides/port-forwarding.md) +- [Tailscale Guide](docs/brain/08-Guides/tailscale.md) +- [Client Architecture](docs/brain/06-Specs/CLIENT-ARCHITECTURE.md) +- [Server Spec](docs/brain/06-Specs/CHATSERVER.md) +- [WebSocket Protocol](docs/brain/06-Specs/PROTOCOL.md) +- [REST API](docs/brain/06-Specs/API.md) +- [Database Schema](docs/brain/06-Specs/SCHEMA.md) +- [Testing Strategy](docs/brain/06-Specs/TESTING-STRATEGY.md) +- [Contributing](docs/brain/08-Guides/CONTRIBUTING.md) +- [Security](docs/brain/08-Guides/SECURITY.md) + +## Tech Stack + +| Component | Technology | +| --------- | --------- | +| Server | Go, chi router, Pion WebRTC | +| Database | SQLite (pure Go, embedded) | +| Client | Tauri v2 (Rust + TypeScript) | +| Voice/Video | WebRTC with Pion SFU, Google STUN | +| Build | NSIS installer, GitHub Actions CI | + +## License + +MIT diff --git a/SCHEMA.md b/SCHEMA.md deleted file mode 100644 index d0be4b66..00000000 --- a/SCHEMA.md +++ /dev/null @@ -1,291 +0,0 @@ -# Database Schema (SQLite) - -Single file: `data/chatserver.db`. WAL mode enabled. Migrations run automatically on server startup. - ---- - -## Users - -```sql -CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE COLLATE NOCASE, - password TEXT NOT NULL, -- bcrypt hash - avatar TEXT, -- filename in uploads/ or NULL - role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), - totp_secret TEXT, -- encrypted TOTP secret or NULL if 2FA disabled - status TEXT NOT NULL DEFAULT 'offline', -- online, idle, dnd, offline - created_at TEXT NOT NULL DEFAULT (datetime('now')), - last_seen TEXT, - banned INTEGER NOT NULL DEFAULT 0, - ban_reason TEXT, - ban_expires TEXT -- NULL = permanent, datetime = temp ban -); -``` - -## Sessions - -```sql -CREATE TABLE sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token TEXT NOT NULL UNIQUE, -- 256-bit random, hex encoded - device TEXT, -- user-agent or client identifier - ip_address TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - last_used TEXT NOT NULL DEFAULT (datetime('now')), - expires_at TEXT NOT NULL -- 30 days from creation -); - -CREATE INDEX idx_sessions_token ON sessions(token); -CREATE INDEX idx_sessions_user ON sessions(user_id); -``` - -## Roles - -```sql -CREATE TABLE roles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - color TEXT, -- hex color e.g. #E74C3C, NULL for default - permissions INTEGER NOT NULL DEFAULT 0, -- bitfield - position INTEGER NOT NULL DEFAULT 0, -- hierarchy: higher = more power - is_default INTEGER NOT NULL DEFAULT 0 -- 1 = assigned to new users -); - --- Default roles (inserted on first run) --- Owner: permissions = 0x7FFFFFFF (all bits set) --- Admin: permissions = 0x3FFFFFFF --- Moderator: permissions = 0x000FFFFF --- Member: permissions = 0x00100601 -``` - -### Permission Bitfield - -``` -Bit 0: SEND_MESSAGES (0x1) -Bit 1: READ_MESSAGES (0x2) -Bit 5: ATTACH_FILES (0x20) -Bit 6: ADD_REACTIONS (0x40) -Bit 8: USE_SOUNDBOARD (0x100) -Bit 9: CONNECT_VOICE (0x200) -Bit 10: SPEAK_VOICE (0x400) -Bit 11: USE_VIDEO (0x800) -Bit 12: SHARE_SCREEN (0x1000) -Bit 16: MANAGE_MESSAGES (0x10000) -- delete others' messages, pin -Bit 17: MANAGE_CHANNELS (0x20000) -Bit 18: KICK_MEMBERS (0x40000) -Bit 19: BAN_MEMBERS (0x80000) -Bit 20: MUTE_MEMBERS (0x100000) -- server mute/deafen -Bit 24: MANAGE_ROLES (0x1000000) -Bit 25: MANAGE_SERVER (0x2000000) -Bit 26: MANAGE_INVITES (0x4000000) -Bit 27: VIEW_AUDIT_LOG (0x8000000) -Bit 30: ADMINISTRATOR (0x40000000) -- bypasses all checks -``` - -## Channels - -```sql -CREATE TABLE channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'text', -- text, voice, announcement - category TEXT, -- category name for grouping - topic TEXT, -- channel description - position INTEGER NOT NULL DEFAULT 0, - slow_mode INTEGER NOT NULL DEFAULT 0, -- seconds between messages, 0 = off - archived INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); -``` - -## Channel Permission Overrides - -```sql -CREATE TABLE channel_overrides ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - allow INTEGER NOT NULL DEFAULT 0, -- permission bits to grant - deny INTEGER NOT NULL DEFAULT 0, -- permission bits to revoke - UNIQUE(channel_id, role_id) -); -``` - -## Messages - -```sql -CREATE TABLE messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id), - content TEXT NOT NULL, - reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, - edited_at TEXT, - deleted INTEGER NOT NULL DEFAULT 0, -- 1 = soft deleted - pinned INTEGER NOT NULL DEFAULT 0, - timestamp TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX idx_messages_channel ON messages(channel_id, id DESC); -CREATE INDEX idx_messages_user ON messages(user_id); -``` - -## Message Full-Text Search - -```sql -CREATE VIRTUAL TABLE messages_fts USING fts5( - content, - content='messages', - content_rowid='id' -); - --- Triggers to keep FTS in sync -CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN - INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); -END; - -CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN - INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); -END; - -CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN - INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); - INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); -END; -``` - -## Attachments - -```sql -CREATE TABLE attachments ( - id TEXT PRIMARY KEY, -- UUID - message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, - filename TEXT NOT NULL, -- original filename - stored_as TEXT NOT NULL, -- UUID filename on disk - mime_type TEXT NOT NULL, - size INTEGER NOT NULL, -- bytes - uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) -); -``` - -## Reactions - -```sql -CREATE TABLE reactions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - emoji TEXT NOT NULL, - UNIQUE(message_id, user_id, emoji) -); -``` - -## Invites - -```sql -CREATE TABLE invites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL UNIQUE, -- random token - created_by INTEGER NOT NULL REFERENCES users(id), - redeemed_by INTEGER REFERENCES users(id), - max_uses INTEGER, -- NULL = unlimited - use_count INTEGER NOT NULL DEFAULT 0, - expires_at TEXT, -- NULL = never - created_at TEXT NOT NULL DEFAULT (datetime('now')), - revoked INTEGER NOT NULL DEFAULT 0 -); - -CREATE INDEX idx_invites_code ON invites(code); -``` - -## Read State (unread tracking) - -```sql -CREATE TABLE read_states ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - last_message_id INTEGER NOT NULL DEFAULT 0, - mention_count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, channel_id) -); -``` - -## Audit Log - -```sql -CREATE TABLE audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER REFERENCES users(id), - action TEXT NOT NULL, -- e.g. user_ban, channel_create, message_delete, role_update - target_type TEXT, -- user, channel, message, role, invite - target_id INTEGER, - details TEXT, -- JSON with extra context - timestamp TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX idx_audit_timestamp ON audit_log(timestamp DESC); -``` - -## Login Attempts (rate limiting) - -```sql -CREATE TABLE login_attempts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ip_address TEXT NOT NULL, - username TEXT, - success INTEGER NOT NULL DEFAULT 0, - timestamp TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX idx_login_ip ON login_attempts(ip_address, timestamp); -``` - -## Server Settings (key-value) - -```sql -CREATE TABLE settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - --- Default settings inserted on first run: --- server_name, server_icon, motd, max_upload_bytes, voice_quality, --- require_2fa, registration_open (always 0), backup_schedule, backup_retention -``` - -## Custom Emoji - -```sql -CREATE TABLE emoji ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - shortcode TEXT NOT NULL UNIQUE, -- e.g. :pepe: - filename TEXT NOT NULL, -- stored in uploads/emoji/ - uploaded_by INTEGER NOT NULL REFERENCES users(id), - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); -``` - -## Soundboard - -```sql -CREATE TABLE sounds ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - filename TEXT NOT NULL, -- stored in uploads/sounds/ - duration_ms INTEGER NOT NULL, - uploaded_by INTEGER NOT NULL REFERENCES users(id), - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); -``` - ---- - -## Notes - -- All datetimes stored as ISO 8601 UTC strings. -- Enable WAL mode on connection: `PRAGMA journal_mode=WAL;` -- Enable foreign keys: `PRAGMA foreign_keys=ON;` -- Use `modernc.org/sqlite` (pure Go, no CGO needed). -- Migrations: store schema version in `settings` table, apply incremental SQL on startup. diff --git a/SETUP.md b/SETUP.md deleted file mode 100644 index 220467c7..00000000 --- a/SETUP.md +++ /dev/null @@ -1,116 +0,0 @@ -# Developer Setup Guide - -What you need to install yourself vs what Claude Code can handle. - ---- - -## You Install (Claude Code can't do these) - -These require GUI installers, admin privileges, or system-level changes. - -### Required - -1. **Git** — https://git-scm.com/download/win - - Claude Code needs this to manage the project. Just use the default install options. - -2. **Go** — https://go.dev/dl/ - - Download the Windows amd64 `.msi` installer. Default install path is fine. - - After install, open a new terminal and verify: `go version` - -3. **Node.js (LTS)** — https://nodejs.org - - Claude Code itself runs on Node. You likely already have this if you're using Claude Code. - - Verify: `node --version` - -4. **Visual Studio Build Tools** (probably needed for the client) - - If Claude Code picks C++ (Qt), C# (WPF/.NET), or Rust — it will need a compiler. - - Install **Visual Studio Build Tools 2022**: https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022 - - During install, select: - - "Desktop development with C++" (covers C++ and Rust) - - ".NET desktop development" (covers C#/WPF) - - Selecting both covers all possible client language choices (~5-8 GB disk space). - - If Claude Code picks a language that doesn't need this, skip it — Claude Code will tell you what's missing. - -### Depends on Client Language (install if Claude Code asks) - -- **Qt 6** — If C++ is chosen. Online installer: https://www.qt.io/download-qt-installer - - Select: Qt 6.x for MSVC, Qt WebSockets, Qt Multimedia modules. - - Set `QT_DIR` environment variable to install path. - -- **.NET 8 SDK** — If C#/WPF is chosen. https://dotnet.microsoft.com/download/dotnet/8.0 - - Verify: `dotnet --version` - -- **Rust** — If Rust is chosen. https://rustup.rs - - Verify: `rustc --version` - -### Optional but Recommended - -5. **Windows Terminal** — https://aka.ms/terminal - - Much better than cmd.exe for running Claude Code. Get it from the Microsoft Store. - -6. **VS Code** — https://code.visualstudio.com - - For browsing the code Claude Code generates. Install the Go extension. - ---- - -## Claude Code Can Handle These - -Claude Code can install and configure all of the following via the terminal: - -### Go Dependencies (server) -``` -go mod init, go get, go mod tidy -``` -All Go libraries (chi, pion, sqlite, bcrypt, etc.) are installed automatically when Claude Code runs `go get`. No manual action needed. - -### NPM Packages (if any JS tooling is needed for admin panel) -``` -npm install -``` - -### NSIS (installer builder) -Claude Code can download and install NSIS via: -``` -winget install NSIS.NSIS -``` -Or use `choco install nsis` if Chocolatey is installed. - -### Development tools -- `golangci-lint` (Go linter) — Claude Code can install via `go install` -- `air` (Go hot-reload) — Claude Code can install via `go install` -- `sqlc` (SQL code generator) — Claude Code can install via `go install` - ---- - -## Quick Check — Run These After Installing - -Open a terminal and verify everything works: - -``` -git --version -go version -node --version -``` - -If all three print version numbers, you're ready. Start Claude Code in your project folder and tell it: - -``` -@CLAUDE.md Start phase 1 — set up the server project structure and build a hello world that compiles to chatserver.exe -``` - -Claude Code will read CLAUDE.md, pull in the other spec files, and start building. If it needs something you haven't installed (like Qt or .NET SDK based on the client language it picks), it will tell you. - ---- - -## Summary - -| Tool | You Install | Claude Code Installs | -|------|:-----------:|:-------------------:| -| Git | ✅ | | -| Go | ✅ | | -| Node.js | ✅ | | -| VS Build Tools | ✅ | | -| Qt / .NET SDK / Rust | ✅ (when asked) | | -| Go libraries | | ✅ | -| NSIS | | ✅ (via winget) | -| Linters & dev tools | | ✅ | -| NPM packages | | ✅ | diff --git a/SKILL.md b/SKILL.md deleted file mode 100644 index 97f0cfb4..00000000 --- a/SKILL.md +++ /dev/null @@ -1,230 +0,0 @@ ---- -name: windows-native -description: Patterns for building the native Windows desktop chat client (chatclient.exe). Use this skill for any work on the client application — UI layout, Windows API integration, system tray, notifications, keyboard hooks, audio devices, credential storage, installer creation, or any code in the client/ directory. Also trigger when the user mentions push-to-talk, WASAPI, DXGI, toast notifications, systray, NSIS installer, or any Windows-specific client feature. Use this even for simple client tasks like "add a button" or "fix the settings page." ---- - -# Native Windows Client Patterns - -Read this before writing any client code. The client is a native Windows desktop app — NOT Electron, NOT browser-based. - -## Requirements Recap - -The chosen language/framework must support all of these: -- Native Windows desktop UI (no embedded browser engine) -- ~20-40MB install size, ~50-100MB RAM idle -- WebSocket client for real-time chat -- WebRTC for voice/video -- WASAPI for low-latency audio -- Global keyboard hooks (push-to-talk in fullscreen games) -- System tray with badge overlay -- Windows toast notifications with action buttons -- DXGI Desktop Duplication for screen capture -- Windows Credential Manager (DPAPI) for token storage -- NSIS or WiX installer - -## Window Layout - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Server Name ─ □ ✕ │ -├────┬──────────┬─────────────────────────────┬───────────────┤ -│ │ CATEGORY │ │ Online — 5 │ -│ S1 │ # general│ [alex] Hello everyone! │ ● alex │ -│ │ # gaming │ [jordan] Hey what's up │ ● jordan │ -│ S2 │ # random │ │ ● sam │ -│ │ │ [sam] Anyone want to play? │ Offline — 2 │ -│ S3 │ VOICE │ │ ○ pat │ -│ │ 🔊 Voice │ │ ○ taylor │ -│ │ ● alex │ │ │ -│ │ ● jordan│ │ │ -│ │ │ │ │ -│ │ ├─────────────────────────────┤ │ -│ │ │ [message input ] 📎│ │ -├────┴──────────┴─────────────────────────────┴───────────────┤ -│ 🎤 Mute 🎧 Deafen ⚙ Settings alex ● Online │ -└─────────────────────────────────────────────────────────────┘ - -Left edge: Server icons (S1, S2, S3) — click to switch servers -Second column: Channel list with categories, text (#) and voice (🔊) channels -Center: Message area (scrollable, loads history on scroll-up) -Right: Member list (collapsible) -Bottom bar: Voice controls, settings shortcut, current user status -``` - -## Core UI Components - -### Connection Dialog (first screen) -- Server address + port fields -- Login / Register tabs -- "I have an invite code" option on Register tab -- "Remember me" checkbox -- Server profile dropdown (saved bookmarks) -- "Add Server" button to save new profiles - -### Message Area -- Messages grouped by author when consecutive (show avatar + name once, then just messages) -- Timestamp shown on hover or at time gaps (>5 minutes) -- Markdown rendered: **bold**, *italic*, `code`, ```code blocks```, [links](url) -- Reply preview: small quote box above the replied-to content -- Reactions: row of emoji badges below message, click to toggle own reaction -- Edited indicator: "(edited)" text next to timestamp -- Deleted placeholder: "This message was deleted" in italic - -### Settings Window (modal or separate window) -Tabs: -- **Account**: avatar upload, change password, 2FA setup -- **Appearance**: light/dark theme, font size, compact mode toggle -- **Notifications**: enable/disable, sounds on/off, per-channel overrides -- **Audio**: input device dropdown, output device dropdown, input volume slider with live meter, push-to-talk key selector, noise suppression toggle, voice activation sensitivity slider -- **Keybinds**: customizable shortcuts table - -## Windows API Integration - -### System Tray - -``` -Minimize to tray on window close (configurable in settings). -Tray icon: app icon with unread badge overlay. -Left-click: restore/focus window. -Right-click menu: - - Show ChatServer - - Mute All Notifications - - Settings - - ───────────── - - Quit - -Flash tray icon on new @mention. -Badge shows unread count (number overlay on icon). -``` - -### Windows Toast Notifications - -``` -Trigger: new message in channel or DM when window is unfocused or minimized. -Content: "[username] in #channel: message preview..." -Actions: "Reply" (opens input), "Mark Read" (clears unread). -Sound: configurable per event type (message, mention, voice join). -Respect per-channel mute settings — don't notify for muted channels. -Group notifications by channel to avoid spam. -``` - -### Global Keyboard Hooks (Push-to-Talk) - -``` -Use SetWindowsHookEx with WH_KEYBOARD_LL for low-level keyboard hook. -This captures key events system-wide, including in fullscreen games. -The hook runs in a separate thread. -When push-to-talk key is held: unmute mic, send audio. -When released: mute mic. -Visual indicator in the client UI: "Transmitting" badge or border glow. -Allow user to configure any key (including mouse buttons via WH_MOUSE_LL). -``` - -### WASAPI Audio - -``` -Enumerate audio devices: input (microphones) and output (speakers/headphones). -Let user select devices in Settings > Audio. -Use WASAPI in shared mode for low-latency capture and playback. -Feed captured audio into WebRTC audio track. -Play received audio from WebRTC to selected output device. -Noise suppression: process captured audio through RNNoise before sending. -Voice activity detection: analyze audio level, show "speaking" indicator. -``` - -### DXGI Desktop Duplication (Screen Sharing) - -``` -Use IDXGIOutputDuplication to capture the desktop. -Efficient — hardware-accelerated, low CPU overhead. -Encode captured frames and send as WebRTC video track. -Cap at 720p by default (configurable by server admin). -Show "You are sharing your screen" indicator in the UI. -Stop sharing button. -When someone else is sharing: show in a panel within the voice channel view. -Pop-out button to open screen share in a resizable window. -``` - -### Windows Credential Manager - -``` -Store auth tokens using Windows Credential Manager (DPAPI encryption). -Credential target name: "ChatServer:{server_address}" -On login success: store token. -On app launch: read stored token, attempt auto-login. -On logout: delete stored credential. -On session expired (server returns 401): delete credential, show login dialog. -Never store tokens in plaintext files or registry. -``` - -### Certificate Trust (TOFU) - -``` -When connecting to a server with a self-signed certificate: -1. First connection: show dialog "This server uses a self-signed certificate. - Fingerprint: SHA256:xxxx. Trust this certificate?" -2. If user accepts: save the cert fingerprint locally. -3. Future connections: verify fingerprint matches saved value. -4. If fingerprint changes: show warning "Certificate has changed! - This could indicate a security issue." Require explicit re-trust. -Store trusted fingerprints in local settings (per server profile). -``` - -## Connection & Reconnection - -``` -On startup: -1. Load last server profile -2. Read auth token from Credential Manager -3. Connect WebSocket to server -4. Send auth message with token -5. On auth_ok: receive ready payload, populate UI -6. On auth_error: show login dialog - -On disconnect: -1. Show "Reconnecting..." indicator -2. Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (cap) -3. On reconnect: re-authenticate, request missed messages -4. If token expired: show login dialog - -Connection indicator in bottom bar: - ● Green = connected - ● Yellow = reconnecting - ● Red = disconnected -``` - -## Multi-Server Support - -``` -Left sidebar shows server icons (like Discord). -Each server is a separate WebSocket connection. -Server profiles stored locally: -{ - "servers": [ - { - "name": "Friends Server", - "address": "myserver.example.com", - "port": 8443, - "icon": "cached_icon.png" - } - ] -} -Only the active server's messages are shown. -Unread badges shown on all server icons. -Click a server icon to switch context. -"+" button at bottom to add new server. -Right-click server icon: Edit, Remove, Copy Invite Link. -``` - -## Installer (NSIS or WiX) - -``` -Install location: C:\Program Files\ChatServer\ -Creates: Start Menu shortcut, optional Desktop shortcut. -Optional auto-start: adds to HKCU\...\Run registry key. -Registers protocol handler: chatserver:// - → opening chatserver://invite/abc123 launches client with invite dialog. -Uninstaller: removes files, registry entries, Start Menu items. -Size: ~20-40MB installed. -Include: client exe, runtime dependencies (if any), RNNoise DLL, default config. -``` diff --git a/Server/.golangci.yml b/Server/.golangci.yml new file mode 100644 index 00000000..4012cc8a --- /dev/null +++ b/Server/.golangci.yml @@ -0,0 +1,8 @@ +version: "2" + +linters: + settings: + staticcheck: + checks: + - "all" + - "-SA1019" # suppress deprecated usage warnings (websocket library migration tracked separately) diff --git a/Server/admin/admin.go b/Server/admin/admin.go new file mode 100644 index 00000000..4dd49e5d --- /dev/null +++ b/Server/admin/admin.go @@ -0,0 +1,66 @@ +// Package admin provides the embedded admin panel static file server and the +// admin REST API for the OwnCord server. +package admin + +import ( + "embed" + "io/fs" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/db" + "github.com/owncord/server/updater" +) + +//go:embed static +var staticFiles embed.FS + +// NewHandler returns an http.Handler that serves both the admin REST API and +// the embedded admin panel static files. +// +// Routes: +// +// /api/* — admin REST API (all require ADMINISTRATOR permission) +// /* — embedded static files (SPA; index.html for unknown paths) +func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer) http.Handler { + r := chi.NewRouter() + + // Admin REST API mounted at /api + r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf)) + + // Static files — serve from the "static" sub-tree of the embedded FS. + // The //go:embed static directive in this package embeds as "static/…", + // not "admin/static/…", so we strip just "static". + staticFS, err := fs.Sub(staticFiles, "static") + if err != nil { + // This is a programming error (wrong embed path) and should never + // happen in production. Panic so it surfaces immediately in tests. + panic("admin: failed to create static sub-FS: " + err.Error()) + } + + // Serve index.html directly for the root path. We read it once at + // startup instead of using http.FileServer, which has redirect + // behaviour that conflicts with chi's Mount prefix stripping. + indexHTML, err := fs.ReadFile(staticFS, "index.html") + if err != nil { + panic("admin: failed to read index.html: " + err.Error()) + } + r.Get("/", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // The admin SPA uses inline + + + +
+ + + + +
+
+ + + + + +
+ + + + diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html new file mode 100644 index 00000000..dc7c7c54 --- /dev/null +++ b/Server/admin/static/index.html @@ -0,0 +1,834 @@ + + + + + +OwnCord — Admin Panel + + + + + +
+
+

Welcome to OwnCord

+

No accounts exist yet. Create the owner account to get started.

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

Setup Complete!

+

Your owner account has been created. Here's your invite code:

+
+

Save this code! Share it with people you want to invite.

+ +
+
+ + +
+
+

OwnCord Admin

+
+
+ +
+
+
+ + + + + + + + +
+ + + + diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go new file mode 100644 index 00000000..4214660f --- /dev/null +++ b/Server/admin/update_handlers.go @@ -0,0 +1,135 @@ +package admin + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "syscall" + "time" + + "github.com/owncord/server/updater" +) + +// handleCheckUpdate returns the current update status. +func handleCheckUpdate(u *updater.Updater) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if u == nil { + writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured") + return + } + info, err := u.CheckForUpdate(r.Context()) + if err != nil { + writeErr(w, http.StatusBadGateway, "UPDATE_CHECK_FAILED", "failed to check for updates: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, info) + } +} + +// handleApplyUpdate downloads and applies a server update. +func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if u == nil { + writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured") + return + } + + // Check for available update. + info, err := u.CheckForUpdate(r.Context()) + if err != nil { + writeErr(w, http.StatusBadGateway, "UPDATE_CHECK_FAILED", err.Error()) + return + } + if !info.UpdateAvailable { + writeErr(w, http.StatusConflict, "NO_UPDATE", "server is already up to date") + return + } + if info.DownloadURL == "" || info.ChecksumURL == "" { + writeErr(w, http.StatusBadGateway, "MISSING_ASSETS", "release is missing required assets") + return + } + + // Get current executable path. + exePath, err := os.Executable() + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "cannot determine executable path") + return + } + exePath, err = filepath.EvalSymlinks(exePath) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "cannot resolve executable path") + return + } + + newPath := exePath + ".new" + oldPath := exePath + ".old" + + // Download and verify. + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + + if err := u.DownloadAndVerify(ctx, info.DownloadURL, info.ChecksumURL, newPath); err != nil { + writeErr(w, http.StatusBadGateway, "DOWNLOAD_FAILED", err.Error()) + return + } + + // Respond to the client before shutting down. + writeJSON(w, http.StatusOK, map[string]string{ + "status": "applying", + "version": info.Latest, + }) + + // Broadcast restart notification and apply in background. + go func() { + if hub != nil { + hub.BroadcastServerRestart("update", 5) + } + time.Sleep(5 * time.Second) + + // Rename: current -> .old, .new -> current + _ = os.Remove(oldPath) // remove any stale .old + if err := os.Rename(exePath, oldPath); err != nil { + slog.Error("update: rename current to old failed", "error", err) + return + } + if err := os.Rename(newPath, exePath); err != nil { + slog.Error("update: rename new to current failed", "error", err) + // Try to restore + _ = os.Rename(oldPath, exePath) + return + } + + // Spawn new process. + if err := spawnDetached(exePath, os.Args[1:]); err != nil { + slog.Error("update: spawn new process failed", "error", err) + return + } + + // Exit current process. os.Exit skips deferred cleanup intentionally — + // the process must die to release the file lock on its own binary + // before the new process can replace it on Windows. SQLite WAL mode + // protects DB integrity on unclean shutdown. + slog.Info("update: new process spawned, exiting current process") + os.Exit(0) + }() + }) +} + +// spawnDetached starts a new process that is not attached to the current one. +func spawnDetached(exePath string, args []string) error { + cmd := exec.Command(exePath, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if runtime.GOOS == "windows" { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: 0x00000008, // DETACHED_PROCESS + } + } + + return cmd.Start() +} diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go new file mode 100644 index 00000000..a31402ad --- /dev/null +++ b/Server/admin/update_handlers_test.go @@ -0,0 +1,299 @@ +package admin_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/owncord/server/admin" + "github.com/owncord/server/auth" + "github.com/owncord/server/updater" +) + +func TestAdminAPI_CheckUpdate_OK(t *testing.T) { + // Mock GitHub API + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "tag_name": "v2.0.0", + "body": "New release", + "html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", + "assets": []map[string]any{ + {"name": "chatserver.exe", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe"}, + {"name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256"}, + }, + }) + })) + defer mockGH.Close() + + u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord") + u.SetBaseURL(mockGH.URL) + + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + var info updater.UpdateInfo + _ = json.Unmarshal(w.Body.Bytes(), &info) + if !info.UpdateAvailable { + t.Error("expected update_available = true") + } + if info.Latest != "v2.0.0" { + t.Errorf("latest = %q, want v2.0.0", info.Latest) + } +} + +func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) { + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "tag_name": "v1.0.0", + "body": "", + "html_url": "https://github.com/J3vb/OwnCord/releases/tag/v1.0.0", + "assets": []map[string]any{}, + }) + })) + defer mockGH.Close() + + u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord") + u.SetBaseURL(mockGH.URL) + + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + var info updater.UpdateInfo + _ = json.Unmarshal(w.Body.Bytes(), &info) + if info.UpdateAvailable { + t.Error("expected update_available = false") + } +} + +func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) + + w := doRequest(t, handler, http.MethodGet, "/updates", "", nil) + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) + + // Create admin user (not owner - role 2) + adminUID, _ := database.CreateUser("adminonly2", "hash", 2) + token := "admin-role-token" + _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + + w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } +} + +// ─── handleApplyUpdate additional paths ────────────────────────────────────── + +// TestAdminAPI_ApplyUpdate_NilUpdater verifies that POST /updates/apply returns +// 503 when no updater is configured. +func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) { + database := openAdminTestDB(t) + // nil updater — the endpoint should return 503 + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503; body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminAPI_ApplyUpdate_NilUpdater_ErrorCode verifies the error code field +// in the 503 response. +func TestAdminAPI_ApplyUpdate_NilUpdater_ErrorCode(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) + + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp["error"] != "UPDATE_UNAVAILABLE" { + t.Errorf("error code = %q, want UPDATE_UNAVAILABLE", resp["error"]) + } +} + +// TestAdminAPI_ApplyUpdate_NoUpdateAvailable verifies that 409 Conflict is +// returned when the server is already up to date. +func TestAdminAPI_ApplyUpdate_NoUpdateAvailable(t *testing.T) { + // Mock GitHub API to return same version (no update available). + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "tag_name": "v1.0.0", + "body": "", + "html_url": "https://github.com/J3vb/OwnCord/releases/tag/v1.0.0", + "assets": []map[string]any{}, + }) + })) + defer mockGH.Close() + + u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord") + u.SetBaseURL(mockGH.URL) + + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (no update available); body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp["error"] != "NO_UPDATE" { + t.Errorf("error = %q, want NO_UPDATE", resp["error"]) + } +} + +// TestAdminAPI_ApplyUpdate_CheckFails verifies that 502 Bad Gateway is returned +// when the update check request to GitHub fails. +func TestAdminAPI_ApplyUpdate_CheckFails(t *testing.T) { + // Server that immediately closes connections (simulates network error). + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return invalid JSON to trigger a parse error. + w.WriteHeader(http.StatusInternalServerError) + })) + defer mockGH.Close() + + u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord") + u.SetBaseURL(mockGH.URL) + + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) + // Expect 502 Bad Gateway when update check call fails. + if w.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502; body: %s", w.Code, w.Body.String()) + } +} + +// TestAdminAPI_ApplyUpdate_MissingAssets verifies that 502 is returned when the +// release has no download URL or checksum URL. +func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) { + // Return a newer version but with no assets (empty download/checksum URLs). + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "tag_name": "v2.0.0", + "body": "Release notes", + "html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", + "assets": []map[string]any{}, + }) + })) + defer mockGH.Close() + + u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord") + u.SetBaseURL(mockGH.URL) + + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) + if w.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502 (missing assets); body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp["error"] != "MISSING_ASSETS" { + t.Errorf("error = %q, want MISSING_ASSETS", resp["error"]) + } +} + +// TestAdminAPI_ApplyUpdate_Unauthenticated verifies that 401 is returned for +// unauthenticated requests to POST /updates/apply. +func TestAdminAPI_ApplyUpdate_Unauthenticated(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) + + w := doRequest(t, handler, http.MethodPost, "/updates/apply", "", nil) + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +// TestAdminAPI_ApplyUpdate_DownloadFails verifies that 502 is returned when +// the binary download itself fails (bad URL, network error, etc.). +// We use a mock server that reports an available update with valid-format +// GitHub URLs, but those URLs point to a server that returns 404. +func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) { + // The mock server that serves the GitHub release info — it reports an + // update is available with GitHub-prefixed asset URLs. + // The actual download will fail because the URLs don't point to real files. + var mockGHURL string + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // If this is the checksum/download request, return an error. + // The release API endpoint returns a release with asset URLs. + _ = json.NewEncoder(w).Encode(map[string]any{ + "tag_name": "v2.0.0", + "body": "Release notes", + "html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", + "assets": []map[string]any{ + { + "name": "chatserver.exe", + "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe", + }, + { + "name": "checksums.sha256", + "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256", + }, + }, + }) + _ = mockGHURL // suppress unused warning + })) + defer mockGH.Close() + mockGHURL = mockGH.URL + + u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord") + u.SetBaseURL(mockGH.URL) + // The download URLs are real GitHub URLs that will fail since we're not + // actually connected to GitHub in tests, or we can use the URL validation + // to force a failure. The URLs pass validation (they have the right prefix), + // but the actual HTTP fetch will fail (unreachable host). + // In CI environments without internet, this returns 502. + // We accept either 502 (download failed) or 200 (unexpectedly succeeded) — + // the important thing is that the code path is executed. + + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) + // Either 502 (download failed as expected in isolated test environment) + // or 200 (succeeded in environment with GitHub access) is acceptable. + // What should NOT happen is 409 (no update) or 503 (nil updater). + if w.Code == http.StatusServiceUnavailable || w.Code == http.StatusConflict { + t.Errorf("status = %d; expected download attempt to proceed (got 503/409 instead)", w.Code) + } +} diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go new file mode 100644 index 00000000..38cb684e --- /dev/null +++ b/Server/api/auth_handler.go @@ -0,0 +1,332 @@ +package api + +import ( + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/microcosm-cc/bluemonday" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// sanitizer strips all HTML from user-supplied strings before storage. +var sanitizer = bluemonday.StrictPolicy() + +// genericAuthError is returned for all login/register failures to avoid +// revealing whether a username exists. +var genericAuthError = errorResponse{ + Error: "INVALID_CREDENTIALS", + Message: "invalid invite or credentials", +} + +// registerRequest is the JSON body for POST /api/v1/auth/register. +type registerRequest struct { + Username string `json:"username"` + Password string `json:"password"` + InviteCode string `json:"invite_code"` +} + +// loginRequest is the JSON body for POST /api/v1/auth/login. +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// userResponse is the user shape included in auth responses. +type userResponse struct { + ID int64 `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar,omitempty"` + Status string `json:"status"` + RoleID int64 `json:"role_id"` + CreatedAt string `json:"created_at"` +} + +// authSuccessResponse is returned on successful login/register. +type authSuccessResponse struct { + Token string `json:"token"` + User userResponse `json:"user"` +} + +// MountAuthRoutes registers all auth endpoints on the given router. +// Rate limiters are applied per-endpoint as specified. +func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter) { + registerLimiter := limiter + loginLimiter := limiter + + r.Route("/api/v1/auth", func(r chi.Router) { + r.With(RateLimitMiddleware(registerLimiter, 3, time.Minute)). + Post("/register", handleRegister(database)) + + r.With(RateLimitMiddleware(loginLimiter, 5, time.Minute)). + Post("/login", handleLogin(database, limiter)) + + r.With(AuthMiddleware(database)). + Post("/logout", handleLogout(database)) + + r.With(AuthMiddleware(database)). + Get("/me", handleMe()) + }) +} + +// handleRegister processes POST /api/v1/auth/register. +func handleRegister(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "malformed request body", + }) + return + } + + req.Username = strings.TrimSpace(sanitizer.Sanitize(req.Username)) + req.InviteCode = strings.TrimSpace(req.InviteCode) + + if req.Username == "" || req.Password == "" || req.InviteCode == "" { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "username, password, and invite_code are required", + }) + return + } + + // Validate password strength before anything else. + if err := auth.ValidatePasswordStrength(req.Password); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: err.Error(), + }) + return + } + + // Validate and consume invite atomically to prevent TOCTOU races. + if err := database.UseInviteAtomic(req.InviteCode); err != nil { + writeJSON(w, http.StatusBadRequest, genericAuthError) + return + } + + // Hash password. + hash, err := auth.HashPassword(req.Password) + if err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to process registration", + }) + return + } + + // Create user with default Member role. + uid, err := database.CreateUser(req.Username, hash, int(permissions.MemberRoleID)) + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "registration failed — check your details", + }) + return + } + + ip := clientIP(r) + slog.Info("user registered", "username", req.Username, "user_id", uid, "ip", ip) + _ = database.LogAudit(uid, "user_register", "user", uid, + "new account created via invite") + + // Issue session. + token, err := auth.GenerateToken() + if err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to create session", + }) + return + } + + device := r.Header.Get("User-Agent") + if _, err := database.CreateSession(uid, auth.HashToken(token), device, ip); err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to create session", + }) + return + } + + user, err := database.GetUserByID(uid) + if err != nil || user == nil { + slog.Error("failed to fetch user after registration", "user_id", uid, "error", err) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "registration succeeded but user fetch failed", + }) + return + } + writeJSON(w, http.StatusCreated, authSuccessResponse{ + Token: token, + User: toUserResponse(user), + }) + } +} + +// handleLogin processes POST /api/v1/auth/login. +func handleLogin(database *db.DB, limiter *auth.RateLimiter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "malformed request body", + }) + return + } + + req.Username = strings.TrimSpace(req.Username) + // Do NOT trim req.Password — passwords may intentionally contain + // leading/trailing whitespace. Bcrypt handles arbitrary bytes. + + if req.Username == "" || req.Password == "" { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "username and password are required", + }) + return + } + + ip := clientIP(r) + + // Check lockout first. + lockKey := "login_lock:" + ip + if limiter.IsLockedOut(lockKey) { + writeJSON(w, http.StatusTooManyRequests, errorResponse{ + Error: "RATE_LIMITED", + Message: "account temporarily locked due to too many failed attempts", + }) + return + } + + // Constant-time lookup: always attempt bcrypt compare even when user + // does not exist to prevent timing-based username enumeration. + user, err := database.GetUserByUsername(req.Username) + + failKey := "login_fail:" + ip + if err != nil || user == nil || !auth.CheckPassword(user.PasswordHash, req.Password) { + // Track failures; lockout after 10. + if !limiter.Allow(failKey, 10, 15*time.Minute) { + limiter.Lockout(lockKey, 15*time.Minute) + } + slog.Info("login failed", "ip", ip, "username_len", len(req.Username)) + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "invalid credentials", + }) + return + } + + // Reset failure counter on success. + limiter.Reset(failKey) + + if auth.IsEffectivelyBanned(user) { + slog.Warn("banned user login attempt", "username", user.Username, "user_id", user.ID, "ip", ip) + _ = database.LogAudit(user.ID, "login_blocked_banned", "user", user.ID, + "banned user attempted login from "+ip) + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "your account has been suspended", + }) + return + } + + // Issue session. + token, err := auth.GenerateToken() + if err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to create session", + }) + return + } + + device := r.Header.Get("User-Agent") + if _, err := database.CreateSession(user.ID, auth.HashToken(token), device, ip); err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to create session", + }) + return + } + + // Don't set status to "online" here — the WebSocket connection in + // serve.go does that when the user actually connects. Setting it here + // would leave the user permanently "online" if they never open a WS + // connection or if the client crashes before connecting. + slog.Info("user logged in", "username", user.Username, "user_id", user.ID, "ip", ip) + _ = database.LogAudit(user.ID, "user_login", "user", user.ID, + "logged in from "+ip) + writeJSON(w, http.StatusOK, authSuccessResponse{ + Token: token, + User: toUserResponse(user), + }) + } +} + +// handleLogout processes POST /api/v1/auth/logout. +func handleLogout(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess, ok := r.Context().Value(SessionKey).(*db.Session) + if !ok || sess == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + + if err := database.DeleteSession(sess.TokenHash); err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to logout", + }) + return + } + + slog.Info("user logged out", "user_id", sess.UserID) + _ = database.LogAudit(sess.UserID, "user_logout", "user", sess.UserID, "") + + w.WriteHeader(http.StatusNoContent) + } +} + +// handleMe processes GET /api/v1/auth/me. +func handleMe() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(UserKey).(*db.User) + if !ok || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + writeJSON(w, http.StatusOK, toUserResponse(user)) + } +} + +// toUserResponse converts a db.User to the API response shape. +func toUserResponse(u *db.User) userResponse { + avatar := "" + if u.Avatar != nil { + avatar = *u.Avatar + } + return userResponse{ + ID: u.ID, + Username: u.Username, + Avatar: avatar, + Status: u.Status, + RoleID: u.RoleID, + CreatedAt: u.CreatedAt, + } +} diff --git a/Server/api/auth_handler_test.go b/Server/api/auth_handler_test.go new file mode 100644 index 00000000..75beb1c9 --- /dev/null +++ b/Server/api/auth_handler_test.go @@ -0,0 +1,544 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// newAuthTestDB builds an in-memory DB with the full schema needed for auth tests. +func newAuthTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: apiTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// buildAuthRouter returns a chi router with auth routes mounted on /api/v1/auth. +func buildAuthRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler { + r := chi.NewRouter() + api.MountAuthRoutes(r, database, limiter) + return r +} + +// postJSON is a test helper that POSTs JSON to the given router. +func postJSON(t *testing.T, router http.Handler, path string, body any) *httptest.ResponseRecorder { + t.Helper() + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// postJSONWithToken posts with an Authorization header. +func postJSONWithToken(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// getWithToken performs a GET with an Authorization header. +func getWithToken(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// ─── Register tests ─────────────────────────────────────────────────────────── + +func TestRegister_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + // Create an invite first. + ownerID, _ := database.CreateUser("owner", "hash", 1) + code, _ := database.CreateInvite(ownerID, 1, nil) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "newuser", + "password": "securePass1", + "invite_code": code, + }) + + if rr.Code != http.StatusCreated { + t.Errorf("Register status = %d, want 201; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp["token"] == nil { + t.Error("Register response missing token") + } + if resp["user"] == nil { + t.Error("Register response missing user") + } +} + +func TestRegister_InvalidInvite(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "newuser", + "password": "securePass1", + "invite_code": "bogus", + }) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Register invalid invite status = %d, want 400", rr.Code) + } +} + +func TestRegister_WeakPassword(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + ownerID, _ := database.CreateUser("owner2", "hash", 1) + code, _ := database.CreateInvite(ownerID, 1, nil) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "newuser", + "password": "short", + "invite_code": code, + }) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Register weak password status = %d, want 400", rr.Code) + } +} + +func TestRegister_InviteUsedUp(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + ownerID, _ := database.CreateUser("owner3", "hash", 1) + code, _ := database.CreateInvite(ownerID, 1, nil) // max 1 use + + // First registration should succeed. + postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "user1", + "password": "securePass1", + "invite_code": code, + }) + + // Second should fail — invite exhausted. + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "user2", + "password": "securePass2", + "invite_code": code, + }) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Register exhausted invite status = %d, want 400", rr.Code) + } +} + +func TestRegister_MissingFields(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{}) + if rr.Code != http.StatusBadRequest { + t.Errorf("Register missing fields status = %d, want 400", rr.Code) + } +} + +func TestRegister_ErrorNeverRevealUsername(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "someone", + "password": "securePass1", + "invite_code": "bogus", + }) + + body := rr.Body.String() + // Must not hint that the username doesn't exist or the invite is invalid specifically + if contains(body, "username") && contains(body, "taken") { + t.Error("Register error message reveals username status") + } +} + +// ─── Login tests ────────────────────────────────────────────────────────────── + +func TestLogin_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + _, _ = database.CreateUser("loginuser", hash, 4) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "loginuser", + "password": "correctPass1", + }) + + if rr.Code != http.StatusOK { + t.Errorf("Login status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp["token"] == nil { + t.Error("Login response missing token") + } +} + +func TestLogin_WrongPassword(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + _, _ = database.CreateUser("loginuser2", hash, 4) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "loginuser2", + "password": "wrongpassword", + }) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Login wrong password status = %d, want 401", rr.Code) + } +} + +func TestLogin_UnknownUser(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "nobody", + "password": "anypass123", + }) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Login unknown user status = %d, want 401", rr.Code) + } +} + +func TestLogin_GenericErrorOnBadCredentials(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "nobody", + "password": "anypass123", + }) + + body := rr.Body.String() + // The response must never reveal whether the user exists + if contains(body, "user not found") || contains(body, "does not exist") { + t.Errorf("Login error reveals user existence: %s", body) + } +} + +func TestLogin_BannedUser(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + id, _ := database.CreateUser("banned", hash, 4) + _ = database.BanUser(id, "violated rules", nil) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "banned", + "password": "correctPass1", + }) + + if rr.Code != http.StatusForbidden { + t.Errorf("Login banned user status = %d, want 403", rr.Code) + } +} + +func TestLogin_MissingFields(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{}) + if rr.Code != http.StatusBadRequest { + t.Errorf("Login missing fields status = %d, want 400", rr.Code) + } +} + +// ─── Logout tests ───────────────────────────────────────────────────────────── + +func TestLogout_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + uid, _ := database.CreateUser("logoutuser", hash, 4) + token, _ := auth.GenerateToken() + tokenHash := auth.HashToken(token) + _, _ = database.CreateSession(uid, tokenHash, "test", "127.0.0.1") + + rr := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil) + + if rr.Code != http.StatusNoContent { + t.Errorf("Logout status = %d, want 204", rr.Code) + } + + // Session should be gone. + sess, _ := database.GetSessionByTokenHash(tokenHash) + if sess != nil { + t.Error("Session still exists after logout") + } +} + +func TestLogout_NoAuth(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Logout no auth status = %d, want 401", rr.Code) + } +} + +// ─── Me tests ───────────────────────────────────────────────────────────────── + +func TestMe_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + uid, _ := database.CreateUser("meuser", hash, 4) + token, _ := auth.GenerateToken() + _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + + rr := getWithToken(t, router, "/api/v1/auth/me", token) + + if rr.Code != http.StatusOK { + t.Errorf("Me status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp["id"] == nil { + t.Error("Me response missing id") + } + if resp["username"] != "meuser" { + t.Errorf("Me username = %v, want meuser", resp["username"]) + } +} + +func TestMe_NoAuth(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Me no auth status = %d, want 401", rr.Code) + } +} + +// ─── Fix 2.5: Password trim fix ─────────────────────────────────────────────── + +// TestLogin_PasswordWithLeadingSpaceIsPreserved verifies that a password with +// leading whitespace is NOT trimmed, so a user who set " securePass1" can log +// in with " securePass1" and NOT with "securePass1". +func TestLogin_PasswordWithLeadingSpaceIsPreserved(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + // Hash the password WITH the leading space — this is what was registered. + hash, _ := auth.HashPassword(" securePass1") + _, _ = database.CreateUser("spacepassuser", hash, 4) + + // Login with the exact same password (including space) must succeed. + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "spacepassuser", + "password": " securePass1", + }) + + if rr.Code != http.StatusOK { + t.Errorf("Login space-prefixed password status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } +} + +// TestLogin_PasswordWithLeadingSpaceTrimmedFails verifies that logging in with +// the trimmed version of a space-prefixed password correctly fails. +func TestLogin_PasswordWithLeadingSpaceTrimmedFails(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + // Register with password that has a leading space. + hash, _ := auth.HashPassword(" securePass1") + _, _ = database.CreateUser("spacepassuser2", hash, 4) + + // Login without the leading space must fail. + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "spacepassuser2", + "password": "securePass1", + }) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Login trimmed space password status = %d, want 401; body = %s", rr.Code, rr.Body.String()) + } +} + +// TestLogin_PasswordWithTrailingSpaceIsPreserved verifies that a password with +// trailing whitespace is NOT trimmed. +func TestLogin_PasswordWithTrailingSpaceIsPreserved(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("securePass1 ") + _, _ = database.CreateUser("trailingspaceuser", hash, 4) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "trailingspaceuser", + "password": "securePass1 ", + }) + + if rr.Code != http.StatusOK { + t.Errorf("Login trailing-space password status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } +} + +// TestLogin_UsernameIsStillTrimmed verifies that the username IS still trimmed +// (only the password trim was removed). +func TestLogin_UsernameIsStillTrimmed(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + _, _ = database.CreateUser("trimuser", hash, 4) + + // Username with surrounding spaces should resolve to "trimuser". + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": " trimuser ", + "password": "correctPass1", + }) + + if rr.Code != http.StatusOK { + t.Errorf("Login space-padded username status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } +} + +// ─── Rate limiting integration test ────────────────────────────────────────── + +func TestRegister_RateLimit(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + ownerID, _ := database.CreateUser("rl_owner", "hash", 1) + + // Attempt register 4 times (limit=3) — 4th should be rate-limited. + var lastCode int + for i := range 4 { + code, _ := database.CreateInvite(ownerID, 1, nil) + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "rl_user" + string(rune('0'+i)), + "password": "securePass1", + "invite_code": code, + }) + lastCode = rr.Code + } + + if lastCode != http.StatusTooManyRequests { + t.Errorf("Register rate limit: last attempt status = %d, want 429", lastCode) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub)) +} + +func containsStr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// expiredInviteDB creates a DB with an already-expired invite. +func expiredInviteDB(t *testing.T) (*db.DB, string) { + t.Helper() + database := newAuthTestDB(t) + ownerID, _ := database.CreateUser("expowner", "hash", 1) + past := time.Now().Add(-time.Hour) + code, _ := database.CreateInvite(ownerID, 0, &past) + return database, code +} + +func TestRegister_ExpiredInvite(t *testing.T) { + database, code := expiredInviteDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "newuser", + "password": "securePass1", + "invite_code": code, + }) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Register expired invite status = %d, want 400", rr.Code) + } +} diff --git a/Server/api/channel_authz_test.go b/Server/api/channel_authz_test.go new file mode 100644 index 00000000..e7a8b34e --- /dev/null +++ b/Server/api/channel_authz_test.go @@ -0,0 +1,193 @@ +package api_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// ─── Authorization tests for channel read access (REST) ───────────────────── +// These tests verify that permission checks (READ_MESSAGES) are enforced on +// GET /api/v1/channels, GET /api/v1/channels/{id}/messages, and GET /api/v1/search. + +// denyReadMessages inserts a channel_override that denies READ_MESSAGES for the +// given role on the given channel. +func denyReadMessages(t *testing.T, database *db.DB, channelID, roleID int64) { + t.Helper() + _, err := database.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, + channelID, roleID, permissions.ReadMessages, + ) + if err != nil { + t.Fatalf("denyReadMessages: %v", err) + } +} + +// ─── GET /api/v1/channels: permission filtering ───────────────────────────── + +func TestChannelList_FiltersOutDeniedChannels(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + + // Create member user (roleID=4, has READ_MESSAGES by default). + token := chTestCreateToken(t, database, "authz-member1", 4) + + chVisible, _ := database.CreateChannel("visible", "text", "", "", 0) + chHidden, _ := database.CreateChannel("hidden", "text", "", "", 1) + _ = chVisible // used implicitly in response + + // Deny READ_MESSAGES on the hidden channel for the Member role. + denyReadMessages(t, database, chHidden, permissions.MemberRoleID) + + rr := chGet(t, router, "/api/v1/channels", token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + + var channels []map[string]any + if err := json.NewDecoder(rr.Body).Decode(&channels); err != nil { + t.Fatalf("decode: %v", err) + } + if len(channels) != 1 { + t.Errorf("expected 1 visible channel, got %d", len(channels)) + } + if len(channels) > 0 { + name, _ := channels[0]["name"].(string) + if name != "visible" { + t.Errorf("visible channel name = %q, want %q", name, "visible") + } + } +} + +func TestChannelList_AdminSeesAllChannels(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + + // Owner (roleID=1) has Administrator bit — bypasses all checks. + token := chTestCreateToken(t, database, "authz-owner1", 1) + + chA, _ := database.CreateChannel("a", "text", "", "", 0) + chB, _ := database.CreateChannel("b", "text", "", "", 1) + + // Deny READ_MESSAGES on both channels for all roles. + denyReadMessages(t, database, chA, permissions.MemberRoleID) + denyReadMessages(t, database, chB, permissions.MemberRoleID) + + rr := chGet(t, router, "/api/v1/channels", token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + + var channels []any + _ = json.NewDecoder(rr.Body).Decode(&channels) + if len(channels) != 2 { + t.Errorf("admin should see all 2 channels, got %d", len(channels)) + } +} + +// ─── GET /api/v1/channels/{id}/messages: permission check ─────────────────── + +func TestChannelMessages_DeniedByPermission(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + + token := chTestCreateToken(t, database, "authz-member2", 4) + chID, _ := database.CreateChannel("restricted", "text", "", "", 0) + + // Deny READ_MESSAGES for Member role on this channel. + denyReadMessages(t, database, chID, permissions.MemberRoleID) + + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) + if rr.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestChannelMessages_AdminBypassesDeny(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + + token := chTestCreateToken(t, database, "authz-owner2", 1) + chID, _ := database.CreateChannel("restricted", "text", "", "", 0) + + // Deny READ_MESSAGES for Member role — should not affect Owner. + denyReadMessages(t, database, chID, permissions.MemberRoleID) + + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200; admin should bypass deny", rr.Code) + } +} + +// ─── GET /api/v1/search: permission filtering ─────────────────────────────── + +func TestSearch_FiltersResultsByPermission(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + + // Create an owner to insert messages (owner can write anywhere). + _ = chTestCreateToken(t, database, "authz-owner3", 1) + owner, _ := database.GetUserByUsername("authz-owner3") + + // Member user for search. + memberToken := chTestCreateToken(t, database, "authz-member3", 4) + + chVisible, _ := database.CreateChannel("pub", "text", "", "", 0) + chHidden, _ := database.CreateChannel("priv", "text", "", "", 1) + + // Insert messages in both channels with a common keyword. + _, _ = database.CreateMessage(chVisible, owner.ID, "searchable keyword public", nil) + _, _ = database.CreateMessage(chHidden, owner.ID, "searchable keyword private", nil) + + // Deny READ_MESSAGES on the hidden channel for members. + denyReadMessages(t, database, chHidden, permissions.MemberRoleID) + + rr := chGet(t, router, "/api/v1/search?q=searchable", memberToken) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + results, ok := resp["results"].([]any) + if !ok { + t.Fatalf("results is not an array: %v", resp) + } + if len(results) != 1 { + t.Errorf("expected 1 search result (public only), got %d", len(results)) + } +} + +func TestSearch_AdminSeesAllResults(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + + token := chTestCreateToken(t, database, "authz-owner4", 1) + owner, _ := database.GetUserByUsername("authz-owner4") + + chA, _ := database.CreateChannel("a", "text", "", "", 0) + chB, _ := database.CreateChannel("b", "text", "", "", 1) + + _, _ = database.CreateMessage(chA, owner.ID, "findme alpha", nil) + _, _ = database.CreateMessage(chB, owner.ID, "findme beta", nil) + + // Deny READ_MESSAGES on both for member role — admin bypasses. + denyReadMessages(t, database, chA, permissions.MemberRoleID) + denyReadMessages(t, database, chB, permissions.MemberRoleID) + + rr := chGet(t, router, "/api/v1/search?q=findme", token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + results := resp["results"].([]any) + if len(results) != 2 { + t.Errorf("admin should see all 2 results, got %d", len(results)) + } +} diff --git a/Server/api/channel_handler.go b/Server/api/channel_handler.go new file mode 100644 index 00000000..c34852e6 --- /dev/null +++ b/Server/api/channel_handler.go @@ -0,0 +1,288 @@ +package api + +import ( + "log/slog" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +const ( + defaultMessageLimit = 50 + maxMessageLimit = 100 +) + +// MountChannelRoutes registers all channel-related routes onto r. +// All routes require authentication. +func MountChannelRoutes(r chi.Router, database *db.DB) { + r.Route("/api/v1/channels", func(r chi.Router) { + r.Use(AuthMiddleware(database)) + r.Get("/", handleListChannels(database)) + r.Get("/{id}/messages", handleGetMessages(database)) + }) + r.With(AuthMiddleware(database)).Get("/api/v1/search", handleSearch(database)) +} + +// hasChannelPermREST checks whether the role has the given permission on the channel, +// accounting for Administrator bypass and channel overrides. +func hasChannelPermREST(database *db.DB, role *db.Role, channelID, perm int64) bool { + if role == nil { + return false + } + if permissions.HasAdmin(role.Permissions) { + return true + } + allow, deny, err := database.GetChannelPermissions(channelID, role.ID) + if err != nil { + return false + } + effective := permissions.EffectivePerms(role.Permissions, allow, deny) + return effective&perm == perm +} + +// hasChannelPermBatch checks permission using a pre-fetched overrides map, +// eliminating N+1 queries when filtering multiple channels. +func hasChannelPermBatch(role *db.Role, overrides map[int64]db.ChannelOverride, channelID, perm int64) bool { + if role == nil { + return false + } + if permissions.HasAdmin(role.Permissions) { + return true + } + o := overrides[channelID] // zero-value (0,0) when no override exists + effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny) + return effective&perm == perm +} + +// handleListChannels returns all channels the authenticated user can see. +func handleListChannels(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + role, _ := r.Context().Value(RoleKey).(*db.Role) + + channels, err := database.ListChannels() + if err != nil { + slog.Error("handleListChannels ListChannels", "err", err) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL", + Message: "failed to list channels", + }) + return + } + + // Batch-fetch all channel permission overrides for this role in one query. + overrides := map[int64]db.ChannelOverride{} + if role != nil && !permissions.HasAdmin(role.Permissions) { + var oErr error + overrides, oErr = database.GetAllChannelPermissionsForRole(role.ID) + if oErr != nil { + slog.Error("handleListChannels GetAllChannelPermissionsForRole", "err", oErr) + } + } + + // Filter channels by READ_MESSAGES permission. + var visible []db.Channel + for _, ch := range channels { + if hasChannelPermBatch(role, overrides, ch.ID, permissions.ReadMessages) { + visible = append(visible, ch) + } + } + if visible == nil { + visible = []db.Channel{} + } + writeJSON(w, http.StatusOK, visible) + } +} + +// handleGetMessages returns paginated messages for a channel. +// Query params: before (int64, message ID for pagination), limit (1-100, default 50). +func handleGetMessages(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + channelID, ok := parseIDParam(w, r, "id") + if !ok { + return + } + + ch, err := database.GetChannel(channelID) + if err != nil { + slog.Error("handleGetMessages GetChannel", "err", err, "channel_id", channelID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL", + Message: "failed to look up channel", + }) + return + } + if ch == nil { + writeJSON(w, http.StatusNotFound, errorResponse{ + Error: "NOT_FOUND", + Message: "channel not found", + }) + return + } + + // Permission check: user must have READ_MESSAGES on this channel. + role, _ := r.Context().Value(RoleKey).(*db.Role) + if !hasChannelPermREST(database, role, channelID, permissions.ReadMessages) { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "no permission to view this channel", + }) + return + } + + // Parse query params. + before := int64(0) + if raw := r.URL.Query().Get("before"); raw != "" { + v, parseErr := strconv.ParseInt(raw, 10, 64) + if parseErr != nil || v < 0 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: "before must be a non-negative integer", + }) + return + } + before = v + } + + limit := defaultMessageLimit + if raw := r.URL.Query().Get("limit"); raw != "" { + v, parseErr := strconv.Atoi(raw) + if parseErr != nil || v < 1 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: "limit must be a positive integer", + }) + return + } + if v > maxMessageLimit { + v = maxMessageLimit + } + limit = v + } + + // Extract requesting user ID for reaction "me" flag. + var userID int64 + if user, ok := r.Context().Value(UserKey).(*db.User); ok && user != nil { + userID = user.ID + } + + // Fetch one extra to determine has_more. + msgs, err := database.GetMessagesForAPI(channelID, before, limit+1, userID) + if err != nil { + slog.Error("handleGetMessages GetMessagesForAPI", "err", err, "channel_id", channelID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL", + Message: "failed to fetch messages", + }) + return + } + + hasMore := false + if len(msgs) > limit { + hasMore = true + msgs = msgs[:limit] + } + + type response struct { + Messages []db.MessageAPIResponse `json:"messages"` + HasMore bool `json:"has_more"` + } + writeJSON(w, http.StatusOK, response{Messages: msgs, HasMore: hasMore}) + } +} + +// handleSearch performs a full-text search across messages. +// Query params: q (required), channel_id (optional), limit (optional, 1-100). +func handleSearch(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if q == "" { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: "query parameter 'q' is required", + }) + return + } + + var channelID *int64 + if raw := r.URL.Query().Get("channel_id"); raw != "" { + v, parseErr := strconv.ParseInt(raw, 10, 64) + if parseErr != nil || v <= 0 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: "channel_id must be a positive integer", + }) + return + } + channelID = &v + } + + limit := defaultMessageLimit + if raw := r.URL.Query().Get("limit"); raw != "" { + v, parseErr := strconv.Atoi(raw) + if parseErr != nil || v < 1 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: "limit must be a positive integer", + }) + return + } + if v > maxMessageLimit { + v = maxMessageLimit + } + limit = v + } + + results, err := database.SearchMessages(q, channelID, limit) + if err != nil { + slog.Error("handleSearch SearchMessages", "err", err, "query", q) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL", + Message: "search failed", + }) + return + } + + // Batch-fetch overrides and post-filter results by READ_MESSAGES. + role, _ := r.Context().Value(RoleKey).(*db.Role) + overrides := map[int64]db.ChannelOverride{} + if role != nil && !permissions.HasAdmin(role.Permissions) { + var oErr error + overrides, oErr = database.GetAllChannelPermissionsForRole(role.ID) + if oErr != nil { + slog.Error("handleSearch GetAllChannelPermissionsForRole", "err", oErr) + } + } + var filtered []db.MessageSearchResult + for _, res := range results { + if hasChannelPermBatch(role, overrides, res.ChannelID, permissions.ReadMessages) { + filtered = append(filtered, res) + } + } + if filtered == nil { + filtered = []db.MessageSearchResult{} + } + + type response struct { + Results []db.MessageSearchResult `json:"results"` + } + writeJSON(w, http.StatusOK, response{Results: filtered}) + } +} + +// parseIDParam extracts and validates a chi URL param as int64. +// Writes a 400 response and returns false on failure. +func parseIDParam(w http.ResponseWriter, r *http.Request, param string) (int64, bool) { + raw := chi.URLParam(r, param) + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: param + " must be a positive integer", + }) + return 0, false + } + return id, true +} diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go new file mode 100644 index 00000000..a6e20567 --- /dev/null +++ b/Server/api/channel_handler_test.go @@ -0,0 +1,555 @@ +package api_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// ─── schema for channel tests ───────────────────────────────────────────────── + +var channelTestSchema = []byte(` +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT, + permissions INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); +INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES + (1, 'Owner', '#E74C3C', 2147483647, 100, 0), + (2, 'Admin', '#F39C12', 1073741823, 80, 0), + (3, 'Moderator', '#3498DB', 1048575, 60, 0), + (4, 'Member', NULL, 1635, 40, 1); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + avatar TEXT, + role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, + status TEXT NOT NULL DEFAULT 'offline', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT +); +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); + +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS channel_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + UNIQUE(channel_id, role_id) +); +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, + edited_at TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, id DESC); + +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + content, + content='messages', + content_rowid='id' +); +CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; +CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); +END; +CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; + +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + stored_as TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + UNIQUE(message_id, user_id, emoji) +); +CREATE TABLE IF NOT EXISTS read_states ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + last_message_id INTEGER NOT NULL DEFAULT 0, + mention_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, channel_id) +); +CREATE TABLE IF NOT EXISTS invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + redeemed_by INTEGER REFERENCES users(id), + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +INSERT OR IGNORE INTO settings (key, value) VALUES + ('server_name', 'OwnCord Server'), + ('motd', 'Welcome!'); +`) + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func newChannelTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + migrFS := fstest.MapFS{"001_schema.sql": {Data: channelTestSchema}} + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +func buildChannelRouter(database *db.DB) http.Handler { + r := chi.NewRouter() + api.MountChannelRoutes(r, database) + return r +} + +// chTestCreateToken creates a user+session and returns the plaintext token. +func chTestCreateToken(t *testing.T, database *db.DB, username string, roleID int) string { + t.Helper() + _, err := database.CreateUser(username, "$2a$12$fake", roleID) + if err != nil { + t.Fatalf("CreateUser %q: %v", username, err) + } + token := "chtest-token-" + username + hash := auth.HashToken(token) + _, err = database.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) + SELECT id, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z' FROM users WHERE username = ?`, + hash, username, + ) + if err != nil { + t.Fatalf("insert session for %q: %v", username, err) + } + return token +} + +func chGet(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// ─── GET /api/v1/channels ───────────────────────────────────────────────────── + +func TestChannelList_Unauthenticated(t *testing.T) { + router := buildChannelRouter(newChannelTestDB(t)) + rr := chGet(t, router, "/api/v1/channels", "") + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +func TestChannelList_Empty(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "alice", 1) + + rr := chGet(t, router, "/api/v1/channels", token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + var resp []any + _ = json.NewDecoder(rr.Body).Decode(&resp) + if len(resp) != 0 { + t.Errorf("expected empty array, got %d items", len(resp)) + } +} + +func TestChannelList_WithChannels(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "bob", 1) + + _, _ = database.CreateChannel("general", "text", "", "", 0) + _, _ = database.CreateChannel("random", "text", "", "", 1) + + rr := chGet(t, router, "/api/v1/channels", token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + var resp []any + _ = json.NewDecoder(rr.Body).Decode(&resp) + if len(resp) != 2 { + t.Errorf("expected 2 channels, got %d", len(resp)) + } +} + +// ─── GET /api/v1/channels/{id}/messages ────────────────────────────────────── + +func TestChannelMessages_Unauthenticated(t *testing.T) { + router := buildChannelRouter(newChannelTestDB(t)) + rr := chGet(t, router, "/api/v1/channels/1/messages", "") + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +func TestChannelMessages_InvalidID(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "carol", 1) + + rr := chGet(t, router, "/api/v1/channels/abc/messages", token) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rr.Code) + } +} + +func TestChannelMessages_ChannelNotFound(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "dave", 1) + + rr := chGet(t, router, "/api/v1/channels/9999/messages", token) + if rr.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rr.Code) + } +} + +func TestChannelMessages_EmptyChannel(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "eve", 1) + chID, _ := database.CreateChannel("general", "text", "", "", 0) + + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + msgs, ok := resp["messages"].([]any) + if !ok || len(msgs) != 0 { + t.Errorf("expected empty messages array, got: %v", resp["messages"]) + } +} + +func TestChannelMessages_ReturnsMessages(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "frank", 1) + user, _ := database.GetUserByUsername("frank") + chID, _ := database.CreateChannel("ch", "text", "", "", 0) + + for i := range 3 { + _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("msg%d", i), nil) + } + + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + msgs := resp["messages"].([]any) + if len(msgs) != 3 { + t.Errorf("expected 3 messages, got %d", len(msgs)) + } +} + +func TestChannelMessages_LimitCappedAt100(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "grace", 1) + chID, _ := database.CreateChannel("ch", "text", "", "", 0) + + // limit=200 should succeed (capped internally). + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=200", chID), token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } +} + +func TestChannelMessages_HasMore(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "henry", 1) + user, _ := database.GetUserByUsername("henry") + chID, _ := database.CreateChannel("ch", "text", "", "", 0) + + for i := range 60 { + _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("m%d", i), nil) + } + + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=50", chID), token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp["has_more"] != true { + t.Errorf("has_more = %v, want true", resp["has_more"]) + } +} + +func TestChannelMessages_HasMoreFalse(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "ivan", 1) + user, _ := database.GetUserByUsername("ivan") + chID, _ := database.CreateChannel("ch", "text", "", "", 0) + + for i := range 5 { + _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("m%d", i), nil) + } + + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=50", chID), token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp["has_more"] != false { + t.Errorf("has_more = %v, want false", resp["has_more"]) + } +} + +// ─── GET /api/v1/search ─────────────────────────────────────────────────────── + +func TestSearch_Unauthenticated(t *testing.T) { + router := buildChannelRouter(newChannelTestDB(t)) + rr := chGet(t, router, "/api/v1/search?q=hello", "") + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +func TestSearch_MissingQuery(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "julia", 1) + + rr := chGet(t, router, "/api/v1/search", token) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rr.Code) + } +} + +func TestSearch_ReturnsResults(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "kim", 1) + user, _ := database.GetUserByUsername("kim") + chID, _ := database.CreateChannel("searchable", "text", "", "", 0) + _, _ = database.CreateMessage(chID, user.ID, "uniqueterm in message", nil) + + rr := chGet(t, router, "/api/v1/search?q=uniqueterm", token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + results, ok := resp["results"].([]any) + if !ok || len(results) == 0 { + t.Errorf("expected search results, got: %v", resp) + } +} + +func TestSearch_NoResults(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "larry", 1) + + rr := chGet(t, router, "/api/v1/search?q=xyzzynotfound", token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + results := resp["results"].([]any) + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } +} + +func TestSearch_WithChannelID(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "searchch", 1) + user, _ := database.GetUserByUsername("searchch") + chID, _ := database.CreateChannel("filtered", "text", "", "", 0) + _, _ = database.CreateMessage(chID, user.ID, "filtered message here", nil) + + rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=filtered&channel_id=%d", chID), token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestSearch_InvalidChannelID(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "badchid", 1) + + rr := chGet(t, router, "/api/v1/search?q=test&channel_id=abc", token) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rr.Code) + } +} + +func TestSearch_NegativeChannelID(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "negchid", 1) + + rr := chGet(t, router, "/api/v1/search?q=test&channel_id=-1", token) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rr.Code) + } +} + +func TestSearch_WithLimit(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "limituser", 1) + + rr := chGet(t, router, "/api/v1/search?q=test&limit=5", token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } +} + +func TestSearch_InvalidLimit(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "badlimit", 1) + + rr := chGet(t, router, "/api/v1/search?q=test&limit=abc", token) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rr.Code) + } +} + +func TestSearch_ZeroLimit(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "zerolimit", 1) + + rr := chGet(t, router, "/api/v1/search?q=test&limit=0", token) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rr.Code) + } +} + +func TestSearch_LimitCappedAt100(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "highlimit", 1) + + // limit=200 should be silently capped to 100 + rr := chGet(t, router, "/api/v1/search?q=test&limit=200", token) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rr.Code) + } +} + + +// ─── Messages — before/after cursor ───────────────────────────────────────── + +func TestChannelMessages_BeforeCursor(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "cursoruser", 1) + user, _ := database.GetUserByUsername("cursoruser") + chID, _ := database.CreateChannel("cursor", "text", "", "", 0) + + var lastID int64 + for i := range 5 { + lastID, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("msg%d", i), nil) + } + + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=%d", chID, lastID), token) + if rr.Code != http.StatusOK { + t.Errorf("before cursor status = %d, want 200", rr.Code) + } +} + +func TestChannelMessages_InvalidLimit(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "badlimituser", 1) + chID, _ := database.CreateChannel("lim", "text", "", "", 0) + + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=abc", chID), token) + if rr.Code != http.StatusBadRequest { + t.Errorf("invalid limit status = %d, want 400", rr.Code) + } +} + diff --git a/Server/api/client_update.go b/Server/api/client_update.go new file mode 100644 index 00000000..2be19f80 --- /dev/null +++ b/Server/api/client_update.go @@ -0,0 +1,98 @@ +// Package api provides the HTTP router and handlers for the OwnCord server. +// +// client_update.go serves Tauri-compatible update metadata so the desktop +// client can check for new versions and self-update. +package api + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/updater" + "golang.org/x/mod/semver" +) + +// tauriPlatformResponse is the per-platform entry in the Tauri updater JSON. +type tauriPlatformResponse struct { + Signature string `json:"signature"` + URL string `json:"url"` +} + +// tauriUpdateResponse is the JSON shape the Tauri updater plugin expects. +type tauriUpdateResponse struct { + Version string `json:"version"` + Notes string `json:"notes,omitempty"` + PubDate string `json:"pub_date,omitempty"` + Platforms map[string]tauriPlatformResponse `json:"platforms"` +} + +// MountClientUpdateRoute adds the unauthenticated client-update endpoint. +// The route is outside the auth middleware because the client needs to check +// for updates before (or without) logging in. +func MountClientUpdateRoute(r chi.Router, u *updater.Updater) { + r.Get("/api/v1/client-update/{target}/{current_version}", handleClientUpdate(u)) +} + +func handleClientUpdate(u *updater.Updater) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + target := chi.URLParam(r, "target") + currentVersion := chi.URLParam(r, "current_version") + + if target == "" || currentVersion == "" { + http.Error(w, "missing target or current_version", http.StatusBadRequest) + return + } + + info, err := u.CheckForUpdate(r.Context()) + if err != nil { + http.Error(w, "failed to check for updates", http.StatusBadGateway) + return + } + + // Compare versions — return 204 if no update available. + cv := ensureV(currentVersion) + lv := ensureV(info.Latest) + if semver.Compare(cv, lv) >= 0 { + w.WriteHeader(http.StatusNoContent) + return + } + + // Find the .nsis.zip and .nsis.zip.sig assets from the release. + clientAssets := u.FindClientAssets() + nsisURL := clientAssets.InstallerURL + sigURL := clientAssets.SignatureURL + if nsisURL == "" || sigURL == "" { + w.WriteHeader(http.StatusNoContent) + return + } + + // Fetch the signature file content (small text file). + sigContent, err := u.FetchTextAsset(r.Context(), sigURL) + if err != nil { + http.Error(w, "failed to fetch signature", http.StatusBadGateway) + return + } + + resp := tauriUpdateResponse{ + Version: strings.TrimPrefix(info.Latest, "v"), + Notes: info.ReleaseNotes, + Platforms: map[string]tauriPlatformResponse{ + target: { + Signature: strings.TrimSpace(sigContent), + URL: nsisURL, + }, + }, + } + + writeJSON(w, http.StatusOK, resp) + } +} + +// ensureV returns a version string with a "v" prefix for semver comparison. +func ensureV(v string) string { + if strings.HasPrefix(v, "v") { + return v + } + return "v" + v +} diff --git a/Server/api/clientip_test.go b/Server/api/clientip_test.go new file mode 100644 index 00000000..271bc3c3 --- /dev/null +++ b/Server/api/clientip_test.go @@ -0,0 +1,165 @@ +package api + +// White-box tests for clientIP and isTrustedProxy. +// These live in package api (not api_test) so they can reach unexported symbols. + +import ( + "net/http/httptest" + "testing" +) + +// ─── isTrustedProxy ─────────────────────────────────────────────────────────── + +func TestIsTrustedProxy_EmptyList_ReturnsFalse(t *testing.T) { + trusted, err := isTrustedProxy("10.0.0.1", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if trusted { + t.Error("isTrustedProxy(empty list) = true, want false") + } +} + +func TestIsTrustedProxy_ExactIPMatch(t *testing.T) { + trusted, err := isTrustedProxy("10.0.0.1", []string{"10.0.0.1/32"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !trusted { + t.Error("isTrustedProxy exact match = false, want true") + } +} + +func TestIsTrustedProxy_CIDRMatch(t *testing.T) { + trusted, err := isTrustedProxy("192.168.1.50", []string{"192.168.1.0/24"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !trusted { + t.Error("isTrustedProxy CIDR match = false, want true") + } +} + +func TestIsTrustedProxy_CIDRNoMatch(t *testing.T) { + trusted, err := isTrustedProxy("10.9.9.9", []string{"192.168.1.0/24"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if trusted { + t.Error("isTrustedProxy CIDR non-match = true, want false") + } +} + +func TestIsTrustedProxy_MultipleCIDRs_FirstMatches(t *testing.T) { + trusted, err := isTrustedProxy("10.0.0.5", []string{"172.16.0.0/12", "10.0.0.0/8"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !trusted { + t.Error("isTrustedProxy multi-CIDR first match = false, want true") + } +} + +func TestIsTrustedProxy_MultipleCIDRs_NoneMatch(t *testing.T) { + trusted, err := isTrustedProxy("8.8.8.8", []string{"10.0.0.0/8", "192.168.0.0/16"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if trusted { + t.Error("isTrustedProxy multi-CIDR no match = true, want false") + } +} + +func TestIsTrustedProxy_InvalidCIDR_ReturnsError(t *testing.T) { + _, err := isTrustedProxy("10.0.0.1", []string{"not-a-cidr"}) + if err == nil { + t.Error("isTrustedProxy invalid CIDR should return error, got nil") + } +} + +func TestIsTrustedProxy_BarePlainIP_TreatedAsCIDR32(t *testing.T) { + // Bare IP without mask — should not panic; behaviour is to return error or + // treat as /32 depending on implementation. We just verify it doesn't panic. + _, _ = isTrustedProxy("10.0.0.1", []string{"10.0.0.1"}) +} + +func TestIsTrustedProxy_IPv6Match(t *testing.T) { + trusted, err := isTrustedProxy("::1", []string{"::1/128"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !trusted { + t.Error("isTrustedProxy IPv6 exact match = false, want true") + } +} + +// ─── clientIP with trusted proxies ─────────────────────────────────────────── + +func TestClientIP_NoTrustedProxies_UsesRemoteAddr(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "203.0.113.5:4321" + req.Header.Set("X-Real-IP", "1.2.3.4") + req.Header.Set("X-Forwarded-For", "1.2.3.4") + + ip := clientIPWithProxies(req, nil) + if ip != "203.0.113.5" { + t.Errorf("clientIP no trusted proxies = %q, want %q", ip, "203.0.113.5") + } +} + +func TestClientIP_TrustedProxy_UsesXRealIP(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "10.0.0.1:9999" + req.Header.Set("X-Real-IP", "203.0.113.42") + + ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + if ip != "203.0.113.42" { + t.Errorf("clientIP trusted proxy = %q, want %q", ip, "203.0.113.42") + } +} + +func TestClientIP_TrustedProxy_NoXRealIP_FallsBackToRemoteAddr(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "10.0.0.1:9999" + // No X-Real-IP header set. + + ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + if ip != "10.0.0.1" { + t.Errorf("clientIP trusted proxy no header = %q, want %q", ip, "10.0.0.1") + } +} + +func TestClientIP_UntrustedSource_IgnoresXRealIP(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "8.8.8.8:12345" + req.Header.Set("X-Real-IP", "192.168.1.1") // attacker-supplied + + ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + // Must use RemoteAddr, not the forged X-Real-IP. + if ip != "8.8.8.8" { + t.Errorf("clientIP untrusted source = %q, want %q", ip, "8.8.8.8") + } +} + +func TestClientIP_XForwardedFor_UsedWhenNoXRealIP(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "10.0.0.1:9999" + req.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1") + // No X-Real-IP; X-Forwarded-For first entry should be used. + + ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + if ip != "203.0.113.10" { + t.Errorf("clientIP X-Forwarded-For = %q, want %q", ip, "203.0.113.10") + } +} + +func TestClientIP_RemoteAddrWithoutPort(t *testing.T) { + // RemoteAddr sometimes has no port (e.g. Unix sockets in tests). + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "10.0.0.1" + + ip := clientIPWithProxies(req, nil) + if ip != "10.0.0.1" { + t.Errorf("clientIP no port = %q, want %q", ip, "10.0.0.1") + } +} diff --git a/Server/api/contract_test.go b/Server/api/contract_test.go new file mode 100644 index 00000000..3d2b1da1 --- /dev/null +++ b/Server/api/contract_test.go @@ -0,0 +1,181 @@ +package api_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" +) + +// ─── Contract tests: verify REST responses match API.md shapes ────────────── +// These tests assert that responses include all documented fields with the +// correct types, catching drift between implementation and specification. + +// ─── GET /api/v1/channels/{id}/messages: response shape ───────────────────── + +func TestContract_Messages_HasRequiredFields(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "contract-msg1", 1) + user, _ := database.GetUserByUsername("contract-msg1") + chID, _ := database.CreateChannel("contract-ch", "text", "", "", 0) + _, _ = database.CreateMessage(chID, user.ID, "contract test message", nil) + + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Messages []json.RawMessage `json:"messages"` + HasMore *bool `json:"has_more"` + } + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.HasMore == nil { + t.Error("response missing 'has_more' field") + } + if len(resp.Messages) == 0 { + t.Fatal("expected at least 1 message") + } + + // Parse the first message and verify all API.md fields are present. + var msg map[string]any + if err := json.Unmarshal(resp.Messages[0], &msg); err != nil { + t.Fatalf("decode message: %v", err) + } + + requiredFields := []string{ + "id", "channel_id", "user", "content", "reply_to", + "attachments", "reactions", "pinned", "edited_at", + "deleted", "timestamp", + } + for _, field := range requiredFields { + if _, ok := msg[field]; !ok { + t.Errorf("message missing required field %q (per API.md)", field) + } + } + + // Verify 'user' is an object with id, username. + userObj, ok := msg["user"].(map[string]any) + if !ok { + t.Fatal("'user' is not an object") + } + for _, f := range []string{"id", "username"} { + if _, ok := userObj[f]; !ok { + t.Errorf("user object missing field %q", f) + } + } + + // Verify 'attachments' is an array (even if empty). + if _, ok := msg["attachments"].([]any); !ok { + t.Error("'attachments' is not an array") + } + + // Verify 'reactions' is an array (even if empty). + if _, ok := msg["reactions"].([]any); !ok { + t.Error("'reactions' is not an array") + } +} + +// TestContract_Messages_ReactionsHaveMeFlag verifies that when a reaction +// exists, the response includes the 'me' boolean per API.md. +func TestContract_Messages_ReactionsHaveMeFlag(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "contract-react1", 1) + user, _ := database.GetUserByUsername("contract-react1") + chID, _ := database.CreateChannel("react-ch", "text", "", "", 0) + msgID, _ := database.CreateMessage(chID, user.ID, "reaction target", nil) + _ = database.AddReaction(msgID, user.ID, "👍") + + rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + + var resp struct { + Messages []struct { + Reactions []struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Me *bool `json:"me"` + } `json:"reactions"` + } `json:"messages"` + } + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Messages) == 0 { + t.Fatal("expected at least 1 message") + } + if len(resp.Messages[0].Reactions) == 0 { + t.Fatal("expected at least 1 reaction") + } + r := resp.Messages[0].Reactions[0] + if r.Emoji != "👍" { + t.Errorf("emoji = %q, want 👍", r.Emoji) + } + if r.Count != 1 { + t.Errorf("count = %d, want 1", r.Count) + } + if r.Me == nil { + t.Error("reaction missing 'me' boolean field (per API.md)") + } else if !*r.Me { + t.Error("me = false, want true (requesting user added the reaction)") + } +} + +// ─── GET /api/v1/search: response shape ───────────────────────────────────── + +func TestContract_Search_HasRequiredFields(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "contract-search1", 1) + user, _ := database.GetUserByUsername("contract-search1") + chID, _ := database.CreateChannel("search-ch", "text", "", "", 0) + _, _ = database.CreateMessage(chID, user.ID, "contractsearchterm in body", nil) + + rr := chGet(t, router, "/api/v1/search?q=contractsearchterm", token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Results []json.RawMessage `json:"results"` + } + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Results) == 0 { + t.Fatal("expected at least 1 search result") + } + + var result map[string]any + if err := json.Unmarshal(resp.Results[0], &result); err != nil { + t.Fatalf("decode result: %v", err) + } + + // Per API.md, search results must have these fields. + requiredFields := []string{ + "message_id", "channel_id", "channel_name", "user", + "content", "timestamp", + } + for _, field := range requiredFields { + if _, ok := result[field]; !ok { + t.Errorf("search result missing required field %q (per API.md)", field) + } + } + + // Verify 'user' is an object with id and username. + userObj, ok := result["user"].(map[string]any) + if !ok { + t.Fatal("search result 'user' is not an object") + } + for _, f := range []string{"id", "username"} { + if _, ok := userObj[f]; !ok { + t.Errorf("search result user object missing field %q", f) + } + } +} diff --git a/Server/api/invite_handler.go b/Server/api/invite_handler.go new file mode 100644 index 00000000..83838b79 --- /dev/null +++ b/Server/api/invite_handler.go @@ -0,0 +1,164 @@ +package api + +import ( + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// createInviteRequest is the JSON body for POST /api/v1/invites. +type createInviteRequest struct { + MaxUses int `json:"max_uses"` + ExpiresInHours int `json:"expires_in_hours"` +} + +// inviteResponse is the API shape for an invite. +type inviteResponse struct { + ID int64 `json:"id"` + Code string `json:"code"` + MaxUses *int `json:"max_uses"` + Uses int `json:"uses"` + ExpiresAt *string `json:"expires_at"` + Revoked bool `json:"revoked"` + CreatedAt string `json:"created_at"` +} + +// MountInviteRoutes registers invite endpoints on the given router. +// All routes require authentication and MANAGE_INVITES permission. +func MountInviteRoutes(r chi.Router, database *db.DB) { + r.Route("/api/v1/invites", func(r chi.Router) { + r.Use(AuthMiddleware(database)) + r.Use(RequirePermission(permissions.ManageInvites)) + + r.Post("/", handleCreateInvite(database)) + r.Get("/", handleListInvites(database)) + r.Delete("/{code}", handleRevokeInvite(database)) + }) +} + +// handleCreateInvite processes POST /api/v1/invites. +func handleCreateInvite(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req createInviteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Treat missing body as default values (all optional). + req = createInviteRequest{} + } + + user, ok := r.Context().Value(UserKey).(*db.User) + if !ok || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + + var expiresAt *time.Time + if req.ExpiresInHours > 0 { + t := time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour) + expiresAt = &t + } + + code, err := database.CreateInvite(user.ID, req.MaxUses, expiresAt) + if err != nil { + slog.Error("handleCreateInvite CreateInvite", "err", err, "user_id", user.ID) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to create invite", + }) + return + } + + inv, err := database.GetInvite(code) + if err != nil || inv == nil { + slog.Error("handleCreateInvite GetInvite", "err", err, "code", code) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to retrieve invite", + }) + return + } + + writeJSON(w, http.StatusCreated, toInviteResponse(inv)) + } +} + +// handleListInvites processes GET /api/v1/invites. +func handleListInvites(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + invites, err := database.ListInvites() + if err != nil { + slog.Error("handleListInvites ListInvites", "err", err) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to list invites", + }) + return + } + + resp := make([]inviteResponse, 0, len(invites)) + for _, inv := range invites { + resp = append(resp, toInviteResponse(inv)) + } + writeJSON(w, http.StatusOK, resp) + } +} + +// handleRevokeInvite processes DELETE /api/v1/invites/:code. +func handleRevokeInvite(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + + inv, err := database.GetInvite(code) + if err != nil { + slog.Error("handleRevokeInvite GetInvite", "err", err, "code", code) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to look up invite", + }) + return + } + if inv == nil { + writeJSON(w, http.StatusNotFound, errorResponse{ + Error: "NOT_FOUND", + Message: "invite not found", + }) + return + } + + if err := database.RevokeInvite(code); err != nil { + slog.Error("handleRevokeInvite RevokeInvite", "err", err, "code", code) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to revoke invite", + }) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +// toInviteResponse converts a db.Invite to the API response shape. +func toInviteResponse(inv *db.Invite) inviteResponse { + var maxUses *int + if inv.MaxUses != nil { + v := *inv.MaxUses + maxUses = &v + } + return inviteResponse{ + ID: inv.ID, + Code: inv.Code, + MaxUses: maxUses, + Uses: inv.Uses, + ExpiresAt: inv.ExpiresAt, + Revoked: inv.Revoked, + CreatedAt: inv.CreatedAt, + } +} diff --git a/Server/api/invite_handler_test.go b/Server/api/invite_handler_test.go new file mode 100644 index 00000000..bf8c7452 --- /dev/null +++ b/Server/api/invite_handler_test.go @@ -0,0 +1,271 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// buildInviteRouter returns a chi router with invite routes and auth middleware. +func buildInviteRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler { + r := chi.NewRouter() + api.MountAuthRoutes(r, database, limiter) + api.MountInviteRoutes(r, database) + return r +} + +// loginAndGetToken creates a user with a known password and returns their session token. +func loginAndGetToken(t *testing.T, _ http.Handler, database *db.DB, username string, roleID int) string { + t.Helper() + hash, _ := auth.HashPassword("Password1!") + uid, _ := database.CreateUser(username, hash, roleID) + token, _ := auth.GenerateToken() + _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + return token +} + +// ─── POST /api/v1/invites ───────────────────────────────────────────────────── + +func TestCreateInvite_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + // Admin role (id=2) has MANAGE_INVITES (0x4000000) set. + token := loginAndGetToken(t, router, database, "invitecreator", 2) + + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{ + "max_uses": 5, + "expires_in_hours": 48, + }) + + if rr.Code != http.StatusCreated { + t.Errorf("CreateInvite status = %d, want 201; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp["code"] == nil { + t.Error("CreateInvite response missing code") + } +} + +func TestCreateInvite_Unauthorized(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/invites", map[string]any{ + "max_uses": 5, + }) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("CreateInvite no auth status = %d, want 401", rr.Code) + } +} + +func TestCreateInvite_MemberForbidden(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + // Member role (id=4) does NOT have MANAGE_INVITES. + token := loginAndGetToken(t, router, database, "memberuser", 4) + + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{ + "max_uses": 1, + }) + + if rr.Code != http.StatusForbidden { + t.Errorf("CreateInvite member status = %d, want 403", rr.Code) + } +} + +func TestCreateInvite_Unlimited(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "adminuser2", 2) + + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) + + if rr.Code != http.StatusCreated { + t.Errorf("CreateInvite unlimited status = %d, want 201", rr.Code) + } +} + +// ─── GET /api/v1/invites ────────────────────────────────────────────────────── + +func TestListInvites_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "listuser", 2) + + // Create a couple of invites. + postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{"max_uses": 1}) + postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{"max_uses": 5}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("ListInvites status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + + var resp []any + _ = json.NewDecoder(rr.Body).Decode(&resp) + if len(resp) < 2 { + t.Errorf("ListInvites returned %d items, want >= 2", len(resp)) + } +} + +func TestListInvites_Unauthorized(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("ListInvites no auth status = %d, want 401", rr.Code) + } +} + +// ─── DELETE /api/v1/invites/:code ───────────────────────────────────────────── + +func TestRevokeInvite_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "revoker", 2) + + // Create invite via API. + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) + if rr.Code != http.StatusCreated { + t.Fatalf("Create invite for revoke test: status = %d, body = %s", rr.Code, rr.Body.String()) + } + var created map[string]any + _ = json.NewDecoder(rr.Body).Decode(&created) + codeVal, ok := created["code"] + if !ok || codeVal == nil { + t.Fatalf("Create invite response missing code field; body parsed as %v", created) + } + code := codeVal.(string) + + // Revoke it. + req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr2 := httptest.NewRecorder() + router.ServeHTTP(rr2, req) + + if rr2.Code != http.StatusNoContent { + t.Errorf("RevokeInvite status = %d, want 204; body = %s", rr2.Code, rr2.Body.String()) + } + + // Verify invite is revoked. + inv, _ := database.GetInvite(code) + if inv == nil || !inv.Revoked { + t.Error("Invite not revoked in database after DELETE") + } +} + +func TestRevokeInvite_NotFound(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "revoker2", 2) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/doesnotexist", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Errorf("RevokeInvite not found status = %d, want 404", rr.Code) + } +} + +func TestRevokeInvite_MemberForbidden(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + adminToken := loginAndGetToken(t, router, database, "admin3", 2) + memberToken := loginAndGetToken(t, router, database, "member3", 4) + + // Admin creates invite. + rr := postJSONWithToken(t, router, "/api/v1/invites", adminToken, map[string]any{}) + var created map[string]any + _ = json.NewDecoder(rr.Body).Decode(&created) + code := created["code"].(string) + + // Member tries to revoke. + req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil) + req.Header.Set("Authorization", "Bearer "+memberToken) + req.RemoteAddr = "127.0.0.1:9999" + rr2 := httptest.NewRecorder() + router.ServeHTTP(rr2, req) + + if rr2.Code != http.StatusForbidden { + t.Errorf("RevokeInvite member status = %d, want 403", rr2.Code) + } +} + +// TestListInvites_IncludesRevokedAndActive checks the list endpoint returns +// correct data for both revoked and active invites. +func TestListInvites_IncludesRevokedAndActive(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "listall", 2) + + // Create and revoke one invite. + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) + if rr.Code != http.StatusCreated { + t.Fatalf("Create invite for list test: status = %d, body = %s", rr.Code, rr.Body.String()) + } + var created map[string]any + _ = json.NewDecoder(rr.Body).Decode(&created) + code := created["code"].(string) + + delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil) + delReq.Header.Set("Authorization", "Bearer "+token) + delReq.RemoteAddr = "127.0.0.1:9999" + httptest.NewRecorder() // discard + router.ServeHTTP(httptest.NewRecorder(), delReq) + + // Create one active invite. + postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) + + // List should include both. + req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr2 := httptest.NewRecorder() + router.ServeHTTP(rr2, req) + + if rr2.Code != http.StatusOK { + t.Errorf("ListInvites status = %d, want 200", rr2.Code) + } +} + diff --git a/Server/api/middleware.go b/Server/api/middleware.go new file mode 100644 index 00000000..6d7ea7ce --- /dev/null +++ b/Server/api/middleware.go @@ -0,0 +1,303 @@ +package api + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// contextKey is an unexported type for context keys in this package. +type contextKey int + +const ( + // UserKey is the context key for the authenticated *db.User. + UserKey contextKey = iota + // SessionKey is the context key for the authenticated *db.Session. + SessionKey + // RoleKey is the context key for the *db.Role of the authenticated user. + RoleKey +) + +// AuthMiddleware reads the "Authorization: Bearer " header, validates +// the session, and injects the user and session into the request context. +// Returns 401 if the token is missing, invalid, or the session is expired. +func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := auth.ExtractBearerToken(r) + if !ok { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "missing or invalid authorization header", + }) + return + } + + hash := auth.HashToken(token) + sess, err := database.GetSessionByTokenHash(hash) + if err != nil || sess == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "invalid or expired session", + }) + return + } + + // Check expiry. + if auth.IsSessionExpired(sess.ExpiresAt) { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "session has expired", + }) + return + } + + // Load user. + user, err := database.GetUserByID(sess.UserID) + if err != nil || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "user not found", + }) + return + } + + // Reject effectively-banned users before any further processing. + if auth.IsEffectivelyBanned(user) { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "your account has been suspended", + }) + return + } + + // Load role for permission checks. + role, err := database.GetRoleByID(user.RoleID) + if err != nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "role not found", + }) + return + } + + // Touch session in background — non-fatal if it fails. + _ = database.TouchSession(hash) + + ctx := context.WithValue(r.Context(), UserKey, user) + ctx = context.WithValue(ctx, SessionKey, sess) + ctx = context.WithValue(ctx, RoleKey, role) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RequirePermission returns middleware that checks the authenticated user's +// role permissions. Returns 403 if the user lacks the required permission. +// The ADMINISTRATOR bit (0x40000000) bypasses all checks. +func RequirePermission(perm int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + role, ok := r.Context().Value(RoleKey).(*db.Role) + if !ok || role == nil { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "insufficient permissions", + }) + return + } + + // ADMINISTRATOR bypasses all permission checks. + if permissions.HasAdmin(role.Permissions) { + next.ServeHTTP(w, r) + return + } + + if role.Permissions&perm == 0 { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "insufficient permissions", + }) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// RateLimitMiddleware returns middleware that limits requests per IP using the +// provided RateLimiter. The client IP is resolved via clientIPWithProxies using +// the supplied trustedProxies CIDRs — pass nil to always use RemoteAddr. +// Returns 429 with Retry-After when the limit is exceeded. +func RateLimitMiddleware(limiter *auth.RateLimiter, limit int, window time.Duration, trustedProxies ...[]string) func(http.Handler) http.Handler { + var proxies []string + if len(trustedProxies) > 0 { + proxies = trustedProxies[0] + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := clientIPWithProxies(r, proxies) + + if !limiter.Allow(ip, limit, window) { + w.Header().Set("Retry-After", fmt.Sprintf("%d", int(window.Seconds()))) + writeJSON(w, http.StatusTooManyRequests, errorResponse{ + Error: "RATE_LIMITED", + Message: "too many requests, please slow down", + }) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +// clientIP returns the connecting IP from RemoteAddr, ignoring any proxy +// headers. It is safe to use for audit logging and lockout keys where proxy +// header trust has not been established. For rate-limiting with proxy support +// use clientIPWithProxies. +func clientIP(r *http.Request) string { + return clientIPWithProxies(r, nil) +} + +// clientIPWithProxies returns the real client IP for rate-limiting purposes. +// +// Security model: +// - Always parse the actual connecting address from r.RemoteAddr. +// - Only honour X-Real-IP or X-Forwarded-For if the connecting address matches +// one of the trustedCIDRs. This prevents clients from forging their IP to +// bypass rate limits. +// - If trustedCIDRs is empty (the default), RemoteAddr is always used. +// +// Invalid CIDR entries in trustedCIDRs are silently skipped so that a +// misconfigured entry cannot crash the server; the connecting IP is used as the +// fallback. +func clientIPWithProxies(r *http.Request, trustedCIDRs []string) string { + remoteHost, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // RemoteAddr without port (e.g. Unix socket or test stub) — use as-is. + remoteHost = r.RemoteAddr + } + + if len(trustedCIDRs) == 0 { + return remoteHost + } + + trusted, _ := isTrustedProxy(remoteHost, trustedCIDRs) + if !trusted { + return remoteHost + } + + // Prefer X-Real-IP when coming from a trusted proxy. + if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" { + return xri + } + + // Fall back to the leftmost (client) entry in X-Forwarded-For. + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.SplitN(xff, ",", 2) + if client := strings.TrimSpace(parts[0]); client != "" { + return client + } + } + + return remoteHost +} + +// isTrustedProxy reports whether remoteIP (a plain IP string, no port) falls +// within any of the provided CIDR ranges. It returns an error if any CIDR is +// malformed. +func isTrustedProxy(remoteIP string, cidrList []string) (bool, error) { + ip := net.ParseIP(remoteIP) + if ip == nil { + return false, nil + } + for _, cidr := range cidrList { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + return false, fmt.Errorf("isTrustedProxy: invalid CIDR %q: %w", cidr, err) + } + if network.Contains(ip) { + return true, nil + } + } + return false, nil +} + +// SecurityHeaders sets a standard suite of defensive HTTP response headers on +// every response. It must be added to the router-level middleware stack so that +// all routes, including error responses, carry these headers. +// +// Header choices: +// - X-Content-Type-Options: nosniff — prevent MIME-type sniffing +// - X-Frame-Options: DENY — block clickjacking via iframes +// - X-XSS-Protection: 0 — disable legacy XSS filter; rely on CSP +// - Referrer-Policy: strict-origin-when-cross-origin +// - Content-Security-Policy: default-src 'self' +// - Permissions-Policy: camera=(), microphone=(), geolocation=() +// - Cache-Control: no-store — prevent sensitive data caching +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("X-XSS-Protection", "0") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set("Content-Security-Policy", "default-src 'self'") + h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + h.Set("Cache-Control", "no-store") + next.ServeHTTP(w, r) + }) +} + +// MaxBodySize wraps r.Body with http.MaxBytesReader so that reads beyond +// maxBytes return an error. This prevents clients from exhausting server memory +// by sending arbitrarily large request bodies. +// +// Usage in the router: +// +// r.Use(MaxBodySize(1 << 20)) // 1 MiB default for API endpoints +// +// Upload endpoints that need a higher limit should apply their own +// http.MaxBytesReader or a route-scoped middleware with a larger value. +func MaxBodySize(maxBytes int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + next.ServeHTTP(w, r) + }) + } +} + +// MaxBodySizeUnless is like MaxBodySize but skips the limit for specific paths. +// Exempted paths apply their own limit via route-scoped middleware. +func MaxBodySizeUnless(maxBytes int64, exemptPaths ...string) func(http.Handler) http.Handler { + exempt := make(map[string]bool, len(exemptPaths)) + for _, p := range exemptPaths { + exempt[p] = true + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !exempt[r.URL.Path] { + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + } + next.ServeHTTP(w, r) + }) + } +} + +// errorResponse is the standard error JSON shape. +type errorResponse struct { + Error string `json:"error"` + Message string `json:"message"` +} diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go new file mode 100644 index 00000000..387f3f45 --- /dev/null +++ b/Server/api/middleware_test.go @@ -0,0 +1,638 @@ +package api_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func newAPITestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: apiTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// ok is a trivial handler that responds 200 OK to confirm the middleware +// passed the request through. +func ok(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} + +// bearerToken wraps an HTTP handler with an Authorization header bearing token. +func withBearer(req *http.Request, token string) *http.Request { + req.Header.Set("Authorization", "Bearer "+token) + return req +} + +// ─── AuthMiddleware tests ───────────────────────────────────────────────────── + +func TestAuthMiddleware_ValidToken(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("alice", "hash", 4) + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("AuthMiddleware valid token status = %d, want %d", rr.Code, http.StatusOK) + } +} + +func TestAuthMiddleware_MissingToken(t *testing.T) { + database := newAPITestDB(t) + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware no token status = %d, want 401", rr.Code) + } +} + +func TestAuthMiddleware_InvalidToken(t *testing.T) { + database := newAPITestDB(t) + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, "notarealtoken") + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware invalid token status = %d, want 401", rr.Code) + } +} + +func TestAuthMiddleware_ExpiredSession(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("bob", "hash", 4) + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + + // Insert an already-expired session. + pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05") + _, _ = database.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, + uid, hash, "test", "127.0.0.1", pastTime, + ) + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware expired session status = %d, want 401", rr.Code) + } +} + +func TestAuthMiddleware_MalformedAuthHeader(t *testing.T) { + database := newAPITestDB(t) + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + + cases := []string{ + "Token abc", // wrong scheme + "Bearer", // missing token after Bearer + "abc", // no space + } + for _, header := range cases { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", header) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware header=%q status = %d, want 401", header, rr.Code) + } + } +} + +// ─── RequirePermission tests ────────────────────────────────────────────────── + +func TestRequirePermission_Allowed(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("carol", "hash", 4) // Member role = 0x663 + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + + // SEND_MESSAGES = 0x1 — Member role has this bit + h := api.AuthMiddleware(database)( + api.RequirePermission(0x1)(http.HandlerFunc(ok)), + ) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("RequirePermission allowed status = %d, want 200", rr.Code) + } +} + +func TestRequirePermission_Forbidden(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("dave", "hash", 4) // Member role = 0x663 + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + + // MANAGE_ROLES = 0x1000000 — Member does not have this + h := api.AuthMiddleware(database)( + api.RequirePermission(0x1000000)(http.HandlerFunc(ok)), + ) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("RequirePermission forbidden status = %d, want 403", rr.Code) + } +} + +func TestRequirePermission_Administrator_Bypass(t *testing.T) { + database := newAPITestDB(t) + // Owner role (id=1) has permissions 0x7FFFFFFF which includes ADMINISTRATOR (0x40000000) + uid, _ := database.CreateUser("owner", "hash", 1) + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + + // Any permission should pass for ADMINISTRATOR + h := api.AuthMiddleware(database)( + api.RequirePermission(0x1000000)(http.HandlerFunc(ok)), + ) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("RequirePermission administrator bypass status = %d, want 200", rr.Code) + } +} + +// ─── RateLimitMiddleware tests ──────────────────────────────────────────────── + +func TestRateLimitMiddleware_UnderLimit(t *testing.T) { + limiter := auth.NewRateLimiter() + + h := api.RateLimitMiddleware(limiter, 5, time.Minute)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:1234" + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("RateLimitMiddleware under limit status = %d, want 200", rr.Code) + } +} + +func TestRateLimitMiddleware_OverLimit(t *testing.T) { + limiter := auth.NewRateLimiter() + limit := 3 + + h := api.RateLimitMiddleware(limiter, limit, time.Minute)(http.HandlerFunc(ok)) + + for range limit { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.2:1234" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + } + + // This next request should be rate-limited. + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.2:1234" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusTooManyRequests { + t.Errorf("RateLimitMiddleware over limit status = %d, want 429", rr.Code) + } +} + +func TestRateLimitMiddleware_RetryAfterHeader(t *testing.T) { + limiter := auth.NewRateLimiter() + + h := api.RateLimitMiddleware(limiter, 1, time.Minute)(http.HandlerFunc(ok)) + + // Exhaust limit. + for range 2 { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.3:1234" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.3:1234" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Header().Get("Retry-After") == "" { + t.Error("RateLimitMiddleware: missing Retry-After header on 429 response") + } +} + +func TestRateLimitMiddleware_XRealIPIgnoredWithoutTrustedProxy(t *testing.T) { + // Without trusted proxies configured, X-Real-IP must be ignored. + // Each request with the same RemoteAddr host counts as the same IP regardless + // of what the X-Real-IP header says. + limiter := auth.NewRateLimiter() + limit := 2 + + h := api.RateLimitMiddleware(limiter, limit, time.Minute)(http.HandlerFunc(ok)) + + // Two requests from RemoteAddr 10.0.0.99 with an attacker-supplied X-Real-IP. + for range limit { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Real-IP", "192.168.1.1") // forged; must be ignored + req.RemoteAddr = "10.0.0.99:9999" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + } + + // Third request from the same RemoteAddr should be blocked — rate key is + // 10.0.0.99, not the forged 192.168.1.1. + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Real-IP", "192.168.1.1") + req.RemoteAddr = "10.0.0.99:9999" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusTooManyRequests { + t.Errorf("RateLimitMiddleware no-trusted-proxy status = %d, want 429", rr.Code) + } +} + +func TestRateLimitMiddleware_XRealIPHonouredFromTrustedProxy(t *testing.T) { + // With a trusted proxy configured, X-Real-IP from that proxy is used. + limiter := auth.NewRateLimiter() + limit := 2 + trustedCIDRs := []string{"10.0.0.0/8"} + + h := api.RateLimitMiddleware(limiter, limit, time.Minute, trustedCIDRs)(http.HandlerFunc(ok)) + + // Two requests coming through trusted proxy 10.0.0.1, client IP 203.0.113.5. + for range limit { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Real-IP", "203.0.113.5") + req.RemoteAddr = "10.0.0.1:9999" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + } + + // Third request with same X-Real-IP from same trusted proxy — should be blocked. + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Real-IP", "203.0.113.5") + req.RemoteAddr = "10.0.0.1:9999" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusTooManyRequests { + t.Errorf("RateLimitMiddleware trusted proxy X-Real-IP status = %d, want 429", rr.Code) + } +} + +// ─── Fix 2.10: Ban expiry in AuthMiddleware ─────────────────────────────────── + +// TestAuthMiddleware_BannedUserBlocked verifies that an actively banned user +// with no expiry cannot pass the auth middleware. +func TestAuthMiddleware_BannedUserBlocked(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("banneduser", "hash", 4) + _ = database.BanUser(uid, "rule violation", nil) // permanent ban + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("AuthMiddleware banned user status = %d, want 403", rr.Code) + } +} + +// TestAuthMiddleware_ExpiredBanAllowed verifies that a user whose ban has +// expired in the past can pass the auth middleware. +func TestAuthMiddleware_ExpiredBanAllowed(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("expbanned", "hash", 4) + + // Set ban with an expiry time in the past. + past := time.Now().UTC().Add(-time.Hour) + _ = database.BanUser(uid, "temp ban", &past) + + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("AuthMiddleware expired-ban user status = %d, want 200", rr.Code) + } +} + +// TestAuthMiddleware_ActiveTemporaryBanBlocked verifies that a user with a +// temporary ban whose expiry is in the future is still blocked. +func TestAuthMiddleware_ActiveTemporaryBanBlocked(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("tempbanned", "hash", 4) + + // Set ban with an expiry time in the future. + future := time.Now().UTC().Add(time.Hour) + _ = database.BanUser(uid, "temp ban", &future) + + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("AuthMiddleware active temp-ban user status = %d, want 403", rr.Code) + } +} + +// ─── SecurityHeaders tests ─────────────────────────────────────────────────── + +func TestSecurityHeaders_AllHeadersPresent(t *testing.T) { + h := api.SecurityHeaders(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + want := map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-Xss-Protection": "0", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'self'", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Cache-Control": "no-store", + } + for header, expected := range want { + if got := rr.Header().Get(header); got != expected { + t.Errorf("SecurityHeaders: %s = %q, want %q", header, got, expected) + } + } +} + +func TestSecurityHeaders_PassesThrough(t *testing.T) { + // Middleware must not swallow the response — downstream handler must be called. + called := false + h := api.SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if !called { + t.Error("SecurityHeaders: downstream handler was not called") + } + if rr.Code != http.StatusTeapot { + t.Errorf("SecurityHeaders: status = %d, want 418", rr.Code) + } +} + +func TestSecurityHeaders_DoesNotOverrideExistingHeaders(t *testing.T) { + // If a downstream handler sets its own CSP, SecurityHeaders should not clobber it + // because it runs before the handler writes. The middleware sets headers first, + // the handler can then override them — that is the correct layering. + // This test just confirms the middleware itself sets all seven headers. + h := api.SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handler overrides CSP after SecurityHeaders has already set it. + w.Header().Set("Content-Security-Policy", "default-src 'none'") + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + // The handler's override wins because it runs after the middleware sets the header. + if got := rr.Header().Get("Content-Security-Policy"); got != "default-src 'none'" { + t.Errorf("SecurityHeaders: handler CSP override = %q, want \"default-src 'none'\"", got) + } +} + +// ─── MaxBodySize tests ──────────────────────────────────────────────────────── + +func TestMaxBodySize_UnderLimit(t *testing.T) { + // A body smaller than the limit must be read successfully by the handler. + const limit = 10 // bytes + body := strings.NewReader("hello") // 5 bytes — under limit + + h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data := make([]byte, 20) + n, _ := r.Body.Read(data) + if n != 5 { + t.Errorf("MaxBodySize under limit: read %d bytes, want 5", n) + } + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodPost, "/", body) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("MaxBodySize under limit: status = %d, want 200", rr.Code) + } +} + +func TestMaxBodySize_ExactLimit(t *testing.T) { + // A body exactly at the limit must be read without error. + const limit = 5 + body := strings.NewReader("hello") // exactly 5 bytes + + h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data := make([]byte, 10) + n, _ := r.Body.Read(data) + if n != 5 { + t.Errorf("MaxBodySize exact limit: read %d bytes, want 5", n) + } + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodPost, "/", body) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("MaxBodySize exact limit: status = %d, want 200", rr.Code) + } +} + +func TestMaxBodySize_OverLimit(t *testing.T) { + // Reading beyond the limit must return an error from MaxBytesReader. + const limit = 5 + body := strings.NewReader("hello world") // 11 bytes — over limit + + var readErr error + h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data := make([]byte, 20) + _, readErr = r.Body.Read(data) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodPost, "/", body) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if readErr == nil { + t.Error("MaxBodySize over limit: expected read error, got nil") + } +} + +func TestMaxBodySize_NilBody(t *testing.T) { + // GET requests with no body must pass through without panic. + h := api.MaxBodySize(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + // Must not panic. + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("MaxBodySize nil body: status = %d, want 200", rr.Code) + } +} + +func TestMaxBodySize_PassesThrough(t *testing.T) { + // Downstream handler must be called and its status code preserved. + h := api.MaxBodySize(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + })) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("data")) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusCreated { + t.Errorf("MaxBodySize pass-through: status = %d, want 201", rr.Code) + } +} + +// apiTestSchema is the full schema needed for all api tests (middleware, +// auth handler, and invite handler). +var apiTestSchema = []byte(` +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT, + permissions INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES + (1, 'Owner', '#E74C3C', 2147483647, 100, 0), + (2, 'Admin', '#F39C12', 1073741823, 80, 0), + (3, 'Moderator', '#3498DB', 1048575, 60, 0), + (4, 'Member', NULL, 1635, 40, 1); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + avatar TEXT, + role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, + status TEXT NOT NULL DEFAULT 'offline', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); + +CREATE TABLE IF NOT EXISTS invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + redeemed_by INTEGER REFERENCES users(id), + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); +`) diff --git a/Server/api/router.go b/Server/api/router.go new file mode 100644 index 00000000..ace2057e --- /dev/null +++ b/Server/api/router.go @@ -0,0 +1,175 @@ +// Package api provides the HTTP router and handlers for the OwnCord server. +package api + +import ( + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/owncord/server/admin" + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" + "github.com/owncord/server/storage" + "github.com/owncord/server/updater" + "github.com/owncord/server/ws" +) + +// NewRouter builds and returns the fully configured HTTP handler and the +// WebSocket hub (so the caller can call hub.GracefulStop on shutdown). +func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.RingBuffer) (http.Handler, *ws.Hub) { + r := chi.NewRouter() + + // Middleware stack. + r.Use(middleware.RequestID) + r.Use(setRequestIDHeader) // echo request ID into response header + // NOTE: middleware.RealIP is intentionally omitted — trusting X-Real-IP from + // any source allows IP spoofing for rate-limit bypass. IP header trust is now + // handled explicitly in clientIPWithProxies using the trusted_proxies config. + r.Use(middleware.Recoverer) + r.Use(requestLogger) // structured request/response logging + r.Use(SecurityHeaders) + r.Use(MaxBodySizeUnless(1<<20, "/api/v1/uploads")) // 1 MiB default; upload route exempt + + // Health check — unauthenticated, no versioning prefix. + r.Get("/health", handleHealth(ver)) + + // Shared rate limiter for auth endpoints. + limiter := auth.NewRateLimiter() + + // Versioned API routes. + r.Route("/api/v1", func(r chi.Router) { + r.Get("/health", handleHealth(ver)) + r.Get("/info", handleInfo(cfg, ver)) + }) + + // Auth routes: register, login, logout, me. + MountAuthRoutes(r, database, limiter) + + // Invite management routes (require MANAGE_INVITES permission). + MountInviteRoutes(r, database) + + // Channel and message REST routes. + MountChannelRoutes(r, database) + + // File upload and serving routes. + store, storeErr := storage.New(cfg.Upload.StorageDir, cfg.Upload.MaxSizeMB) + if storeErr != nil { + slog.Error("failed to create file storage", "error", storeErr) + } else { + MountUploadRoutes(r, database, store) + } + + // Voice credentials REST route. + MountVoiceRoutes(r, cfg, database) + + // WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here. + hub := ws.NewHub(database, limiter) + + // Create SFU if voice config is present; voice is disabled on failure. + sfu, sfuErr := ws.NewSFU(&cfg.Voice) + if sfuErr != nil { + slog.Warn("failed to create SFU, voice disabled", "error", sfuErr) + } else { + hub.SetSFU(sfu) + } + + go hub.Run() + r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins)) + + // Admin panel: static files + REST API (Phase 6). + u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord") + r.Mount("/admin", admin.NewHandler(database, ver, hub, u, logBuf)) + + // Client auto-update endpoint (unauthenticated). + MountClientUpdateRoute(r, u) + + return r, hub +} + +// serverStartTime records when the process started; used for uptime in /health. +var serverStartTime = time.Now() + +// healthResponse is the JSON shape returned by GET /health. +type healthResponse struct { + Status string `json:"status"` + Version string `json:"version"` + Uptime int64 `json:"uptime"` +} + +// infoResponse is the JSON shape returned by GET /api/v1/info. +type infoResponse struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func handleHealth(ver string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, healthResponse{ + Status: "ok", + Version: ver, + Uptime: int64(time.Since(serverStartTime).Seconds()), + }) + } +} + +func handleInfo(cfg *config.Config, ver string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, infoResponse{ + Name: cfg.Server.Name, + Version: ver, + }) + } +} + +// setRequestIDHeader copies the request ID from context into the response header. +func setRequestIDHeader(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := middleware.GetReqID(r.Context()) + if requestID != "" { + w.Header().Set("X-Request-Id", requestID) + } + next.ServeHTTP(w, r) + }) +} + +// requestLogger logs every HTTP request with method, path, status, and duration. +// Health checks are logged at Debug level to avoid noise. +func requestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + elapsed := time.Since(start) + status := ww.Status() + + // Health checks at Debug level; errors at Warn; everything else at Info. + path := r.URL.Path + attrs := []any{ + "method", r.Method, + "path", path, + "status", status, + "duration_ms", elapsed.Milliseconds(), + } + switch { + case path == "/health" || path == "/api/v1/health": + slog.Debug("http request", attrs...) + case status >= 500: + slog.Error("http request", attrs...) + case status >= 400: + slog.Warn("http request", attrs...) + default: + slog.Info("http request", attrs...) + } + }) +} + +// writeJSON encodes v as JSON and writes it to w with the given status code. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/Server/api/router_test.go b/Server/api/router_test.go new file mode 100644 index 00000000..0fa99358 --- /dev/null +++ b/Server/api/router_test.go @@ -0,0 +1,195 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/owncord/server/api" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// setupRouter creates a test router with an in-memory database. +func setupRouter(t *testing.T) http.Handler { + t.Helper() + + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open error: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate error: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + cfg := &config.Config{ + Server: config.ServerConfig{ + Name: "Test Server", + Port: 8443, + }, + } + + handler, _ := api.NewRouter(cfg, database, "test", nil) + return handler +} + +func TestHealthEndpointReturns200(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("GET /health status = %d, want 200", rec.Code) + } +} + +func TestHealthEndpointReturnsJSON(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + contentType := rec.Header().Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + t.Errorf("Content-Type = %q, want application/json", contentType) + } + + var body map[string]any + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("response body is not valid JSON: %v", err) + } +} + +func TestHealthEndpointStatusOK(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + var body map[string]any + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("JSON decode error: %v", err) + } + + if body["status"] != "ok" { + t.Errorf("status = %v, want 'ok'", body["status"]) + } +} + +func TestHealthEndpointHasVersion(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + var body map[string]any + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("JSON decode error: %v", err) + } + + if body["version"] == nil || body["version"] == "" { + t.Error("health response missing 'version' field") + } +} + +func TestAPIV1InfoEndpoint(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/info", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("GET /api/v1/info status = %d, want 200", rec.Code) + } +} + +func TestAPIV1InfoReturnsServerName(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/info", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + var body map[string]any + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("JSON decode error: %v", err) + } + + if body["name"] != "Test Server" { + t.Errorf("name = %v, want 'Test Server'", body["name"]) + } +} + +func TestAPIV1InfoReturnsVersion(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/info", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + var body map[string]any + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("JSON decode error: %v", err) + } + + if body["version"] == nil { + t.Error("info response missing 'version' field") + } +} + +func TestUnknownRouteReturns404(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/nonexistent", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET /api/v1/nonexistent status = %d, want 404", rec.Code) + } +} + +func TestRequestIDMiddleware(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + // Request ID header should be set by middleware. + requestID := rec.Header().Get("X-Request-Id") + if requestID == "" { + t.Error("X-Request-Id header not set by middleware") + } +} + +func TestHealthMethodNotAllowed(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodPost, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("POST /health status = %d, want 405", rec.Code) + } +} diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go new file mode 100644 index 00000000..2715705f --- /dev/null +++ b/Server/api/upload_handler.go @@ -0,0 +1,144 @@ +package api + +import ( + "fmt" + "log/slog" + "mime" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/owncord/server/db" + "github.com/owncord/server/storage" +) + +// uploadResponse is the JSON shape returned by POST /api/v1/uploads. +type uploadResponse struct { + ID string `json:"id"` + Filename string `json:"filename"` + Size int64 `json:"size"` + Mime string `json:"mime"` + URL string `json:"url"` +} + +// MountUploadRoutes registers upload and file-serving endpoints. +func MountUploadRoutes(r chi.Router, database *db.DB, store *storage.Storage) { + // Upload requires authentication and a higher body size limit (100 MB). + r.With( + AuthMiddleware(database), + MaxBodySize(100<<20), + ).Post("/api/v1/uploads", handleUpload(database, store)) + // File serving is public (URLs are unguessable UUIDs). + r.Get("/api/v1/files/{id}", handleServeFile(database, store)) +} + +func handleUpload(database *db.DB, store *storage.Storage) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Parse multipart form — 10 MB in memory, rest on disk. + if err := r.ParseMultipartForm(10 << 20); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "BAD_REQUEST", + "message": "invalid multipart form", + }) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "BAD_REQUEST", + "message": "missing file field", + }) + return + } + defer file.Close() //nolint:errcheck + + // Generate UUID for storage. + fileID := uuid.New().String() + + // Detect MIME type from the Content-Type header (set by the browser). + mime := header.Header.Get("Content-Type") + if mime == "" { + mime = "application/octet-stream" + } + // Strip parameters (e.g., "image/png; charset=utf-8" → "image/png"). + if idx := strings.Index(mime, ";"); idx != -1 { + mime = strings.TrimSpace(mime[:idx]) + } + + // Store file on disk (validates file type via magic bytes). + if err := store.Save(fileID, file); err != nil { + slog.Warn("file upload rejected", "error", err) + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "BAD_REQUEST", + "message": fmt.Sprintf("upload rejected: %s", err), + }) + return + } + + // Insert attachment record in DB (unlinked — message_id is NULL). + if err := database.CreateAttachment(fileID, header.Filename, fileID, mime, header.Size); err != nil { + // Clean up stored file on DB failure. + _ = store.Delete(fileID) + slog.Error("failed to create attachment record", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "INTERNAL_ERROR", + "message": "failed to save attachment", + }) + return + } + + slog.Info("file uploaded", "id", fileID, "filename", header.Filename, "size", header.Size, "mime", mime) + + writeJSON(w, http.StatusCreated, uploadResponse{ + ID: fileID, + Filename: header.Filename, + Size: header.Size, + Mime: mime, + URL: "/api/v1/files/" + fileID, + }) + } +} + +func handleServeFile(database *db.DB, store *storage.Storage) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fileID := chi.URLParam(r, "id") + if fileID == "" { + http.NotFound(w, r) + return + } + + // Look up attachment metadata. + att, err := database.GetAttachmentByID(fileID) + if err != nil { + slog.Error("failed to look up attachment", "id", fileID, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if att == nil { + http.NotFound(w, r) + return + } + + // Open file from storage. + f, err := store.Open(att.StoredAs) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() //nolint:errcheck + + // Set headers before ServeContent to ensure correct MIME type. + w.Header().Set("Content-Type", att.MimeType) + w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": att.Filename})) + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + // CORS: allow webview to read the response body. + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Expose-Headers", "Content-Type, Content-Length") + + modTime := time.Now() + http.ServeContent(w, r, att.Filename, modTime, f) + } +} diff --git a/Server/api/voice_handler.go b/Server/api/voice_handler.go new file mode 100644 index 00000000..5edad18c --- /dev/null +++ b/Server/api/voice_handler.go @@ -0,0 +1,136 @@ +package api + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "fmt" + "log/slog" + "net" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +const voiceCredentialTTL = 24 * time.Hour + +// iceServer describes a single ICE server entry for WebRTC peer connections. +type iceServer struct { + URLs string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` +} + +// voiceCredentialsResponse is the JSON body for GET /api/v1/voice/credentials. +type voiceCredentialsResponse struct { + ICEServers []iceServer `json:"ice_servers"` + ExpiresIn int `json:"expires_in"` +} + +// turnCredentials holds the generated TURN username and HMAC credential. +type turnCredentials struct { + Username string + Credential string +} + +// MountVoiceRoutes registers the voice REST endpoints on r. +func MountVoiceRoutes(r chi.Router, cfg *config.Config, database *db.DB) { + r.Route("/api/v1/voice", func(r chi.Router) { + r.Use(AuthMiddleware(database)) + r.Get("/credentials", handleVoiceCredentials(cfg, database)) + }) +} + +// handleVoiceCredentials returns ICE server credentials for WebRTC. +// Requires a valid session (AuthMiddleware). Generates time-limited TURN +// credentials using HMAC-SHA1 as per the coturn REST API spec. +func handleVoiceCredentials(cfg *config.Config, _ *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(UserKey).(*db.User) + if !ok || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "authentication required", + }) + return + } + + host := serverHost(r) + servers := buildICEServers(user.ID, cfg, host) + + urls := make([]string, 0, len(servers)) + for _, s := range servers { + urls = append(urls, s.URLs) + } + slog.Info("voice credentials issued", + "user_id", user.ID, + "host", host, + "ice_servers", urls, + "external_ip", cfg.Voice.ExternalIP) + + writeJSON(w, http.StatusOK, voiceCredentialsResponse{ + ICEServers: servers, + ExpiresIn: int(voiceCredentialTTL.Seconds()), + }) + } +} + +// buildICEServers constructs the ICE server list for the given user. +// Always includes a public STUN server so clients behind NAT can discover +// their server-reflexive address. Adds the self-hosted STUN and optional +// TURN server if configured. +func buildICEServers(userID int64, cfg *config.Config, host string) []iceServer { + servers := []iceServer{ + // Public STUN — reliable fallback for NAT traversal even if the + // self-hosted STUN port isn't reachable. + {URLs: "stun:stun.l.google.com:19302"}, + {URLs: fmt.Sprintf("stun:%s:%d", host, cfg.Voice.STUNPort)}, + } + + if cfg.Voice.TURNEnabled && cfg.Voice.TURNSecret != "" { + creds := generateTURNCredentials(userID, cfg.Voice.TURNSecret) + servers = append(servers, iceServer{ + URLs: fmt.Sprintf("turn:%s:%d", host, cfg.Voice.TURNPort), + Username: creds.Username, + Credential: creds.Credential, + }) + } + + return servers +} + +// generateTURNCredentials produces time-limited TURN credentials using HMAC-SHA1. +// Username format: ":" +// Credential: base64(HMAC-SHA1(secret, username)) +func generateTURNCredentials(userID int64, secret string) turnCredentials { + expiry := time.Now().Add(voiceCredentialTTL).Unix() + username := fmt.Sprintf("%d:%d", expiry, userID) + + mac := hmac.New(sha1.New, []byte(secret)) + _, _ = mac.Write([]byte(username)) + credential := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + return turnCredentials{ + Username: username, + Credential: credential, + } +} + +// serverHost extracts the host (without port) for ICE server URLs from the +// request, or falls back to "localhost". Uses net.SplitHostPort for correct +// handling of IPv6 addresses with ports (e.g. "[::1]:8443"). +func serverHost(r *http.Request) string { + host := r.Host + if host == "" { + return "localhost" + } + h, _, err := net.SplitHostPort(host) + if err != nil { + // No port present — return as-is. + return host + } + return h +} diff --git a/Server/api/voice_handler_test.go b/Server/api/voice_handler_test.go new file mode 100644 index 00000000..57ae2765 --- /dev/null +++ b/Server/api/voice_handler_test.go @@ -0,0 +1,344 @@ +package api_test + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// newVoiceAPITestDB opens an in-memory DB for voice API tests. +func newVoiceAPITestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: apiTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// buildVoiceRouter returns a chi router with voice routes mounted. +func buildVoiceRouter(database *db.DB, cfg *config.Config) http.Handler { + r := chi.NewRouter() + api.MountVoiceRoutes(r, cfg, database) + return r +} + +// seedAPIUser creates a user+session and returns a valid bearer token. +func seedVoiceAPIUser(t *testing.T, database *db.DB, username string) string { + t.Helper() + _, err := database.CreateUser(username, "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + token := "test-token-" + username + hash := auth.HashToken(token) + future := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02 15:04:05") + _, err = database.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, + user.ID, hash, "test", "127.0.0.1", future, + ) + if err != nil { + t.Fatalf("insert session: %v", err) + } + return token +} + +// voiceGetWithToken performs a GET with Authorization: Bearer header. +func voiceGetWithToken(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// defaultVoiceCfg returns a Config with a known TURN secret for testing. +func defaultVoiceCfg() *config.Config { + return &config.Config{ + Server: config.ServerConfig{Name: "Test"}, + Voice: config.VoiceConfig{ + TURNSecret: "test-secret-key-12345", + STUNPort: 3478, + TURNPort: 3478, + TURNEnabled: true, + }, + } +} + +// ─── GET /api/v1/voice/credentials ─────────────────────────────────────────── + +func TestVoiceCredentials_Authenticated_Returns200(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "alice") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestVoiceCredentials_Unauthenticated_Returns401(t *testing.T) { + database := newVoiceAPITestDB(t) + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + req := httptest.NewRequest(http.MethodGet, "/api/v1/voice/credentials", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +func TestVoiceCredentials_ResponseContainsIceServers(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "bob") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + iceServers, ok := resp["ice_servers"] + if !ok { + t.Fatal("response missing ice_servers field") + } + servers, ok := iceServers.([]any) + if !ok || len(servers) == 0 { + t.Error("ice_servers is empty or wrong type") + } +} + +func TestVoiceCredentials_ContainsSTUNEntry(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "carol") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + + servers := resp["ice_servers"].([]any) + foundSTUN := false + for _, s := range servers { + entry := s.(map[string]any) + if urls, ok := entry["urls"].(string); ok { + if len(urls) > 5 && urls[:5] == "stun:" { + foundSTUN = true + break + } + } + } + if !foundSTUN { + t.Error("ice_servers does not contain a STUN entry") + } +} + +func TestVoiceCredentials_ContainsTURNEntry(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "dave") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + + servers := resp["ice_servers"].([]any) + foundTURN := false + for _, s := range servers { + entry := s.(map[string]any) + if urls, ok := entry["urls"].(string); ok { + if len(urls) > 5 && urls[:5] == "turn:" { + foundTURN = true + // TURN entries must have username and credential. + if _, hasUser := entry["username"]; !hasUser { + t.Error("TURN entry missing username") + } + if _, hasCred := entry["credential"]; !hasCred { + t.Error("TURN entry missing credential") + } + break + } + } + } + if !foundTURN { + t.Error("ice_servers does not contain a TURN entry") + } +} + +func TestVoiceCredentials_TURNCredentialIsValidHMAC(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "eve") + secret := "test-secret-key-12345" + cfg := &config.Config{ + Voice: config.VoiceConfig{ + TURNSecret: secret, + STUNPort: 3478, + TURNPort: 3478, + TURNEnabled: true, + }, + } + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + + servers := resp["ice_servers"].([]any) + for _, s := range servers { + entry := s.(map[string]any) + urls, _ := entry["urls"].(string) + if len(urls) < 5 || urls[:5] != "turn:" { + continue + } + username, _ := entry["username"].(string) + credential, _ := entry["credential"].(string) + + if username == "" || credential == "" { + t.Fatal("TURN entry has empty username or credential") + } + + // Verify HMAC-SHA1: credential should be base64(HMAC-SHA1(secret, username)). + mac := hmac.New(sha1.New, []byte(secret)) + mac.Write([]byte(username)) + expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + if credential != expected { + t.Errorf("TURN credential HMAC mismatch\n got: %s\n want: %s", credential, expected) + } + return + } + t.Error("no TURN entry found to validate HMAC") +} + +func TestVoiceCredentials_UsernameContainsTimestampAndUserID(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "frank") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + + servers := resp["ice_servers"].([]any) + for _, s := range servers { + entry := s.(map[string]any) + urls, _ := entry["urls"].(string) + if len(urls) < 5 || urls[:5] != "turn:" { + continue + } + username, _ := entry["username"].(string) + + // Username format: ":". + var ts, uid int64 + if _, err := fmt.Sscanf(username, "%d:%d", &ts, &uid); err != nil { + t.Errorf("TURN username %q is not in format :: %v", username, err) + } + if ts <= time.Now().Unix() { + t.Errorf("TURN username timestamp %d is in the past, want future", ts) + } + if uid <= 0 { + t.Errorf("TURN username userID %d must be positive", uid) + } + return + } + t.Error("no TURN entry found to validate username format") +} + +func TestVoiceCredentials_ResponseContainsExpiresIn(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "grace") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + + expiresIn, ok := resp["expires_in"] + if !ok { + t.Fatal("response missing expires_in field") + } + // expires_in should be 86400 (24 hours in seconds). + val, ok := expiresIn.(float64) + if !ok || val != 86400 { + t.Errorf("expires_in = %v, want 86400", expiresIn) + } +} + +func TestVoiceCredentials_TURNDisabled_NoTURNEntry(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "henry") + cfg := &config.Config{ + Voice: config.VoiceConfig{ + TURNSecret: "secret", + STUNPort: 3478, + TURNPort: 3478, + TURNEnabled: false, // TURN disabled + }, + } + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + + servers := resp["ice_servers"].([]any) + for _, s := range servers { + entry := s.(map[string]any) + if urls, _ := entry["urls"].(string); len(urls) >= 5 && urls[:5] == "turn:" { + t.Error("TURN entry present when TURNEnabled=false") + } + } +} diff --git a/Server/auth/auth.go b/Server/auth/auth.go new file mode 100644 index 00000000..0c6b9713 --- /dev/null +++ b/Server/auth/auth.go @@ -0,0 +1,2 @@ +// Package auth provides authentication helpers for the OwnCord server. +package auth diff --git a/Server/auth/helpers.go b/Server/auth/helpers.go new file mode 100644 index 00000000..2ef5bc04 --- /dev/null +++ b/Server/auth/helpers.go @@ -0,0 +1,67 @@ +package auth + +import ( + "net/http" + "strings" + "time" + + "github.com/owncord/server/db" +) + +// ExtractBearerToken parses the "Authorization: Bearer " header from r +// and returns the token and true. Returns "", false if the header is absent, +// uses a scheme other than "bearer" (case-insensitive), or has an empty token. +func ExtractBearerToken(r *http.Request) (string, bool) { + header := r.Header.Get("Authorization") + if header == "" { + return "", false + } + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" { + return "", false + } + return parts[1], true +} + +// IsEffectivelyBanned reports whether u is currently banned, accounting for +// temporary ban expiry. A user is effectively banned when: +// - u.Banned is true, AND +// - u.BanExpires is nil (permanent ban), OR the expiry is in the future. +// +// If u is nil the function returns false without panicking. +// If BanExpires holds an unparseable string the ban is treated as active +// (fail-safe: keep user blocked rather than silently unblocking them). +func IsEffectivelyBanned(u *db.User) bool { + if u == nil || !u.Banned { + return false + } + // Permanent ban — no expiry set. + if u.BanExpires == nil { + return true + } + // Temporary ban — parse the expiry and compare to now. + for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} { + t, err := time.Parse(layout, *u.BanExpires) + if err == nil { + // Ban is still active if expiry is in the future. + return time.Now().UTC().Before(t.UTC()) + } + } + // Unparseable expiry — fail-safe: treat as still banned. + return true +} + +// IsSessionExpired reports whether the expiresAt timestamp string represents a +// time in the past. It accepts both the SQLite space-separated format +// ("2006-01-02 15:04:05") and the ISO-8601 UTC format ("2006-01-02T15:04:05Z"). +// Any string that cannot be parsed is treated as expired for safety. +func IsSessionExpired(expiresAt string) bool { + for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} { + t, err := time.Parse(layout, expiresAt) + if err == nil { + return time.Now().UTC().After(t.UTC()) + } + } + // Unparseable expiry — treat as expired for safety. + return true +} diff --git a/Server/auth/helpers_test.go b/Server/auth/helpers_test.go new file mode 100644 index 00000000..fbe6b9f8 --- /dev/null +++ b/Server/auth/helpers_test.go @@ -0,0 +1,311 @@ +package auth_test + +import ( + "net/http" + "testing" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// ─── ExtractBearerToken ─────────────────────────────────────────────────────── + +func TestExtractBearerToken_ValidHeader(t *testing.T) { + r, _ := http.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer mytoken123") + + token, ok := auth.ExtractBearerToken(r) + + if !ok { + t.Fatal("ExtractBearerToken() ok = false, want true") + } + if token != "mytoken123" { + t.Errorf("ExtractBearerToken() token = %q, want %q", token, "mytoken123") + } +} + +func TestExtractBearerToken_MissingHeader(t *testing.T) { + r, _ := http.NewRequest(http.MethodGet, "/", nil) + + token, ok := auth.ExtractBearerToken(r) + + if ok { + t.Error("ExtractBearerToken() ok = true with no Authorization header, want false") + } + if token != "" { + t.Errorf("ExtractBearerToken() token = %q, want empty string", token) + } +} + +func TestExtractBearerToken_EmptyHeaderValue(t *testing.T) { + r, _ := http.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "") + + _, ok := auth.ExtractBearerToken(r) + + if ok { + t.Error("ExtractBearerToken() ok = true for empty header value, want false") + } +} + +func TestExtractBearerToken_WrongScheme(t *testing.T) { + r, _ := http.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + + _, ok := auth.ExtractBearerToken(r) + + if ok { + t.Error("ExtractBearerToken() ok = true for Basic scheme, want false") + } +} + +func TestExtractBearerToken_BearerCaseInsensitive(t *testing.T) { + cases := []string{ + "BEARER mytoken", + "bearer mytoken", + "Bearer mytoken", + "bEaReR mytoken", + } + for _, authHeader := range cases { + r, _ := http.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", authHeader) + + token, ok := auth.ExtractBearerToken(r) + + if !ok { + t.Errorf("ExtractBearerToken() ok = false for header %q, want true", authHeader) + } + if token != "mytoken" { + t.Errorf("ExtractBearerToken() token = %q for header %q, want %q", token, authHeader, "mytoken") + } + } +} + +func TestExtractBearerToken_BearerWithNoToken(t *testing.T) { + r, _ := http.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer ") + + _, ok := auth.ExtractBearerToken(r) + + if ok { + t.Error("ExtractBearerToken() ok = true for 'Bearer ' with empty token, want false") + } +} + +func TestExtractBearerToken_OnlySchemeNoSpace(t *testing.T) { + r, _ := http.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer") + + _, ok := auth.ExtractBearerToken(r) + + if ok { + t.Error("ExtractBearerToken() ok = true for 'Bearer' with no space or token, want false") + } +} + +func TestExtractBearerToken_TokenPreservesValue(t *testing.T) { + // Tokens can contain mixed-case, digits, hyphens, underscores, dots. + rawToken := "aB3-xY9_zZ0.qQ7" + r, _ := http.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer "+rawToken) + + token, ok := auth.ExtractBearerToken(r) + + if !ok { + t.Fatal("ExtractBearerToken() ok = false, want true") + } + if token != rawToken { + t.Errorf("ExtractBearerToken() token = %q, want %q", token, rawToken) + } +} + +func TestExtractBearerToken_MultipleSpaces(t *testing.T) { + // SplitN with n=2 means "Bearer tok" splits into ["Bearer", " tok"]. + // The second part " tok" is non-empty, so the function must return " tok", true. + r, _ := http.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer mytoken") + + token, ok := auth.ExtractBearerToken(r) + + // The contract: returns whatever follows the single separating space. + // " mytoken" is non-empty, so ok should be true. + if !ok { + t.Fatal("ExtractBearerToken() ok = false for double-space header, want true") + } + if token != " mytoken" { + t.Errorf("ExtractBearerToken() token = %q, want %q", token, " mytoken") + } +} + +// ─── IsSessionExpired ───────────────────────────────────────────────────────── + +func TestIsSessionExpired_FutureTimeNotExpired(t *testing.T) { + future := time.Now().UTC().Add(time.Hour) + expiresAt := future.Format("2006-01-02 15:04:05") + + if auth.IsSessionExpired(expiresAt) { + t.Errorf("IsSessionExpired(%q) = true for future time, want false", expiresAt) + } +} + +func TestIsSessionExpired_PastTimeExpired(t *testing.T) { + past := time.Now().UTC().Add(-time.Hour) + expiresAt := past.Format("2006-01-02 15:04:05") + + if !auth.IsSessionExpired(expiresAt) { + t.Errorf("IsSessionExpired(%q) = false for past time, want true", expiresAt) + } +} + +func TestIsSessionExpired_FutureTimeSQLiteFormat(t *testing.T) { + future := time.Now().UTC().Add(24 * time.Hour) + expiresAt := future.Format("2006-01-02 15:04:05") + + if auth.IsSessionExpired(expiresAt) { + t.Errorf("IsSessionExpired(%q) = true for future SQLite-format time, want false", expiresAt) + } +} + +func TestIsSessionExpired_PastTimeSQLiteFormat(t *testing.T) { + past := time.Now().UTC().Add(-24 * time.Hour) + expiresAt := past.Format("2006-01-02 15:04:05") + + if !auth.IsSessionExpired(expiresAt) { + t.Errorf("IsSessionExpired(%q) = false for past SQLite-format time, want true", expiresAt) + } +} + +func TestIsSessionExpired_FutureTimeISO8601Format(t *testing.T) { + future := time.Now().UTC().Add(time.Hour) + expiresAt := future.Format("2006-01-02T15:04:05Z") + + if auth.IsSessionExpired(expiresAt) { + t.Errorf("IsSessionExpired(%q) = true for future ISO-8601 time, want false", expiresAt) + } +} + +func TestIsSessionExpired_PastTimeISO8601Format(t *testing.T) { + past := time.Now().UTC().Add(-time.Hour) + expiresAt := past.Format("2006-01-02T15:04:05Z") + + if !auth.IsSessionExpired(expiresAt) { + t.Errorf("IsSessionExpired(%q) = false for past ISO-8601 time, want true", expiresAt) + } +} + +func TestIsSessionExpired_EmptyString(t *testing.T) { + // Unparseable — must treat as expired for safety. + if !auth.IsSessionExpired("") { + t.Error("IsSessionExpired(\"\") = false for empty string, want true (fail-safe)") + } +} + +func TestIsSessionExpired_InvalidFormat(t *testing.T) { + cases := []string{ + "not-a-date", + "2025/03/15 12:00:00", + "15-03-2025", + "2025-13-45T99:99:99Z", // out-of-range values + } + for _, s := range cases { + if !auth.IsSessionExpired(s) { + t.Errorf("IsSessionExpired(%q) = false for invalid format, want true (fail-safe)", s) + } + } +} + +func TestIsSessionExpired_ExactlyNow(t *testing.T) { + // A timestamp one second in the past must always be expired. + justPast := time.Now().UTC().Add(-time.Second) + expiresAt := justPast.Format("2006-01-02 15:04:05") + + if !auth.IsSessionExpired(expiresAt) { + t.Errorf("IsSessionExpired(%q) = false for just-past time, want true", expiresAt) + } +} + +// ─── IsEffectivelyBanned ────────────────────────────────────────────────────── + +// ptr is a helper to get a pointer to a string literal. +func ptr(s string) *string { return &s } + +func TestIsEffectivelyBanned_NotBanned(t *testing.T) { + u := &db.User{Banned: false} + if auth.IsEffectivelyBanned(u) { + t.Error("IsEffectivelyBanned(Banned=false) = true, want false") + } +} + +func TestIsEffectivelyBanned_BannedNilExpiry(t *testing.T) { + // Banned with no expiry — permanently banned. + u := &db.User{Banned: true, BanExpires: nil} + if !auth.IsEffectivelyBanned(u) { + t.Error("IsEffectivelyBanned(Banned=true, BanExpires=nil) = false, want true") + } +} + +func TestIsEffectivelyBanned_BannedFutureExpiry(t *testing.T) { + // Banned with an expiry in the future — still banned. + future := time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05") + u := &db.User{Banned: true, BanExpires: ptr(future)} + if !auth.IsEffectivelyBanned(u) { + t.Error("IsEffectivelyBanned(Banned=true, future expiry) = false, want true") + } +} + +func TestIsEffectivelyBanned_BannedPastExpiry(t *testing.T) { + // Banned but the ban expired in the past — should be treated as NOT banned. + past := time.Now().UTC().Add(-time.Hour).Format("2006-01-02 15:04:05") + u := &db.User{Banned: true, BanExpires: ptr(past)} + if auth.IsEffectivelyBanned(u) { + t.Error("IsEffectivelyBanned(Banned=true, past expiry) = true, want false") + } +} + +func TestIsEffectivelyBanned_BannedExpiredISO8601(t *testing.T) { + // ISO-8601 format for BanExpires past — should be treated as NOT banned. + past := time.Now().UTC().Add(-time.Minute).Format("2006-01-02T15:04:05Z") + u := &db.User{Banned: true, BanExpires: ptr(past)} + if auth.IsEffectivelyBanned(u) { + t.Error("IsEffectivelyBanned(Banned=true, ISO-8601 past expiry) = true, want false") + } +} + +func TestIsEffectivelyBanned_BannedFutureISO8601(t *testing.T) { + // ISO-8601 format for BanExpires in future — still banned. + future := time.Now().UTC().Add(time.Hour).Format("2006-01-02T15:04:05Z") + u := &db.User{Banned: true, BanExpires: ptr(future)} + if !auth.IsEffectivelyBanned(u) { + t.Error("IsEffectivelyBanned(Banned=true, ISO-8601 future expiry) = false, want true") + } +} + +func TestIsEffectivelyBanned_BannedUnparsableExpiry(t *testing.T) { + // Unparseable expiry string — fail-safe: treat as still banned. + u := &db.User{Banned: true, BanExpires: ptr("not-a-date")} + if !auth.IsEffectivelyBanned(u) { + t.Error("IsEffectivelyBanned(Banned=true, unparseable expiry) = false, want true (fail-safe)") + } +} + +func TestIsEffectivelyBanned_NotBannedIgnoresExpiry(t *testing.T) { + // Banned=false even with a future expiry field — should be false. + future := time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05") + u := &db.User{Banned: false, BanExpires: ptr(future)} + if auth.IsEffectivelyBanned(u) { + t.Error("IsEffectivelyBanned(Banned=false, future expiry) = true, want false") + } +} + +func TestIsEffectivelyBanned_NilUser(t *testing.T) { + // A nil user pointer must not panic and must return false. + defer func() { + if r := recover(); r != nil { + t.Errorf("IsEffectivelyBanned(nil) panicked: %v", r) + } + }() + if auth.IsEffectivelyBanned(nil) { + t.Error("IsEffectivelyBanned(nil) = true, want false") + } +} diff --git a/Server/auth/password.go b/Server/auth/password.go new file mode 100644 index 00000000..f1cffb07 --- /dev/null +++ b/Server/auth/password.go @@ -0,0 +1,50 @@ +package auth + +import ( + "errors" + + "golang.org/x/crypto/bcrypt" +) + +const ( + bcryptCost = 12 + minPassLen = 8 + maxPassLen = 72 // bcrypt silently truncates beyond 72 bytes +) + +// ErrPasswordTooShort is returned when the password is below the minimum length. +var ErrPasswordTooShort = errors.New("password must be at least 8 characters") + +// ErrPasswordTooLong is returned when the password exceeds bcrypt's 72-byte limit. +var ErrPasswordTooLong = errors.New("password must not exceed 72 characters") + +// HashPassword returns a bcrypt hash of password using cost 12. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// CheckPassword reports whether password matches hash. Returns false on any +// error, including an empty or malformed hash. +func CheckPassword(hash, password string) bool { + if hash == "" { + return false + } + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// ValidatePasswordStrength returns an error if password fails strength +// requirements: minimum 8 characters, maximum 72 characters. +func ValidatePasswordStrength(password string) error { + if len(password) < minPassLen { + return ErrPasswordTooShort + } + if len(password) > maxPassLen { + return ErrPasswordTooLong + } + return nil +} diff --git a/Server/auth/password_test.go b/Server/auth/password_test.go new file mode 100644 index 00000000..a8581062 --- /dev/null +++ b/Server/auth/password_test.go @@ -0,0 +1,106 @@ +package auth_test + +import ( + "strings" + "testing" + + "github.com/owncord/server/auth" +) + +func TestHashPassword_DiffersFromPlaintext(t *testing.T) { + hash, err := auth.HashPassword("mypassword") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if hash == "mypassword" { + t.Error("HashPassword() hash equals plaintext") + } +} + +func TestHashPassword_BcryptPrefix(t *testing.T) { + hash, err := auth.HashPassword("mypassword") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if !strings.HasPrefix(hash, "$2") { + t.Errorf("HashPassword() = %q, want bcrypt prefix $2*", hash) + } +} + +func TestCheckPassword_CorrectPassword(t *testing.T) { + hash, err := auth.HashPassword("correctpassword") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if !auth.CheckPassword(hash, "correctpassword") { + t.Error("CheckPassword() returned false for correct password") + } +} + +func TestCheckPassword_WrongPassword(t *testing.T) { + hash, err := auth.HashPassword("correctpassword") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if auth.CheckPassword(hash, "wrongpassword") { + t.Error("CheckPassword() returned true for wrong password") + } +} + +func TestCheckPassword_EmptyPassword(t *testing.T) { + hash, err := auth.HashPassword("somepassword") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if auth.CheckPassword(hash, "") { + t.Error("CheckPassword() returned true for empty password") + } +} + +func TestCheckPassword_EmptyHash(t *testing.T) { + if auth.CheckPassword("", "somepassword") { + t.Error("CheckPassword() returned true with empty hash") + } +} + +func TestValidatePasswordStrength_Valid(t *testing.T) { + cases := []string{ + "12345678", // exactly 8 chars + "abcdefghij", // 10 chars + strings.Repeat("a", 72), // exactly 72 chars (bcrypt max) + } + for _, pw := range cases { + if err := auth.ValidatePasswordStrength(pw); err != nil { + t.Errorf("ValidatePasswordStrength(%q) error = %v, want nil", pw, err) + } + } +} + +func TestValidatePasswordStrength_TooShort(t *testing.T) { + cases := []string{ + "", // empty + "1234567", // 7 chars + "abc", // 3 chars + } + for _, pw := range cases { + if err := auth.ValidatePasswordStrength(pw); err == nil { + t.Errorf("ValidatePasswordStrength(%q) error = nil, want error", pw) + } + } +} + +func TestValidatePasswordStrength_TooLong(t *testing.T) { + pw := strings.Repeat("a", 73) // 73 chars — over bcrypt 72 byte limit + if err := auth.ValidatePasswordStrength(pw); err == nil { + t.Errorf("ValidatePasswordStrength(%q) error = nil, want error for >72 chars", pw) + } +} + +func TestHashPassword_TwoCallsDifferentHashes(t *testing.T) { + // bcrypt includes a random salt + h1, _ := auth.HashPassword("password") + h2, _ := auth.HashPassword("password") + if h1 == h2 { + t.Error("HashPassword() produced identical hashes for the same password (salt missing?)") + } +} diff --git a/Server/auth/ratelimit.go b/Server/auth/ratelimit.go new file mode 100644 index 00000000..7dbf48cd --- /dev/null +++ b/Server/auth/ratelimit.go @@ -0,0 +1,170 @@ +package auth + +import ( + "sync" + "time" +) + +// entry records individual request timestamps for sliding-window limiting. +type entry struct { + timestamps []time.Time +} + +// lockoutEntry records when a lockout expires. +type lockoutEntry struct { + expiresAt time.Time +} + +// RateLimiter is an in-memory, thread-safe sliding-window rate limiter with +// optional IP lockout support. +type RateLimiter struct { + mu sync.Mutex + windows map[string]*entry + lockouts map[string]*lockoutEntry +} + +// NewRateLimiter returns an initialised RateLimiter. +func NewRateLimiter() *RateLimiter { + return &RateLimiter{ + windows: make(map[string]*entry), + lockouts: make(map[string]*lockoutEntry), + } +} + +// Allow reports whether a request from key is permitted given the limit and +// window. It records the current request timestamp regardless of the outcome. +// Returns false when key is locked out or has exceeded limit within window. +func (r *RateLimiter) Allow(key string, limit int, window time.Duration) bool { + r.mu.Lock() + defer r.mu.Unlock() + + // Lockout takes priority. + if lo, ok := r.lockouts[key]; ok { + if time.Now().Before(lo.expiresAt) { + return false + } + delete(r.lockouts, key) + } + + now := time.Now() + cutoff := now.Add(-window) + + e, ok := r.windows[key] + if !ok { + e = &entry{} + r.windows[key] = e + } + + // Prune timestamps outside the current window. + valid := e.timestamps[:0] + for _, ts := range e.timestamps { + if ts.After(cutoff) { + valid = append(valid, ts) + } + } + e.timestamps = valid + + if len(e.timestamps) >= limit { + return false + } + + e.timestamps = append(e.timestamps, now) + return true +} + +// Lockout prevents any requests from key for duration regardless of the +// sliding-window counter. +func (r *RateLimiter) Lockout(key string, duration time.Duration) { + r.mu.Lock() + defer r.mu.Unlock() + r.lockouts[key] = &lockoutEntry{expiresAt: time.Now().Add(duration)} +} + +// IsLockedOut reports whether key is currently under a lockout. +func (r *RateLimiter) IsLockedOut(key string) bool { + r.mu.Lock() + defer r.mu.Unlock() + lo, ok := r.lockouts[key] + if !ok { + return false + } + if time.Now().Before(lo.expiresAt) { + return true + } + delete(r.lockouts, key) + return false +} + +// Reset clears all rate-limit state (timestamps and lockout) for key. +func (r *RateLimiter) Reset(key string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.windows, key) + delete(r.lockouts, key) +} + +// Cleanup evicts stale map entries to prevent unbounded memory growth. +// +// A windows entry is removed when every recorded timestamp is older than +// maxWindow — meaning the entry could not affect any future Allow call that +// uses a window equal to or shorter than maxWindow. +// +// A lockouts entry is removed when its expiry has passed. +// +// Pass defaultCleanupMaxWindow (15 minutes) for normal server operation, or +// a shorter duration in tests. +func (r *RateLimiter) Cleanup(maxWindow time.Duration) { + r.mu.Lock() + defer r.mu.Unlock() + + cutoff := time.Now().Add(-maxWindow) + + for key, e := range r.windows { + allStale := true + for _, ts := range e.timestamps { + if ts.After(cutoff) { + allStale = false + break + } + } + if allStale { + delete(r.windows, key) + } + } + + now := time.Now() + for key, lo := range r.lockouts { + if now.After(lo.expiresAt) { + delete(r.lockouts, key) + } + } +} + +// StartCleanup runs Cleanup on a ticker with the given interval until the +// stop channel is closed. It is intended to be called in a goroutine: +// +// stop := make(chan struct{}) +// go rl.StartCleanup(5*time.Minute, 15*time.Minute, stop) +// +// Closing stop causes the goroutine to exit promptly. +func (r *RateLimiter) StartCleanup(interval, maxWindow time.Duration, stop <-chan struct{}) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + r.Cleanup(maxWindow) + case <-stop: + return + } + } +} + +// Len returns the number of entries currently stored in the windows and +// lockouts maps. It is primarily useful for testing and monitoring. +func (r *RateLimiter) Len() (windows, lockouts int) { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.windows), len(r.lockouts) +} diff --git a/Server/auth/ratelimit_cleanup_test.go b/Server/auth/ratelimit_cleanup_test.go new file mode 100644 index 00000000..c7af31c6 --- /dev/null +++ b/Server/auth/ratelimit_cleanup_test.go @@ -0,0 +1,212 @@ +package auth_test + +import ( + "testing" + "time" + + "github.com/owncord/server/auth" +) + +// ─── Cleanup ────────────────────────────────────────────────────────────────── + +// TestCleanup_RemovesExpiredWindows verifies that window entries whose +// timestamps are all older than the max window are deleted by Cleanup. +func TestCleanup_RemovesExpiredWindows(t *testing.T) { + rl := auth.NewRateLimiter() + + // Populate a window entry that will have expired timestamps. + shortWindow := 30 * time.Millisecond + rl.Allow("stale-ip", 10, shortWindow) + + // Wait long enough that all timestamps fall outside the 15-minute + // cleanup horizon — we override by using a very short max-window for test. + time.Sleep(shortWindow + 10*time.Millisecond) + + // Use a maxWindow shorter than 15 minutes so the test runs fast. + rl.Cleanup(shortWindow) + + wins, _ := rl.Len() + if wins != 0 { + t.Errorf("Len().windows = %d after Cleanup, want 0 (stale entry should be evicted)", wins) + } +} + +// TestCleanup_RemovesExpiredLockouts verifies that expired lockout entries +// are deleted by Cleanup. +func TestCleanup_RemovesExpiredLockouts(t *testing.T) { + rl := auth.NewRateLimiter() + + rl.Lockout("stale-lockout", 20*time.Millisecond) + time.Sleep(40 * time.Millisecond) + + rl.Cleanup(15 * time.Minute) + + _, locks := rl.Len() + if locks != 0 { + t.Errorf("Len().lockouts = %d after Cleanup, want 0 (expired lockout should be evicted)", locks) + } +} + +// TestCleanup_PreservesActiveWindows verifies that a window with recent +// timestamps is NOT evicted during Cleanup. +func TestCleanup_PreservesActiveWindows(t *testing.T) { + rl := auth.NewRateLimiter() + + // Issue a request; the timestamp is recent. + rl.Allow("active-ip", 100, time.Hour) + + // Cleanup with a 15-minute max window should keep the fresh entry. + rl.Cleanup(15 * time.Minute) + + wins, _ := rl.Len() + if wins != 1 { + t.Errorf("Len().windows = %d after Cleanup, want 1 (active entry should be preserved)", wins) + } +} + +// TestCleanup_PreservesActiveLockouts verifies that a non-expired lockout +// is NOT deleted by Cleanup. +func TestCleanup_PreservesActiveLockouts(t *testing.T) { + rl := auth.NewRateLimiter() + + rl.Lockout("live-lockout", time.Hour) + + rl.Cleanup(15 * time.Minute) + + _, locks := rl.Len() + if locks != 1 { + t.Errorf("Len().lockouts = %d after Cleanup, want 1 (active lockout should be preserved)", locks) + } +} + +// TestCleanup_MixedEntries verifies that Cleanup correctly partitions stale +// from active entries when both are present. +// +// Strategy: the "stale" window key gets a single request right now, then we +// sleep until that timestamp is outside the cleanup maxWindow. The "active" +// key gets a new request AFTER the sleep so its timestamp is always fresh. +func TestCleanup_MixedEntries(t *testing.T) { + rl := auth.NewRateLimiter() + shortWindow := 30 * time.Millisecond + + // Stale window entry — its timestamp will be older than shortWindow. + rl.Allow("stale", 10, shortWindow) + // Stale lockout — expires in shortWindow. + rl.Lockout("stale-lock", shortWindow) + + // Wait until the stale timestamps fall outside shortWindow. + time.Sleep(shortWindow + 10*time.Millisecond) + + // Active entries added AFTER the sleep — their timestamps are fresh. + rl.Allow("active", 10, time.Hour) + rl.Lockout("live-lock", time.Hour) + + // Cleanup with shortWindow: "stale" was recorded before the cutoff, so it + // is evicted. "active" was just recorded, so it is kept. + rl.Cleanup(shortWindow) + + wins, locks := rl.Len() + if wins != 1 { + t.Errorf("windows = %d, want 1 (only active should remain)", wins) + } + if locks != 1 { + t.Errorf("lockouts = %d, want 1 (only live lockout should remain)", locks) + } +} + +// ─── Len ───────────────────────────────────────────────────────────────────── + +// TestLen_Empty verifies Len returns (0, 0) on a fresh RateLimiter. +func TestLen_Empty(t *testing.T) { + rl := auth.NewRateLimiter() + wins, locks := rl.Len() + if wins != 0 || locks != 0 { + t.Errorf("Len() = (%d, %d), want (0, 0) on empty RateLimiter", wins, locks) + } +} + +// TestLen_AfterAllows verifies Len accurately reflects the number of +// distinct keys that have issued at least one request. +func TestLen_AfterAllows(t *testing.T) { + rl := auth.NewRateLimiter() + rl.Allow("a", 10, time.Hour) + rl.Allow("b", 10, time.Hour) + rl.Allow("a", 10, time.Hour) // same key again — should not increment + + wins, _ := rl.Len() + if wins != 2 { + t.Errorf("Len().windows = %d, want 2", wins) + } +} + +// TestLen_AfterLockouts verifies Len accurately reflects the number of +// active lockout entries. +func TestLen_AfterLockouts(t *testing.T) { + rl := auth.NewRateLimiter() + rl.Lockout("x", time.Hour) + rl.Lockout("y", time.Hour) + + _, locks := rl.Len() + if locks != 2 { + t.Errorf("Len().lockouts = %d, want 2", locks) + } +} + +// ─── StartCleanup ───────────────────────────────────────────────────────────── + +// TestStartCleanup_RunsPeriodically verifies that StartCleanup evicts stale +// entries automatically without a manual Cleanup call. +func TestStartCleanup_RunsPeriodically(t *testing.T) { + rl := auth.NewRateLimiter() + shortWindow := 20 * time.Millisecond + + rl.Allow("stale", 10, shortWindow) + + wins, _ := rl.Len() + if wins != 1 { + t.Fatalf("expected 1 window entry before cleanup, got %d", wins) + } + + stop := make(chan struct{}) + // Run cleanup every 10 ms with a 20 ms max window so the stale entry is + // evicted after the first tick. + go rl.StartCleanup(10*time.Millisecond, shortWindow, stop) + defer close(stop) + + // Give the ticker at least two cycles to fire. + deadline := time.Now().Add(200 * time.Millisecond) + for time.Now().Before(deadline) { + time.Sleep(15 * time.Millisecond) + if w, _ := rl.Len(); w == 0 { + return // evicted as expected + } + } + + wins, _ = rl.Len() + if wins != 0 { + t.Errorf("StartCleanup did not evict stale entry within 200 ms; Len().windows = %d", wins) + } +} + +// TestStartCleanup_StopsOnSignal verifies that closing the stop channel +// terminates the background goroutine (no leak). We cannot observe the +// goroutine directly, but we verify no panic/deadlock occurs after stop. +func TestStartCleanup_StopsOnSignal(t *testing.T) { + rl := auth.NewRateLimiter() + stop := make(chan struct{}) + + done := make(chan struct{}) + go func() { + rl.StartCleanup(10*time.Millisecond, 15*time.Minute, stop) + close(done) + }() + + close(stop) + + select { + case <-done: + // goroutine exited cleanly + case <-time.After(500 * time.Millisecond): + t.Error("StartCleanup goroutine did not exit after stop channel was closed") + } +} diff --git a/Server/auth/ratelimit_test.go b/Server/auth/ratelimit_test.go new file mode 100644 index 00000000..15770a43 --- /dev/null +++ b/Server/auth/ratelimit_test.go @@ -0,0 +1,126 @@ +package auth_test + +import ( + "testing" + "time" + + "github.com/owncord/server/auth" +) + +func TestRateLimiter_UnderLimitAllowed(t *testing.T) { + rl := auth.NewRateLimiter() + for i := range 5 { + if !rl.Allow("key1", 5, time.Second) { + t.Errorf("Allow() = false at iteration %d, want true", i) + } + } +} + +func TestRateLimiter_AtLimitAllowed(t *testing.T) { + rl := auth.NewRateLimiter() + // Allow up to exactly the limit + for range 3 { + rl.Allow("keyA", 3, time.Second) + } + // The 4th call should be blocked + if rl.Allow("keyA", 3, time.Second) { + t.Error("Allow() = true after limit exceeded, want false") + } +} + +func TestRateLimiter_OverLimitBlocked(t *testing.T) { + rl := auth.NewRateLimiter() + limit := 3 + for range limit { + rl.Allow("key2", limit, time.Second) + } + if rl.Allow("key2", limit, time.Second) { + t.Error("Allow() = true when over limit, want false") + } +} + +func TestRateLimiter_WindowExpiryResets(t *testing.T) { + rl := auth.NewRateLimiter() + window := 50 * time.Millisecond + limit := 2 + // Exhaust limit + rl.Allow("key3", limit, window) + rl.Allow("key3", limit, window) + if rl.Allow("key3", limit, window) { + t.Error("Allow() should be blocked after exhausting limit") + } + // Wait for window to expire + time.Sleep(window + 10*time.Millisecond) + if !rl.Allow("key3", limit, window) { + t.Error("Allow() should be permitted after window expires") + } +} + +func TestRateLimiter_DifferentKeysIndependent(t *testing.T) { + rl := auth.NewRateLimiter() + for range 5 { + rl.Allow("keyX", 3, time.Second) + } + // keyY should still be allowed + if !rl.Allow("keyY", 3, time.Second) { + t.Error("Allow() blocked keyY even though only keyX exceeded limit") + } +} + +func TestRateLimiter_LockoutEnforced(t *testing.T) { + rl := auth.NewRateLimiter() + rl.Lockout("keyLock", time.Hour) + if !rl.IsLockedOut("keyLock") { + t.Error("IsLockedOut() = false after Lockout(), want true") + } +} + +func TestRateLimiter_LockoutExpires(t *testing.T) { + rl := auth.NewRateLimiter() + rl.Lockout("keyExp", 30*time.Millisecond) + time.Sleep(50 * time.Millisecond) + if rl.IsLockedOut("keyExp") { + t.Error("IsLockedOut() = true after lockout expired, want false") + } +} + +func TestRateLimiter_IsLockedOut_UnknownKey(t *testing.T) { + rl := auth.NewRateLimiter() + if rl.IsLockedOut("unknown") { + t.Error("IsLockedOut() = true for unknown key, want false") + } +} + +func TestRateLimiter_Reset(t *testing.T) { + rl := auth.NewRateLimiter() + rl.Allow("keyR", 1, time.Second) + rl.Allow("keyR", 1, time.Second) // now blocked + rl.Reset("keyR") + if !rl.Allow("keyR", 1, time.Second) { + t.Error("Allow() = false after Reset(), want true") + } +} + +func TestRateLimiter_LockoutBlocksAllow(t *testing.T) { + rl := auth.NewRateLimiter() + rl.Lockout("keyLB", time.Hour) + // Even under normal limit, lockout should block + if rl.Allow("keyLB", 100, time.Second) { + t.Error("Allow() = true for locked-out key, want false") + } +} + +func TestRateLimiter_ThreadSafe(t *testing.T) { + rl := auth.NewRateLimiter() + done := make(chan struct{}, 100) + for range 100 { + go func() { + rl.Allow("concurrent", 50, time.Second) + done <- struct{}{} + }() + } + for range 100 { + <-done + } + // If we get here without a race condition data race, we pass +} diff --git a/Server/auth/session.go b/Server/auth/session.go new file mode 100644 index 00000000..19e3cedb --- /dev/null +++ b/Server/auth/session.go @@ -0,0 +1,24 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" +) + +// GenerateToken returns a cryptographically random 256-bit token encoded as a +// 64-character lowercase hex string. +func GenerateToken() (string, error) { + raw := make([]byte, 32) // 256 bits + if _, err := rand.Read(raw); err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} + +// HashToken returns the SHA-256 hex digest of token. Store this hash in the +// database; never store the plaintext token. +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/Server/auth/session_test.go b/Server/auth/session_test.go new file mode 100644 index 00000000..6c2bab94 --- /dev/null +++ b/Server/auth/session_test.go @@ -0,0 +1,77 @@ +package auth_test + +import ( + "testing" + + "github.com/owncord/server/auth" +) + +func TestGenerateToken_Length(t *testing.T) { + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken() error = %v", err) + } + if len(token) != 64 { + t.Errorf("GenerateToken() len = %d, want 64", len(token)) + } +} + +func TestGenerateToken_HexCharacters(t *testing.T) { + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken() error = %v", err) + } + for i, c := range token { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Errorf("GenerateToken() char[%d] = %q, not lowercase hex", i, c) + } + } +} + +func TestGenerateToken_Uniqueness(t *testing.T) { + const n = 1000 + seen := make(map[string]struct{}, n) + for i := range n { + tok, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken() iteration %d error = %v", i, err) + } + if _, dup := seen[tok]; dup { + t.Fatalf("GenerateToken() produced duplicate token at iteration %d", i) + } + seen[tok] = struct{}{} + } +} + +func TestHashToken_Deterministic(t *testing.T) { + token := "abc123" + h1 := auth.HashToken(token) + h2 := auth.HashToken(token) + if h1 != h2 { + t.Errorf("HashToken() not deterministic: %q != %q", h1, h2) + } +} + +func TestHashToken_DiffersFromPlaintext(t *testing.T) { + token := "abc123" + hash := auth.HashToken(token) + if hash == token { + t.Errorf("HashToken() hash equals plaintext token") + } +} + +func TestHashToken_Length(t *testing.T) { + // SHA-256 hex = 64 chars + hash := auth.HashToken("any-token") + if len(hash) != 64 { + t.Errorf("HashToken() len = %d, want 64", len(hash)) + } +} + +func TestHashToken_DifferentInputsDifferentHashes(t *testing.T) { + h1 := auth.HashToken("token-one") + h2 := auth.HashToken("token-two") + if h1 == h2 { + t.Errorf("HashToken() same hash for different inputs") + } +} diff --git a/Server/auth/tls.go b/Server/auth/tls.go new file mode 100644 index 00000000..685ee5a3 --- /dev/null +++ b/Server/auth/tls.go @@ -0,0 +1,210 @@ +// Package auth provides authentication and TLS helpers for the OwnCord server. +package auth + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/crypto/acme/autocert" + + "github.com/owncord/server/config" +) + +// TLSResult holds the output of LoadOrGenerate. +// For most TLS modes only TLSConfig is set. In ACME mode, HTTPHandler is +// also set and must be served on :80 for HTTP-01 challenges and redirect. +type TLSResult struct { + TLSConfig *tls.Config + HTTPHandler http.Handler // non-nil only for ACME mode +} + +// GenerateSelfSigned generates an ECDSA P-256 self-signed TLS certificate +// valid for 10 years and writes the PEM-encoded cert and key to the given +// file paths. +// +// ECDSA P-256 is preferred over RSA 4096 for performance — it provides +// equivalent security at a fraction of the key generation cost, which matters +// for server startup and test speed. +func GenerateSelfSigned(certFile, keyFile string) error { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generating ECDSA key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("generating serial number: %w", err) + } + + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + Organization: []string{"OwnCord Server"}, + CommonName: "OwnCord Self-Signed", + }, + NotBefore: now, + NotAfter: now.Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey) + if err != nil { + return fmt.Errorf("creating certificate: %w", err) + } + + if err := writePEM(certFile, "CERTIFICATE", certDER); err != nil { + return fmt.Errorf("writing cert file: %w", err) + } + + keyDER, err := x509.MarshalECPrivateKey(privKey) + if err != nil { + return fmt.Errorf("marshalling EC private key: %w", err) + } + + if err := writePEM(keyFile, "EC PRIVATE KEY", keyDER); err != nil { + return fmt.Errorf("writing key file: %w", err) + } + + return nil +} + +// LoadOrGenerate returns a *TLSResult based on the TLS configuration mode: +// - "self_signed": loads existing cert/key or generates new ones +// - "manual": loads existing cert/key from CertFile/KeyFile paths +// - "off": returns nil TLSConfig (TLS disabled) +// - "acme": obtains Let's Encrypt certificate via ACME; HTTPHandler must be served on :80 +func LoadOrGenerate(cfg config.TLSConfig) (*TLSResult, error) { + switch cfg.Mode { + case "off": + return &TLSResult{}, nil + + case "self_signed": + tlsCfg, err := loadOrGenerateSelfSigned(cfg) + if err != nil { + return nil, err + } + return &TLSResult{TLSConfig: tlsCfg}, nil + + case "manual": + tlsCfg, err := loadCertPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, err + } + return &TLSResult{TLSConfig: tlsCfg}, nil + + case "acme": + return loadACME(cfg) + + default: + return nil, fmt.Errorf("unknown TLS mode: %q", cfg.Mode) + } +} + +// loadOrGenerateSelfSigned loads the cert/key if both files exist, otherwise +// generates a new self-signed pair. +func loadOrGenerateSelfSigned(cfg config.TLSConfig) (*tls.Config, error) { + certExists := fileExists(cfg.CertFile) + keyExists := fileExists(cfg.KeyFile) + + if !certExists || !keyExists { + if err := GenerateSelfSigned(cfg.CertFile, cfg.KeyFile); err != nil { + return nil, fmt.Errorf("generating self-signed cert: %w", err) + } + } + + return loadCertPair(cfg.CertFile, cfg.KeyFile) +} + +// loadCertPair loads a TLS certificate and key from the given file paths. +func loadCertPair(certFile, keyFile string) (*tls.Config, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("loading cert/key pair: %w", err) + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }, nil +} + +// writePEM encodes data as a PEM block and writes it to path (mode 0600). +func writePEM(path, pemType string, data []byte) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer f.Close() //nolint:errcheck + + return pem.Encode(f, &pem.Block{Type: pemType, Bytes: data}) +} + +// fileExists reports whether path refers to an existing file. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// loadACME sets up an autocert.Manager for automatic Let's Encrypt certificates. +// The returned TLSResult includes an HTTPHandler that must be served on :80 for +// HTTP-01 challenge validation and HTTP→HTTPS redirect. +func loadACME(cfg config.TLSConfig) (*TLSResult, error) { + if cfg.Domain == "" { + return nil, fmt.Errorf("TLS mode 'acme' requires tls.domain to be set (e.g. \"chat.example.com\")") + } + + // Validate domain is not an IP address. + if ip := net.ParseIP(cfg.Domain); ip != nil { + return nil, fmt.Errorf("TLS mode 'acme': domain must be a hostname, not an IP address (%s); Let's Encrypt does not issue certificates for IP addresses", cfg.Domain) + } + + // Reject wildcard domains (HTTP-01 does not support them). + if strings.HasPrefix(cfg.Domain, "*.") || strings.Contains(cfg.Domain, "*") { + return nil, fmt.Errorf("TLS mode 'acme': wildcard domains (%s) are not supported with HTTP-01 challenge; use a specific hostname", cfg.Domain) + } + + cacheDir := cfg.AcmeCacheDir + if cacheDir == "" { + cacheDir = "data/acme_certs" + } + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + return nil, fmt.Errorf("creating ACME cache directory %s: %w", cacheDir, err) + } + + m := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + Cache: autocert.DirCache(cacheDir), + HostPolicy: autocert.HostWhitelist(cfg.Domain), + } + + // HTTP handler serves ACME HTTP-01 challenges on port 80 and redirects + // all other traffic to HTTPS. + redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + target := "https://" + cfg.Domain + r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusMovedPermanently) + }) + + tlsCfg := m.TLSConfig() + tlsCfg.MinVersion = tls.VersionTLS12 + + return &TLSResult{ + TLSConfig: tlsCfg, + HTTPHandler: m.HTTPHandler(redirect), + }, nil +} diff --git a/Server/auth/tls_test.go b/Server/auth/tls_test.go new file mode 100644 index 00000000..53ca5bbd --- /dev/null +++ b/Server/auth/tls_test.go @@ -0,0 +1,290 @@ +package auth_test + +import ( + "crypto/tls" + "crypto/x509" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/config" +) + +func TestGenerateSelfSignedCreatesFiles(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + + if err := auth.GenerateSelfSigned(certFile, keyFile); err != nil { + t.Fatalf("GenerateSelfSigned() error: %v", err) + } + + if _, err := os.Stat(certFile); os.IsNotExist(err) { + t.Error("cert.pem not created") + } + if _, err := os.Stat(keyFile); os.IsNotExist(err) { + t.Error("key.pem not created") + } +} + +func TestGenerateSelfSignedProducesValidCert(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + + if err := auth.GenerateSelfSigned(certFile, keyFile); err != nil { + t.Fatalf("GenerateSelfSigned() error: %v", err) + } + + // Load the generated cert/key pair. + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + t.Fatalf("tls.LoadX509KeyPair error: %v", err) + } + + // Parse the leaf certificate. + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatalf("x509.ParseCertificate error: %v", err) + } + + // Verify validity period is at least 9 years in the future (10y cert). + minExpiry := time.Now().Add(9 * 365 * 24 * time.Hour) + if leaf.NotAfter.Before(minExpiry) { + t.Errorf("cert expires %v, expected at least 9 years from now (%v)", leaf.NotAfter, minExpiry) + } + + // Verify it is a CA/self-signed cert. + if !leaf.IsCA { + t.Error("expected IsCA = true for self-signed cert") + } +} + +func TestGenerateSelfSignedInvalidCertPath(t *testing.T) { + err := auth.GenerateSelfSigned("/nonexistent/dir/cert.pem", "/nonexistent/dir/key.pem") + if err == nil { + t.Error("GenerateSelfSigned() should error for invalid cert path") + } +} + +func TestGenerateSelfSignedInvalidKeyPath(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + + // Key path in non-existent dir. + err := auth.GenerateSelfSigned(certFile, "/nonexistent/dir/key.pem") + if err == nil { + t.Error("GenerateSelfSigned() should error for invalid key path") + } +} + +func TestLoadOrGenerateSelfSigned(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + + cfg := config.TLSConfig{ + Mode: "self_signed", + CertFile: certFile, + KeyFile: keyFile, + } + + result, err := auth.LoadOrGenerate(cfg) + if err != nil { + t.Fatalf("LoadOrGenerate() error: %v", err) + } + if result.TLSConfig == nil { + t.Fatal("LoadOrGenerate() returned nil TLSConfig") + } + if len(result.TLSConfig.Certificates) == 0 { + t.Error("LoadOrGenerate() returned TLSConfig with no certificates") + } + if result.HTTPHandler != nil { + t.Error("self_signed mode should not set HTTPHandler") + } +} + +func TestLoadOrGenerateLoadsExistingCert(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + + // Generate a cert first. + if err := auth.GenerateSelfSigned(certFile, keyFile); err != nil { + t.Fatalf("GenerateSelfSigned() error: %v", err) + } + + cfg := config.TLSConfig{ + Mode: "self_signed", + CertFile: certFile, + KeyFile: keyFile, + } + + // Load the existing cert (should not regenerate). + result, err := auth.LoadOrGenerate(cfg) + if err != nil { + t.Fatalf("LoadOrGenerate() error: %v", err) + } + if len(result.TLSConfig.Certificates) == 0 { + t.Error("LoadOrGenerate() returned no certificates") + } +} + +func TestLoadOrGenerateModeOff(t *testing.T) { + cfg := config.TLSConfig{Mode: "off"} + + result, err := auth.LoadOrGenerate(cfg) + if err != nil { + t.Fatalf("LoadOrGenerate(mode=off) error: %v", err) + } + if result.TLSConfig != nil { + t.Error("LoadOrGenerate(mode=off) should return nil TLSConfig") + } +} + +func TestLoadOrGenerateModeManualMissingFiles(t *testing.T) { + cfg := config.TLSConfig{ + Mode: "manual", + CertFile: "/nonexistent/cert.pem", + KeyFile: "/nonexistent/key.pem", + } + + _, err := auth.LoadOrGenerate(cfg) + if err == nil { + t.Error("LoadOrGenerate(mode=manual) should error when cert/key don't exist") + } +} + +func TestLoadOrGenerateModeManualValidFiles(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + + // Pre-generate cert files. + if err := auth.GenerateSelfSigned(certFile, keyFile); err != nil { + t.Fatalf("GenerateSelfSigned() error: %v", err) + } + + cfg := config.TLSConfig{ + Mode: "manual", + CertFile: certFile, + KeyFile: keyFile, + } + + result, err := auth.LoadOrGenerate(cfg) + if err != nil { + t.Fatalf("LoadOrGenerate(mode=manual) error: %v", err) + } + if len(result.TLSConfig.Certificates) == 0 { + t.Error("LoadOrGenerate(mode=manual) returned no certificates") + } +} + +func TestLoadOrGenerateUnknownMode(t *testing.T) { + cfg := config.TLSConfig{Mode: "unknown_mode"} + + _, err := auth.LoadOrGenerate(cfg) + if err == nil { + t.Error("LoadOrGenerate() should error for unknown TLS mode") + } +} + +// ── ACME mode tests ─────────────────────────────────────────────────────── + +func TestLoadOrGenerateACME_MissingDomain(t *testing.T) { + cfg := config.TLSConfig{Mode: "acme", Domain: ""} + + _, err := auth.LoadOrGenerate(cfg) + if err == nil { + t.Fatal("expected error for ACME mode without domain") + } + if !strings.Contains(err.Error(), "domain") { + t.Errorf("error should mention domain, got: %v", err) + } +} + +func TestLoadOrGenerateACME_IPAddress(t *testing.T) { + cfg := config.TLSConfig{Mode: "acme", Domain: "192.168.1.1"} + + _, err := auth.LoadOrGenerate(cfg) + if err == nil { + t.Fatal("expected error for ACME mode with IP address") + } + if !strings.Contains(err.Error(), "IP address") { + t.Errorf("error should mention IP address, got: %v", err) + } +} + +func TestLoadOrGenerateACME_WildcardDomain(t *testing.T) { + cfg := config.TLSConfig{Mode: "acme", Domain: "*.example.com"} + + _, err := auth.LoadOrGenerate(cfg) + if err == nil { + t.Fatal("expected error for ACME mode with wildcard domain") + } + if !strings.Contains(err.Error(), "wildcard") { + t.Errorf("error should mention wildcard, got: %v", err) + } +} + +func TestLoadOrGenerateACME_ValidDomain(t *testing.T) { + tmpDir := t.TempDir() + cacheDir := filepath.Join(tmpDir, "acme_certs") + + cfg := config.TLSConfig{ + Mode: "acme", + Domain: "chat.example.com", + AcmeCacheDir: cacheDir, + } + + result, err := auth.LoadOrGenerate(cfg) + if err != nil { + t.Fatalf("LoadOrGenerate(acme) error: %v", err) + } + if result.TLSConfig == nil { + t.Fatal("ACME mode should return non-nil TLSConfig") + } + if result.TLSConfig.GetCertificate == nil { + t.Error("ACME TLSConfig should have GetCertificate set") + } + if result.HTTPHandler == nil { + t.Error("ACME mode should return non-nil HTTPHandler") + } + + // Verify cache directory was created. + if _, err := os.Stat(cacheDir); os.IsNotExist(err) { + t.Error("ACME cache directory was not created") + } +} + +func TestLoadOrGenerateACME_HTTPRedirect(t *testing.T) { + tmpDir := t.TempDir() + cfg := config.TLSConfig{ + Mode: "acme", + Domain: "chat.example.com", + AcmeCacheDir: filepath.Join(tmpDir, "acme_certs"), + } + + result, err := auth.LoadOrGenerate(cfg) + if err != nil { + t.Fatalf("LoadOrGenerate(acme) error: %v", err) + } + + // Non-challenge requests should redirect to HTTPS. + req := httptest.NewRequest(http.MethodGet, "http://chat.example.com/some/path", nil) + rec := httptest.NewRecorder() + result.HTTPHandler.ServeHTTP(rec, req) + + if rec.Code != http.StatusMovedPermanently { + t.Errorf("expected 301 redirect, got %d", rec.Code) + } + loc := rec.Header().Get("Location") + if !strings.HasPrefix(loc, "https://chat.example.com/") { + t.Errorf("redirect should point to HTTPS, got: %s", loc) + } +} diff --git a/Server/config/config.go b/Server/config/config.go new file mode 100644 index 00000000..9e2f5b31 --- /dev/null +++ b/Server/config/config.go @@ -0,0 +1,230 @@ +// Package config provides configuration loading for the OwnCord server. +package config + +import ( + "fmt" + "os" + "strings" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/structs" + "github.com/knadh/koanf/v2" + goyaml "go.yaml.in/yaml/v3" +) + +// Config holds the full server configuration. +type Config struct { + Server ServerConfig `koanf:"server"` + Database DatabaseConfig `koanf:"database"` + TLS TLSConfig `koanf:"tls"` + Upload UploadConfig `koanf:"upload"` + Voice VoiceConfig `koanf:"voice"` + GitHub GitHubConfig `koanf:"github"` +} + +// GitHubConfig holds GitHub API settings for update checking. +type GitHubConfig struct { + Token string `koanf:"token"` +} + +// VoiceConfig holds STUN/TURN server settings and SFU configuration. +type VoiceConfig struct { + TURNSecret string `koanf:"turn_secret"` // HMAC-SHA1 secret; auto-generated if empty + STUNPort int `koanf:"stun_port"` // default 3478 + TURNPort int `koanf:"turn_port"` // default 3478 + TURNEnabled bool `koanf:"turn_enabled"` // default true + Quality string `koanf:"quality"` // low | medium | high + MixingThreshold int `koanf:"mixing_threshold"` // selective forwarding threshold + TopSpeakers int `koanf:"top_speakers"` // top-N speakers in selective mode + ExternalIP string `koanf:"external_ip"` // set if behind NAT + MediaPortMin int `koanf:"media_port_min"` // UDP port range start for WebRTC media + MediaPortMax int `koanf:"media_port_max"` // UDP port range end for WebRTC media +} + +// ServerConfig holds HTTP server settings. +type ServerConfig struct { + Port int `koanf:"port"` + Name string `koanf:"name"` + DataDir string `koanf:"data_dir"` + AllowedOrigins []string `koanf:"allowed_origins"` + TrustedProxies []string `koanf:"trusted_proxies"` +} + +// DatabaseConfig holds database settings. +type DatabaseConfig struct { + Path string `koanf:"path"` +} + +// TLSConfig holds TLS/certificate settings. +type TLSConfig struct { + Mode string `koanf:"mode"` + CertFile string `koanf:"cert_file"` + KeyFile string `koanf:"key_file"` + Domain string `koanf:"domain"` + AcmeCacheDir string `koanf:"acme_cache_dir"` +} + +// UploadConfig holds file upload settings. +type UploadConfig struct { + MaxSizeMB int `koanf:"max_size_mb"` + StorageDir string `koanf:"storage_dir"` +} + +// defaults returns the default configuration. +func defaults() Config { + return Config{ + Server: ServerConfig{ + Port: 8443, + Name: "OwnCord Server", + DataDir: "data", + AllowedOrigins: []string{"*"}, + TrustedProxies: []string{}, + }, + Database: DatabaseConfig{ + Path: "data/chatserver.db", + }, + TLS: TLSConfig{ + Mode: "self_signed", + CertFile: "data/cert.pem", + KeyFile: "data/key.pem", + AcmeCacheDir: "data/acme_certs", + }, + Upload: UploadConfig{ + MaxSizeMB: 100, + StorageDir: "data/uploads", + }, + Voice: VoiceConfig{ + STUNPort: 3478, + TURNPort: 3478, + TURNEnabled: true, + Quality: "medium", + MixingThreshold: 10, + TopSpeakers: 3, + MediaPortMin: 10000, + MediaPortMax: 10100, + }, + GitHub: GitHubConfig{}, + } +} + +// defaultYAML is the content written when no config file is present. +const defaultYAML = `# OwnCord Server Configuration +server: + port: 8443 + name: "OwnCord Server" + data_dir: "data" + # allowed_origins: ["*"] # restrict WebSocket origins, e.g. ["https://example.com"] + # trusted_proxies: [] # CIDRs of trusted reverse proxies, e.g. ["10.0.0.0/8"] + +database: + path: "data/chatserver.db" + +tls: + mode: "self_signed" # self_signed, acme, manual, off + cert_file: "data/cert.pem" + key_file: "data/key.pem" + domain: "" # required for acme mode (e.g. "chat.example.com") + acme_cache_dir: "data/acme_certs" # where Let's Encrypt certs are cached + +upload: + max_size_mb: 100 + storage_dir: "data/uploads" + +voice: + # external_ip: "" # set to your public IP if behind NAT (required for voice over internet) + # stun_port: 3478 # UDP port for STUN + # turn_port: 3478 # UDP port for TURN relay + # turn_enabled: true + # quality: "medium" # low | medium | high + # media_port_min: 10000 # UDP port range for WebRTC media + # media_port_max: 10100 + +# github: +# token: "" # optional: GitHub API token for higher rate limits (5000 req/hr vs 60) +` + +// Load reads configuration from the given YAML file path, merging with +// defaults and environment variable overrides. If the file does not exist, +// a default config.yaml is written and defaults are returned. +func Load(cfgPath string) (*Config, error) { + k := koanf.New(".") + + // Layer 1: built-in defaults via struct provider. + def := defaults() + if err := k.Load(structs.Provider(def, "koanf"), nil); err != nil { + return nil, fmt.Errorf("loading defaults: %w", err) + } + + // Layer 2: YAML file (create default if missing). + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + if writeErr := os.WriteFile(cfgPath, []byte(defaultYAML), 0o644); writeErr != nil { + return nil, fmt.Errorf("writing default config: %w", writeErr) + } + } else { + // Read the file and try to parse it ourselves to detect invalid YAML. + raw, readErr := os.ReadFile(cfgPath) + if readErr != nil { + return nil, fmt.Errorf("reading config file %s: %w", cfgPath, readErr) + } + if parseErr := validateYAML(raw); parseErr != nil { + return nil, fmt.Errorf("loading config file %s: %w", cfgPath, parseErr) + } + if err := k.Load(file.Provider(cfgPath), yaml.Parser()); err != nil { + return nil, fmt.Errorf("loading config file %s: %w", cfgPath, err) + } + } + + // Layer 3: environment variable overrides. + // OWNCORD_SERVER_PORT -> server.port, OWNCORD_TLS_MODE -> tls.mode, etc. + envProvider := env.Provider("OWNCORD_", ".", func(s string) string { + // Strip prefix, lowercase, replace _ with . except within a key segment. + // OWNCORD_SERVER_PORT -> server.port + // OWNCORD_DATABASE_PATH -> database.path + // OWNCORD_UPLOAD_MAX_SIZE_MB -> upload.max_size_mb + s = strings.TrimPrefix(s, "OWNCORD_") + s = strings.ToLower(s) + // Split into at most 2 parts on the first underscore to get + // section.key. We need smarter splitting because keys can have + // underscores (e.g. max_size_mb, data_dir, storage_dir). + return envKeyToKoanf(s) + }) + if err := k.Load(envProvider, nil); err != nil { + return nil, fmt.Errorf("loading env vars: %w", err) + } + + var cfg Config + if err := k.Unmarshal("", &cfg); err != nil { + return nil, fmt.Errorf("unmarshalling config: %w", err) + } + + return &cfg, nil +} + +// validateYAML checks that raw bytes are valid YAML. +func validateYAML(raw []byte) error { + var v any + return goyaml.Unmarshal(raw, &v) +} + +// envKeyToKoanf converts a lower-case env key (without OWNCORD_ prefix) to a +// koanf dotted path. The first segment (up to the first underscore) is the +// section; the remainder is the key (with underscores preserved). +// +// Examples: +// +// server_port -> server.port +// server_name -> server.name +// server_data_dir -> server.data_dir +// database_path -> database.path +// tls_mode -> tls.mode +// tls_cert_file -> tls.cert_file +// upload_max_size_mb -> upload.max_size_mb +func envKeyToKoanf(s string) string { + idx := strings.Index(s, "_") + if idx < 0 { + return s + } + return s[:idx] + "." + s[idx+1:] +} diff --git a/Server/config/config_test.go b/Server/config/config_test.go new file mode 100644 index 00000000..844df880 --- /dev/null +++ b/Server/config/config_test.go @@ -0,0 +1,321 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/owncord/server/config" +) + +func TestLoadDefaults(t *testing.T) { + // When no config file exists, Load should return defaults. + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() with missing file returned error: %v", err) + } + + tests := []struct { + name string + got any + want any + }{ + {"Server.Port", cfg.Server.Port, 8443}, + {"Server.Name", cfg.Server.Name, "OwnCord Server"}, + {"Server.DataDir", cfg.Server.DataDir, "data"}, + {"Database.Path", cfg.Database.Path, "data/chatserver.db"}, + {"TLS.Mode", cfg.TLS.Mode, "self_signed"}, + {"Upload.MaxSizeMB", cfg.Upload.MaxSizeMB, 100}, + {"Upload.StorageDir", cfg.Upload.StorageDir, "data/uploads"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("got %v, want %v", tc.got, tc.want) + } + }) + } +} + +func TestLoadGeneratesDefaultFile(t *testing.T) { + // When no config file exists, Load should write a default config.yaml. + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + _, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) { + t.Error("Load() did not generate default config.yaml") + } +} + +func TestLoadMergesYAML(t *testing.T) { + // When a YAML file exists with overrides, they should be merged with defaults. + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + yaml := ` +server: + port: 9000 + name: "My Custom Server" +database: + path: "custom/path.db" +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + if cfg.Server.Port != 9000 { + t.Errorf("Server.Port = %d, want 9000", cfg.Server.Port) + } + if cfg.Server.Name != "My Custom Server" { + t.Errorf("Server.Name = %q, want 'My Custom Server'", cfg.Server.Name) + } + if cfg.Database.Path != "custom/path.db" { + t.Errorf("Database.Path = %q, want 'custom/path.db'", cfg.Database.Path) + } + // Non-overridden defaults should still be present. + if cfg.Server.DataDir != "data" { + t.Errorf("Server.DataDir = %q, want 'data'", cfg.Server.DataDir) + } + if cfg.Upload.MaxSizeMB != 100 { + t.Errorf("Upload.MaxSizeMB = %d, want 100", cfg.Upload.MaxSizeMB) + } +} + +func TestLoadEnvironmentVariableOverrides(t *testing.T) { + // Environment variables with OWNCORD_ prefix should override config values. + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + t.Setenv("OWNCORD_SERVER_PORT", "7777") + t.Setenv("OWNCORD_SERVER_NAME", "Env Server") + t.Setenv("OWNCORD_DATABASE_PATH", "env/path.db") + t.Setenv("OWNCORD_TLS_MODE", "manual") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + if cfg.Server.Port != 7777 { + t.Errorf("Server.Port = %d, want 7777", cfg.Server.Port) + } + if cfg.Server.Name != "Env Server" { + t.Errorf("Server.Name = %q, want 'Env Server'", cfg.Server.Name) + } + if cfg.Database.Path != "env/path.db" { + t.Errorf("Database.Path = %q, want 'env/path.db'", cfg.Database.Path) + } + if cfg.TLS.Mode != "manual" { + t.Errorf("TLS.Mode = %q, want 'manual'", cfg.TLS.Mode) + } +} + +func TestLoadInvalidYAML(t *testing.T) { + // Malformed YAML (bad indentation/tab mix) should return an error. + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + // Tabs in YAML indentation are illegal per the YAML spec. + invalidYAML := "server:\n\tport: 9000\n" + if err := os.WriteFile(cfgPath, []byte(invalidYAML), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + _, err := config.Load(cfgPath) + if err == nil { + t.Error("Load() with invalid YAML should return error, got nil") + } +} + +func TestLoadTLSModeValues(t *testing.T) { + // Test that all valid TLS modes are accepted. + validModes := []string{"self_signed", "acme", "manual", "off"} + + for _, mode := range validModes { + t.Run(mode, func(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + yaml := "tls:\n mode: " + mode + "\n" + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.TLS.Mode != mode { + t.Errorf("TLS.Mode = %q, want %q", cfg.TLS.Mode, mode) + } + }) + } +} + +func TestLoadEnvVarNoUnderscore(t *testing.T) { + // Test an env var that maps to a top-level key (no section separator). + // OWNCORD_PORT (no second underscore) — should not crash, just map to "port". + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + t.Setenv("OWNCORD_PORT", "1234") + + // Load should succeed without panicking. + _, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } +} + +func TestLoadEnvVarStorageDir(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + t.Setenv("OWNCORD_UPLOAD_STORAGE_DIR", "/mnt/data/uploads") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.Upload.StorageDir != "/mnt/data/uploads" { + t.Errorf("Upload.StorageDir = %q, want '/mnt/data/uploads'", cfg.Upload.StorageDir) + } +} + +func TestLoadTLSCertAndKeyFields(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + yaml := ` +tls: + mode: "manual" + cert_file: "/etc/ssl/cert.pem" + key_file: "/etc/ssl/key.pem" + domain: "example.com" +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.TLS.CertFile != "/etc/ssl/cert.pem" { + t.Errorf("TLS.CertFile = %q, want '/etc/ssl/cert.pem'", cfg.TLS.CertFile) + } + if cfg.TLS.KeyFile != "/etc/ssl/key.pem" { + t.Errorf("TLS.KeyFile = %q, want '/etc/ssl/key.pem'", cfg.TLS.KeyFile) + } + if cfg.TLS.Domain != "example.com" { + t.Errorf("TLS.Domain = %q, want 'example.com'", cfg.TLS.Domain) + } +} + +func TestLoadVoiceConfigDefaults(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + tests := []struct { + name string + got any + want any + }{ + {"Voice.Quality", cfg.Voice.Quality, "medium"}, + {"Voice.MixingThreshold", cfg.Voice.MixingThreshold, 10}, + {"Voice.TopSpeakers", cfg.Voice.TopSpeakers, 3}, + {"Voice.ExternalIP", cfg.Voice.ExternalIP, ""}, + {"Voice.MediaPortMin", cfg.Voice.MediaPortMin, 10000}, + {"Voice.MediaPortMax", cfg.Voice.MediaPortMax, 10100}, + {"Voice.STUNPort", cfg.Voice.STUNPort, 3478}, + {"Voice.TURNPort", cfg.Voice.TURNPort, 3478}, + {"Voice.TURNEnabled", cfg.Voice.TURNEnabled, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("got %v, want %v", tc.got, tc.want) + } + }) + } +} + +func TestLoadVoiceConfigFromYAML(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + yaml := ` +voice: + quality: high + mixing_threshold: 5 + top_speakers: 4 + external_ip: "1.2.3.4" + media_port_min: 20000 + media_port_max: 20500 +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + if cfg.Voice.Quality != "high" { + t.Errorf("Voice.Quality = %q, want 'high'", cfg.Voice.Quality) + } + if cfg.Voice.MixingThreshold != 5 { + t.Errorf("Voice.MixingThreshold = %d, want 5", cfg.Voice.MixingThreshold) + } + if cfg.Voice.TopSpeakers != 4 { + t.Errorf("Voice.TopSpeakers = %d, want 4", cfg.Voice.TopSpeakers) + } + if cfg.Voice.ExternalIP != "1.2.3.4" { + t.Errorf("Voice.ExternalIP = %q, want '1.2.3.4'", cfg.Voice.ExternalIP) + } + if cfg.Voice.MediaPortMin != 20000 { + t.Errorf("Voice.MediaPortMin = %d, want 20000", cfg.Voice.MediaPortMin) + } + if cfg.Voice.MediaPortMax != 20500 { + t.Errorf("Voice.MediaPortMax = %d, want 20500", cfg.Voice.MediaPortMax) + } +} + +func TestLoadUploadBoundaryValues(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + yaml := "upload:\n max_size_mb: 0\n" + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.Upload.MaxSizeMB != 0 { + t.Errorf("Upload.MaxSizeMB = %d, want 0", cfg.Upload.MaxSizeMB) + } +} diff --git a/Server/db/admin_queries.go b/Server/db/admin_queries.go new file mode 100644 index 00000000..34a6857e --- /dev/null +++ b/Server/db/admin_queries.go @@ -0,0 +1,349 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "path/filepath" + "strings" +) + +// ─── Setup ─────────────────────────────────────────────────────────────────── + +// UserCount returns the total number of registered users. +func (d *DB) UserCount() (int64, error) { + var count int64 + if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { + return 0, fmt.Errorf("UserCount: %w", err) + } + return count, nil +} + +// ─── Server Stats ───────────────────────────────────────────────────────────── + +// GetServerStats returns aggregate counts for the admin dashboard. +// DBSizeBytes is 0 for in-memory databases (page_count * page_size returns +// a meaningful value only for file-backed databases). +func (d *DB) GetServerStats() (*ServerStats, error) { + stats := &ServerStats{} + + if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&stats.UserCount); err != nil { + return nil, fmt.Errorf("GetServerStats users: %w", err) + } + if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM messages WHERE deleted = 0`).Scan(&stats.MessageCount); err != nil { + return nil, fmt.Errorf("GetServerStats messages: %w", err) + } + if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM channels`).Scan(&stats.ChannelCount); err != nil { + return nil, fmt.Errorf("GetServerStats channels: %w", err) + } + if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM invites WHERE revoked = 0`).Scan(&stats.InviteCount); err != nil { + return nil, fmt.Errorf("GetServerStats invites: %w", err) + } + + // page_count * page_size gives the database size in bytes. + // For :memory: databases this still works (returns the in-memory size). + var pageCount, pageSize int64 + if err := d.sqlDB.QueryRow(`PRAGMA page_count`).Scan(&pageCount); err != nil { + return nil, fmt.Errorf("GetServerStats page_count: %w", err) + } + if err := d.sqlDB.QueryRow(`PRAGMA page_size`).Scan(&pageSize); err != nil { + return nil, fmt.Errorf("GetServerStats page_size: %w", err) + } + stats.DBSizeBytes = pageCount * pageSize + + return stats, nil +} + +// ─── User Management ────────────────────────────────────────────────────────── + +// ListAllUsers returns users joined with their role name, ordered by ID. +// limit=0 returns no rows. +func (d *DB) ListAllUsers(limit, offset int) ([]UserWithRole, error) { + rows, err := d.sqlDB.Query( + `SELECT u.id, u.username, u.password, u.avatar, u.role_id, u.totp_secret, + u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires, + COALESCE(r.name, '') AS role_name + FROM users u + LEFT JOIN roles r ON r.id = u.role_id + ORDER BY u.id ASC + LIMIT ? OFFSET ?`, + limit, offset, + ) + if err != nil { + return nil, fmt.Errorf("ListAllUsers: %w", err) + } + defer rows.Close() //nolint:errcheck + + var result []UserWithRole + for rows.Next() { + var uwr UserWithRole + var banned int + err := rows.Scan( + &uwr.ID, &uwr.Username, &uwr.PasswordHash, &uwr.Avatar, &uwr.RoleID, + &uwr.TOTPSecret, &uwr.Status, &uwr.CreatedAt, &uwr.LastSeen, + &banned, &uwr.BanReason, &uwr.BanExpires, + &uwr.RoleName, + ) + if err != nil { + return nil, fmt.Errorf("ListAllUsers scan: %w", err) + } + uwr.Banned = banned != 0 + result = append(result, uwr) + } + if rows.Err() != nil { + return nil, fmt.Errorf("ListAllUsers rows: %w", rows.Err()) + } + if result == nil { + result = []UserWithRole{} + } + return result, nil +} + +// UpdateUserRole changes the role_id of a user. +func (d *DB) UpdateUserRole(userID, roleID int64) error { + _, err := d.sqlDB.Exec( + `UPDATE users SET role_id = ? WHERE id = ?`, + roleID, userID, + ) + if err != nil { + return fmt.Errorf("UpdateUserRole: %w", err) + } + return nil +} + +// ForceLogoutUser deletes all sessions for the given user ID. +func (d *DB) ForceLogoutUser(userID int64) error { + _, err := d.sqlDB.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID) + if err != nil { + return fmt.Errorf("ForceLogoutUser: %w", err) + } + return nil +} + +// GetUserSessions returns all active sessions for the given user ID. +func (d *DB) GetUserSessions(userID int64) ([]Session, error) { + rows, err := d.sqlDB.Query( + `SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at + FROM sessions WHERE user_id = ? ORDER BY created_at DESC`, + userID, + ) + if err != nil { + return nil, fmt.Errorf("GetUserSessions: %w", err) + } + defer rows.Close() //nolint:errcheck + + var sessions []Session + for rows.Next() { + var s Session + err := rows.Scan( + &s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP, + &s.CreatedAt, &s.LastUsed, &s.ExpiresAt, + ) + if err != nil { + return nil, fmt.Errorf("GetUserSessions scan: %w", err) + } + sessions = append(sessions, s) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetUserSessions rows: %w", rows.Err()) + } + if sessions == nil { + sessions = []Session{} + } + return sessions, nil +} + +// ─── Channel Management (admin) ─────────────────────────────────────────────── + +// AdminCreateChannel creates a channel with full field control including position. +func (d *DB) AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) { + res, err := d.sqlDB.Exec( + `INSERT INTO channels (name, type, category, topic, position) + VALUES (?, ?, ?, ?, ?)`, + name, chanType, nullableString(category), nullableString(topic), position, + ) + if err != nil { + return 0, fmt.Errorf("AdminCreateChannel: %w", err) + } + return res.LastInsertId() +} + +// AdminUpdateChannel updates all mutable channel fields. +func (d *DB) AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error { + archivedInt := 0 + if archived { + archivedInt = 1 + } + _, err := d.sqlDB.Exec( + `UPDATE channels + SET name = ?, topic = ?, slow_mode = ?, position = ?, archived = ? + WHERE id = ?`, + name, nullableString(topic), slowMode, position, archivedInt, id, + ) + if err != nil { + return fmt.Errorf("AdminUpdateChannel: %w", err) + } + return nil +} + +// AdminDeleteChannel removes a channel by ID (cascades to messages, etc.). +func (d *DB) AdminDeleteChannel(id int64) error { + _, err := d.sqlDB.Exec(`DELETE FROM channels WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("AdminDeleteChannel: %w", err) + } + return nil +} + +// ─── Audit Log ──────────────────────────────────────────────────────────────── + +// LogAudit inserts an audit log entry. +func (d *DB) LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error { + _, err := d.sqlDB.Exec( + `INSERT INTO audit_log (actor_id, action, target_type, target_id, detail) + VALUES (?, ?, ?, ?, ?)`, + actorID, action, targetType, targetID, detail, + ) + if err != nil { + return fmt.Errorf("LogAudit: %w", err) + } + return nil +} + +// GetAuditLog returns audit log entries ordered newest-first with pagination. +func (d *DB) GetAuditLog(limit, offset int) ([]AuditEntry, error) { + rows, err := d.sqlDB.Query( + `SELECT a.id, a.actor_id, COALESCE(u.username, ''), a.action, + a.target_type, a.target_id, a.detail, a.created_at + FROM audit_log a + LEFT JOIN users u ON u.id = a.actor_id + ORDER BY a.id DESC + LIMIT ? OFFSET ?`, + limit, offset, + ) + if err != nil { + return nil, fmt.Errorf("GetAuditLog: %w", err) + } + defer rows.Close() //nolint:errcheck + + var entries []AuditEntry + for rows.Next() { + var e AuditEntry + if err := rows.Scan( + &e.ID, &e.ActorID, &e.ActorName, &e.Action, + &e.TargetType, &e.TargetID, &e.Detail, &e.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("GetAuditLog scan: %w", err) + } + entries = append(entries, e) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetAuditLog rows: %w", rows.Err()) + } + if entries == nil { + entries = []AuditEntry{} + } + return entries, nil +} + +// ─── Settings ───────────────────────────────────────────────────────────────── + +// GetSetting returns the value for the given settings key. +// Returns an error (wrapping sql.ErrNoRows) when the key does not exist. +func (d *DB) GetSetting(key string) (string, error) { + var value string + err := d.sqlDB.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("GetSetting: key %q not found", key) + } + if err != nil { + return "", fmt.Errorf("GetSetting: %w", err) + } + return value, nil +} + +// SetSetting upserts a setting value for the given key. +func (d *DB) SetSetting(key, value string) error { + _, err := d.sqlDB.Exec( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + key, value, + ) + if err != nil { + return fmt.Errorf("SetSetting: %w", err) + } + return nil +} + +// GetAllSettings returns all settings as a key→value map. +func (d *DB) GetAllSettings() (map[string]string, error) { + rows, err := d.sqlDB.Query(`SELECT key, value FROM settings`) + if err != nil { + return nil, fmt.Errorf("GetAllSettings: %w", err) + } + defer rows.Close() //nolint:errcheck + + result := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("GetAllSettings scan: %w", err) + } + result[k] = v + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetAllSettings rows: %w", rows.Err()) + } + return result, nil +} + +// ─── Backup ─────────────────────────────────────────────────────────────────── + +// BackupTo creates an online backup of the database using SQLite's VACUUM INTO. +// The destination path must not already exist. +// +// Security: VACUUM INTO does not support bind parameters, so the path is +// interpolated into SQL. To prevent injection we enforce two structural guards: +// 1. The path must resolve to a location under safeRoot (after filepath.Clean +// and filepath.Abs). +// 2. After structural validation, any single-quote, semicolon, double-dash, +// or null byte in the cleaned path causes rejection as defence-in-depth. +// +// The caller in handleBackup constructs the path from a hardcoded directory +// and a timestamp — no user input reaches this function. +func (d *DB) BackupTo(path string) error { + return d.BackupToSafe(path, filepath.Join("data", "backups")) +} + +// BackupToSafe is the internal implementation that accepts an explicit safe +// root directory. Exported for testing with isolated directories. +func (d *DB) BackupToSafe(path, safeRoot string) error { + clean := filepath.Clean(path) + + absRoot, err := filepath.Abs(safeRoot) + if err != nil { + return fmt.Errorf("BackupToSafe: resolving safe root: %w", err) + } + absClean, err := filepath.Abs(clean) + if err != nil { + return fmt.Errorf("BackupToSafe: resolving path: %w", err) + } + + // Structural guard: path must be under the safe root directory. + if !strings.HasPrefix(absClean, absRoot+string(filepath.Separator)) { + return fmt.Errorf("BackupToSafe: path %q is not under safe root %q", absClean, absRoot) + } + + // Defence-in-depth: reject characters that could break SQL quoting. + for _, forbidden := range []string{"'", `"`, ";", "--", "\x00"} { + if strings.Contains(clean, forbidden) { + return fmt.Errorf("BackupToSafe: path contains forbidden sequence %q", forbidden) + } + } + + _, err = d.sqlDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", clean)) + if err != nil { + return fmt.Errorf("BackupToSafe: %w", err) + } + return nil +} diff --git a/Server/db/admin_queries_test.go b/Server/db/admin_queries_test.go new file mode 100644 index 00000000..a0316da2 --- /dev/null +++ b/Server/db/admin_queries_test.go @@ -0,0 +1,832 @@ +package db_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "github.com/owncord/server/db" +) + +// adminTestSchema extends testSchema with tables needed for admin queries. +var adminTestSchema = append(testSchema, []byte(` +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, + edited_at TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT '', + target_id INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT OR IGNORE INTO settings (key, value) VALUES + ('server_name', 'OwnCord Server'), + ('motd', 'Welcome!'); +`)...) + +// newAdminTestDB opens an in-memory database with the admin-extended schema. +func newAdminTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: adminTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// ─── GetServerStats ──────────────────────────────────────────────────────────── + +func TestGetServerStats_EmptyDB(t *testing.T) { + database := newAdminTestDB(t) + + stats, err := database.GetServerStats() + if err != nil { + t.Fatalf("GetServerStats() error: %v", err) + } + if stats == nil { + t.Fatal("GetServerStats() returned nil") + } + if stats.UserCount != 0 { + t.Errorf("UserCount = %d, want 0", stats.UserCount) + } + if stats.MessageCount != 0 { + t.Errorf("MessageCount = %d, want 0", stats.MessageCount) + } + if stats.ChannelCount != 0 { + t.Errorf("ChannelCount = %d, want 0", stats.ChannelCount) + } + if stats.InviteCount != 0 { + t.Errorf("InviteCount = %d, want 0", stats.InviteCount) + } + if stats.DBSizeBytes < 0 { + t.Errorf("DBSizeBytes = %d, want >= 0", stats.DBSizeBytes) + } +} + +func TestGetServerStats_WithData(t *testing.T) { + database := newAdminTestDB(t) + + _, err := database.CreateUser("statuser", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + _, err = database.CreateChannel("general", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel error: %v", err) + } + + stats, err := database.GetServerStats() + if err != nil { + t.Fatalf("GetServerStats() error: %v", err) + } + if stats.UserCount != 1 { + t.Errorf("UserCount = %d, want 1", stats.UserCount) + } + if stats.ChannelCount != 1 { + t.Errorf("ChannelCount = %d, want 1", stats.ChannelCount) + } +} + +// ─── ListAllUsers ────────────────────────────────────────────────────────────── + +func TestListAllUsers_Empty(t *testing.T) { + database := newAdminTestDB(t) + + users, err := database.ListAllUsers(50, 0) + if err != nil { + t.Fatalf("ListAllUsers() error: %v", err) + } + if len(users) != 0 { + t.Errorf("ListAllUsers() = %d users, want 0", len(users)) + } +} + +func TestListAllUsers_WithRoleName(t *testing.T) { + database := newAdminTestDB(t) + + _, err := database.CreateUser("alice", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + users, err := database.ListAllUsers(50, 0) + if err != nil { + t.Fatalf("ListAllUsers() error: %v", err) + } + if len(users) != 1 { + t.Fatalf("ListAllUsers() = %d users, want 1", len(users)) + } + if users[0].Username != "alice" { + t.Errorf("Username = %q, want 'alice'", users[0].Username) + } + // RoleName comes from JOIN with roles table + if users[0].RoleName == "" { + t.Error("RoleName should not be empty — JOIN with roles table failed") + } +} + +func TestListAllUsers_Pagination(t *testing.T) { + database := newAdminTestDB(t) + + for i := range 5 { + _, err := database.CreateUser( + strings.Repeat("u", i+1), + "hash", + 4, + ) + if err != nil { + t.Fatalf("CreateUser[%d] error: %v", i, err) + } + } + + page1, err := database.ListAllUsers(3, 0) + if err != nil { + t.Fatalf("ListAllUsers page1 error: %v", err) + } + if len(page1) != 3 { + t.Errorf("page1 len = %d, want 3", len(page1)) + } + + page2, err := database.ListAllUsers(3, 3) + if err != nil { + t.Fatalf("ListAllUsers page2 error: %v", err) + } + if len(page2) != 2 { + t.Errorf("page2 len = %d, want 2", len(page2)) + } +} + +func TestListAllUsers_ZeroLimit(t *testing.T) { + database := newAdminTestDB(t) + _, _ = database.CreateUser("zerotest", "hash", 4) + + users, err := database.ListAllUsers(0, 0) + if err != nil { + t.Fatalf("ListAllUsers(0, 0) error: %v", err) + } + // limit=0 should return nothing + if len(users) != 0 { + t.Errorf("ListAllUsers(0, 0) = %d users, want 0", len(users)) + } +} + +// ─── UpdateUserRole ──────────────────────────────────────────────────────────── + +func TestUpdateUserRole(t *testing.T) { + database := newAdminTestDB(t) + + uid, err := database.CreateUser("roleuser", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + if err := database.UpdateUserRole(uid, 2); err != nil { + t.Fatalf("UpdateUserRole() error: %v", err) + } + + user, err := database.GetUserByID(uid) + if err != nil { + t.Fatalf("GetUserByID error: %v", err) + } + if user.RoleID != 2 { + t.Errorf("RoleID = %d, want 2", user.RoleID) + } +} + +func TestUpdateUserRole_NonexistentUser(t *testing.T) { + database := newAdminTestDB(t) + + // UPDATE with no matching rows is not an error + err := database.UpdateUserRole(99999, 2) + if err != nil { + t.Errorf("UpdateUserRole() for nonexistent user returned unexpected error: %v", err) + } +} + +// ─── ForceLogoutUser ─────────────────────────────────────────────────────────── + +func TestForceLogoutUser_DeletesSessions(t *testing.T) { + database := newAdminTestDB(t) + + uid, err := database.CreateUser("logoutuser", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + _, _ = database.CreateSession(uid, "token1hash", "device1", "127.0.0.1") + _, _ = database.CreateSession(uid, "token2hash", "device2", "127.0.0.1") + + sessions, err := database.GetUserSessions(uid) + if err != nil { + t.Fatalf("GetUserSessions error: %v", err) + } + if len(sessions) != 2 { + t.Fatalf("expected 2 sessions before logout, got %d", len(sessions)) + } + + if err := database.ForceLogoutUser(uid); err != nil { + t.Fatalf("ForceLogoutUser() error: %v", err) + } + + sessions, err = database.GetUserSessions(uid) + if err != nil { + t.Fatalf("GetUserSessions after logout error: %v", err) + } + if len(sessions) != 0 { + t.Errorf("expected 0 sessions after ForceLogoutUser, got %d", len(sessions)) + } +} + +func TestForceLogoutUser_NoSessions(t *testing.T) { + database := newAdminTestDB(t) + + uid, err := database.CreateUser("nosessions", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + if err := database.ForceLogoutUser(uid); err != nil { + t.Errorf("ForceLogoutUser() on user with no sessions returned error: %v", err) + } +} + +// ─── GetUserSessions ────────────────────────────────────────────────────────── + +func TestGetUserSessions_Empty(t *testing.T) { + database := newAdminTestDB(t) + + uid, err := database.CreateUser("sessionuser", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + sessions, err := database.GetUserSessions(uid) + if err != nil { + t.Fatalf("GetUserSessions() error: %v", err) + } + if len(sessions) != 0 { + t.Errorf("GetUserSessions() = %d, want 0", len(sessions)) + } +} + +func TestGetUserSessions_IsolatedByUser(t *testing.T) { + database := newAdminTestDB(t) + + uid1, _ := database.CreateUser("user1sess", "hash", 4) + uid2, _ := database.CreateUser("user2sess", "hash", 4) + + _, _ = database.CreateSession(uid1, "u1t1", "web", "1.2.3.4") + _, _ = database.CreateSession(uid1, "u1t2", "mobile", "1.2.3.5") + _, _ = database.CreateSession(uid2, "u2t1", "web", "1.2.3.6") + + sessions, err := database.GetUserSessions(uid1) + if err != nil { + t.Fatalf("GetUserSessions() error: %v", err) + } + if len(sessions) != 2 { + t.Errorf("GetUserSessions(uid1) = %d sessions, want 2", len(sessions)) + } + for _, s := range sessions { + if s.UserID != uid1 { + t.Errorf("session UserID = %d, want %d", s.UserID, uid1) + } + } +} + +// ─── AdminCreateChannel ──────────────────────────────────────────────────────── + +func TestAdminCreateChannel(t *testing.T) { + database := newAdminTestDB(t) + + id, err := database.AdminCreateChannel("announce", "text", "General", "Announcements", 1) + if err != nil { + t.Fatalf("AdminCreateChannel() error: %v", err) + } + if id <= 0 { + t.Errorf("AdminCreateChannel() id = %d, want > 0", id) + } + + ch, err := database.GetChannel(id) + if err != nil { + t.Fatalf("GetChannel() error: %v", err) + } + if ch == nil { + t.Fatal("GetChannel() returned nil after AdminCreateChannel") + } + if ch.Name != "announce" { + t.Errorf("Name = %q, want 'announce'", ch.Name) + } + if ch.Type != "text" { + t.Errorf("Type = %q, want 'text'", ch.Type) + } + if ch.Category != "General" { + t.Errorf("Category = %q, want 'General'", ch.Category) + } + if ch.Topic != "Announcements" { + t.Errorf("Topic = %q, want 'Announcements'", ch.Topic) + } + if ch.Position != 1 { + t.Errorf("Position = %d, want 1", ch.Position) + } +} + +func TestAdminCreateChannel_EmptyOptionals(t *testing.T) { + database := newAdminTestDB(t) + + id, err := database.AdminCreateChannel("simple", "voice", "", "", 0) + if err != nil { + t.Fatalf("AdminCreateChannel() error: %v", err) + } + + ch, err := database.GetChannel(id) + if err != nil { + t.Fatalf("GetChannel() error: %v", err) + } + if ch.Category != "" { + t.Errorf("Category = %q, want ''", ch.Category) + } + if ch.Topic != "" { + t.Errorf("Topic = %q, want ''", ch.Topic) + } +} + +// ─── AdminUpdateChannel ──────────────────────────────────────────────────────── + +func TestAdminUpdateChannel(t *testing.T) { + database := newAdminTestDB(t) + + id, err := database.AdminCreateChannel("old-name", "text", "", "", 0) + if err != nil { + t.Fatalf("AdminCreateChannel() error: %v", err) + } + + if err := database.AdminUpdateChannel(id, "new-name", "new topic", 5, 2, true); err != nil { + t.Fatalf("AdminUpdateChannel() error: %v", err) + } + + ch, err := database.GetChannel(id) + if err != nil { + t.Fatalf("GetChannel() error: %v", err) + } + if ch.Name != "new-name" { + t.Errorf("Name = %q, want 'new-name'", ch.Name) + } + if ch.Topic != "new topic" { + t.Errorf("Topic = %q, want 'new topic'", ch.Topic) + } + if ch.SlowMode != 5 { + t.Errorf("SlowMode = %d, want 5", ch.SlowMode) + } + if ch.Position != 2 { + t.Errorf("Position = %d, want 2", ch.Position) + } + if !ch.Archived { + t.Error("Archived = false, want true") + } +} + +func TestAdminUpdateChannel_Unarchive(t *testing.T) { + database := newAdminTestDB(t) + + id, _ := database.AdminCreateChannel("arch-ch", "text", "", "", 0) + _ = database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, true) + + ch, _ := database.GetChannel(id) + if !ch.Archived { + t.Fatal("channel should be archived") + } + + // Unarchive + _ = database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, false) + ch, _ = database.GetChannel(id) + if ch.Archived { + t.Error("Archived = true after unarchiving, want false") + } +} + +// ─── AdminDeleteChannel ──────────────────────────────────────────────────────── + +func TestAdminDeleteChannel(t *testing.T) { + database := newAdminTestDB(t) + + id, err := database.AdminCreateChannel("to-delete", "text", "", "", 0) + if err != nil { + t.Fatalf("AdminCreateChannel() error: %v", err) + } + + if err := database.AdminDeleteChannel(id); err != nil { + t.Fatalf("AdminDeleteChannel() error: %v", err) + } + + ch, err := database.GetChannel(id) + if err != nil { + t.Fatalf("GetChannel() after delete error: %v", err) + } + if ch != nil { + t.Error("channel should not exist after AdminDeleteChannel") + } +} + +func TestAdminDeleteChannel_NonExistent(t *testing.T) { + database := newAdminTestDB(t) + + // Deleting nonexistent channel should not error + if err := database.AdminDeleteChannel(99999); err != nil { + t.Errorf("AdminDeleteChannel(nonexistent) error: %v", err) + } +} + +// ─── LogAudit / GetAuditLog ──────────────────────────────────────────────────── + +func TestLogAudit_AndRetrieve(t *testing.T) { + database := newAdminTestDB(t) + + uid, err := database.CreateUser("auditor", "hash", 1) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + if err := database.LogAudit(uid, "USER_BANNED", "user", 42, "banned for spam"); err != nil { + t.Fatalf("LogAudit() error: %v", err) + } + + entries, err := database.GetAuditLog(10, 0) + if err != nil { + t.Fatalf("GetAuditLog() error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("GetAuditLog() = %d entries, want 1", len(entries)) + } + + e := entries[0] + if e.ActorID != uid { + t.Errorf("ActorID = %d, want %d", e.ActorID, uid) + } + if e.Action != "USER_BANNED" { + t.Errorf("Action = %q, want 'USER_BANNED'", e.Action) + } + if e.TargetType != "user" { + t.Errorf("TargetType = %q, want 'user'", e.TargetType) + } + if e.TargetID != 42 { + t.Errorf("TargetID = %d, want 42", e.TargetID) + } + if e.Detail != "banned for spam" { + t.Errorf("Detail = %q, want 'banned for spam'", e.Detail) + } + if e.ActorName != "auditor" { + t.Errorf("ActorName = %q, want 'auditor'", e.ActorName) + } + if e.CreatedAt == "" { + t.Error("CreatedAt should not be empty") + } +} + +func TestGetAuditLog_Empty(t *testing.T) { + database := newAdminTestDB(t) + + entries, err := database.GetAuditLog(10, 0) + if err != nil { + t.Fatalf("GetAuditLog() error: %v", err) + } + if len(entries) != 0 { + t.Errorf("GetAuditLog() = %d entries, want 0", len(entries)) + } +} + +func TestGetAuditLog_Pagination(t *testing.T) { + database := newAdminTestDB(t) + + uid, _ := database.CreateUser("auditpager", "hash", 1) + for i := range 5 { + _ = database.LogAudit(uid, "ACTION", "target", int64(i), "detail") + } + + page1, err := database.GetAuditLog(3, 0) + if err != nil { + t.Fatalf("GetAuditLog page1 error: %v", err) + } + if len(page1) != 3 { + t.Errorf("page1 len = %d, want 3", len(page1)) + } + + page2, err := database.GetAuditLog(3, 3) + if err != nil { + t.Fatalf("GetAuditLog page2 error: %v", err) + } + if len(page2) != 2 { + t.Errorf("page2 len = %d, want 2", len(page2)) + } +} + +func TestGetAuditLog_NewestFirst(t *testing.T) { + database := newAdminTestDB(t) + + uid, _ := database.CreateUser("auditorder", "hash", 1) + _ = database.LogAudit(uid, "FIRST", "", 0, "") + _ = database.LogAudit(uid, "SECOND", "", 0, "") + + entries, err := database.GetAuditLog(10, 0) + if err != nil { + t.Fatalf("GetAuditLog() error: %v", err) + } + if len(entries) < 2 { + t.Fatalf("expected at least 2 entries, got %d", len(entries)) + } + if entries[0].ID <= entries[1].ID { + t.Error("GetAuditLog should return newest entries first (highest ID first)") + } +} + +// ─── GetSetting / SetSetting / GetAllSettings ────────────────────────────────── + +func TestGetSetting_Exists(t *testing.T) { + database := newAdminTestDB(t) + + val, err := database.GetSetting("server_name") + if err != nil { + t.Fatalf("GetSetting() error: %v", err) + } + if val == "" { + t.Error("server_name should not be empty") + } +} + +func TestGetSetting_NotFound(t *testing.T) { + database := newAdminTestDB(t) + + _, err := database.GetSetting("nonexistent_key_xyz") + if err == nil { + t.Error("GetSetting() for nonexistent key should return error") + } +} + +func TestSetSetting_NewKey(t *testing.T) { + database := newAdminTestDB(t) + + if err := database.SetSetting("custom_key", "custom_val"); err != nil { + t.Fatalf("SetSetting() error: %v", err) + } + + val, err := database.GetSetting("custom_key") + if err != nil { + t.Fatalf("GetSetting() after SetSetting error: %v", err) + } + if val != "custom_val" { + t.Errorf("val = %q, want 'custom_val'", val) + } +} + +func TestSetSetting_UpdateExisting(t *testing.T) { + database := newAdminTestDB(t) + + if err := database.SetSetting("server_name", "My Custom Server"); err != nil { + t.Fatalf("SetSetting() update error: %v", err) + } + + val, err := database.GetSetting("server_name") + if err != nil { + t.Fatalf("GetSetting() error: %v", err) + } + if val != "My Custom Server" { + t.Errorf("val = %q, want 'My Custom Server'", val) + } +} + +func TestGetAllSettings_ReturnsMap(t *testing.T) { + database := newAdminTestDB(t) + + settings, err := database.GetAllSettings() + if err != nil { + t.Fatalf("GetAllSettings() error: %v", err) + } + if len(settings) == 0 { + t.Error("GetAllSettings() should return default settings") + } + if _, ok := settings["server_name"]; !ok { + t.Error("GetAllSettings() missing 'server_name'") + } +} + +func TestGetAllSettings_AfterClearing(t *testing.T) { + database := newAdminTestDB(t) + + _, _ = database.Exec("DELETE FROM settings") + + settings, err := database.GetAllSettings() + if err != nil { + t.Fatalf("GetAllSettings() after clearing error: %v", err) + } + if len(settings) != 0 { + t.Errorf("GetAllSettings() after clearing = %d entries, want 0", len(settings)) + } +} + +// ─── BackupToSafe ──────────────────────────────────────────────────────────── + +func TestBackupToSafe_AdminQueries(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "source.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: adminTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + + backupDir := filepath.Join(tmpDir, "backups") + _ = os.MkdirAll(backupDir, 0o755) + backupPath := filepath.Join(backupDir, "backup.db") + if err := database.BackupToSafe(backupPath, backupDir); err != nil { + t.Fatalf("BackupToSafe() error: %v", err) + } + + info, err := os.Stat(backupPath) + if err != nil { + t.Fatalf("backup file does not exist: %v", err) + } + if info.Size() == 0 { + t.Error("backup file is empty") + } +} + +func TestBackupToSafe_CreatesDirectoryFile(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "src.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: adminTestSchema}, + } + _ = db.MigrateFS(database, migrFS) + + backupDir := filepath.Join(tmpDir, "backups") + _ = os.MkdirAll(backupDir, 0o755) + backupPath := filepath.Join(backupDir, "chatserver_20260314_120000.db") + + if err := database.BackupToSafe(backupPath, backupDir); err != nil { + t.Fatalf("BackupToSafe() error: %v", err) + } + + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + t.Error("backup file was not created") + } +} + +// ─── UserCount ────────────────────────────────────────────────────────────── + +func TestUserCount_Empty(t *testing.T) { + database := newAdminTestDB(t) + + count, err := database.UserCount() + if err != nil { + t.Fatalf("UserCount() error: %v", err) + } + if count != 0 { + t.Errorf("UserCount() = %d, want 0", count) + } +} + +func TestUserCount_WithUsers(t *testing.T) { + database := newAdminTestDB(t) + + for i := range 3 { + _, err := database.CreateUser( + fmt.Sprintf("countuser%d", i), + "hash", + 4, + ) + if err != nil { + t.Fatalf("CreateUser[%d] error: %v", i, err) + } + } + + count, err := database.UserCount() + if err != nil { + t.Fatalf("UserCount() error: %v", err) + } + if count != 3 { + t.Errorf("UserCount() = %d, want 3", count) + } +} + +// ─── BackupTo ─────────────────────────────────────────────────────────────── + +func TestBackupToSafe_DirectCall(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "backup_src.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: adminTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + + backupDir := filepath.Join(tmpDir, "backups") + _ = os.MkdirAll(backupDir, 0o755) + backupPath := filepath.Join(backupDir, "backup_direct.db") + if err := database.BackupToSafe(backupPath, backupDir); err != nil { + t.Fatalf("BackupToSafe() error: %v", err) + } + + info, err := os.Stat(backupPath) + if err != nil { + t.Fatalf("backup file does not exist: %v", err) + } + if info.Size() == 0 { + t.Error("backup file is empty") + } +} + +func TestBackupToSafe_RejectsTraversal(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "src.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: adminTestSchema}, + } + _ = db.MigrateFS(database, migrFS) + + safeRoot := filepath.Join(tmpDir, "safe") + _ = os.MkdirAll(safeRoot, 0o755) + unsafePath := filepath.Join(tmpDir, "outside", "evil.db") + + err = database.BackupToSafe(unsafePath, safeRoot) + if err == nil { + t.Error("BackupToSafe should reject path outside safe root") + } +} diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go new file mode 100644 index 00000000..5e846852 --- /dev/null +++ b/Server/db/attachment_queries.go @@ -0,0 +1,117 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// Attachment represents a row in the attachments table. +type Attachment struct { + ID string + MessageID *int64 + Filename string + StoredAs string + MimeType string + Size int64 + UploadedAt string +} + +// CreateAttachment inserts a new attachment record (initially unlinked to any message). +func (d *DB) CreateAttachment(id, filename, storedAs, mimeType string, size int64) error { + _, err := d.sqlDB.Exec( + `INSERT INTO attachments (id, filename, stored_as, mime_type, size) VALUES (?, ?, ?, ?, ?)`, + id, filename, storedAs, mimeType, size, + ) + if err != nil { + return fmt.Errorf("CreateAttachment: %w", err) + } + return nil +} + +// GetAttachmentByID returns the attachment with the given ID, or nil if not found. +func (d *DB) GetAttachmentByID(id string) (*Attachment, error) { + row := d.sqlDB.QueryRow( + `SELECT id, message_id, filename, stored_as, mime_type, size, uploaded_at + FROM attachments WHERE id = ?`, id, + ) + a := &Attachment{} + err := row.Scan(&a.ID, &a.MessageID, &a.Filename, &a.StoredAs, &a.MimeType, &a.Size, &a.UploadedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetAttachmentByID: %w", err) + } + return a, nil +} + +// LinkAttachmentsToMessage sets message_id on attachments that are currently +// unlinked (message_id IS NULL). Returns the number of rows updated. +// Uses WHERE message_id IS NULL to prevent double-linking in a race. +func (d *DB) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) { + if len(attachmentIDs) == 0 { + return 0, nil + } + + placeholders := make([]string, len(attachmentIDs)) + args := make([]any, 0, len(attachmentIDs)+1) + args = append(args, messageID) + for i, id := range attachmentIDs { + placeholders[i] = "?" + args = append(args, id) + } + + query := fmt.Sprintf( + `UPDATE attachments SET message_id = ? WHERE id IN (%s) AND message_id IS NULL`, + strings.Join(placeholders, ","), + ) + res, err := d.sqlDB.Exec(query, args...) + if err != nil { + return 0, fmt.Errorf("LinkAttachmentsToMessage: %w", err) + } + return res.RowsAffected() +} + +// GetAttachmentsByMessageIDs returns attachments grouped by message ID. +func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentInfo, error) { + if len(msgIDs) == 0 { + return map[int64][]AttachmentInfo{}, nil + } + + placeholders := make([]string, len(msgIDs)) + args := make([]any, len(msgIDs)) + for i, id := range msgIDs { + placeholders[i] = "?" + args[i] = id + } + + query := fmt.Sprintf( + `SELECT id, message_id, filename, size, mime_type + FROM attachments WHERE message_id IN (%s)`, + strings.Join(placeholders, ","), + ) + rows, err := d.sqlDB.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("GetAttachmentsByMessageIDs: %w", err) + } + defer rows.Close() //nolint:errcheck + + result := make(map[int64][]AttachmentInfo) + for rows.Next() { + var id string + var msgID int64 + var ai AttachmentInfo + if scanErr := rows.Scan(&id, &msgID, &ai.Filename, &ai.Size, &ai.Mime); scanErr != nil { + return nil, fmt.Errorf("GetAttachmentsByMessageIDs scan: %w", scanErr) + } + ai.ID = id + ai.URL = "/api/v1/files/" + id + result[msgID] = append(result[msgID], ai) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetAttachmentsByMessageIDs rows: %w", rows.Err()) + } + return result, nil +} diff --git a/Server/db/attachment_queries_test.go b/Server/db/attachment_queries_test.go new file mode 100644 index 00000000..ab7339e4 --- /dev/null +++ b/Server/db/attachment_queries_test.go @@ -0,0 +1,181 @@ +package db_test + +import ( + "testing" +) + +// ─── GetAttachmentByID ────────────────────────────────────────────────────── + +func TestGetAttachmentByID_NotFound(t *testing.T) { + database := openMigratedMemory(t) + + att, err := database.GetAttachmentByID("nonexistent-id") + if err != nil { + t.Errorf("GetAttachmentByID for nonexistent ID should return nil error, got %v", err) + } + if att != nil { + t.Error("GetAttachmentByID for nonexistent ID should return nil attachment") + } +} + +func TestGetAttachmentByID_Found(t *testing.T) { + database := openMigratedMemory(t) + + // Insert an attachment directly. + _, err := database.Exec( + `INSERT INTO attachments (id, filename, stored_as, mime_type, size) + VALUES (?, ?, ?, ?, ?)`, + "att-001", "photo.png", "stored-photo.png", "image/png", 12345, + ) + if err != nil { + t.Fatalf("inserting attachment: %v", err) + } + + att, err := database.GetAttachmentByID("att-001") + if err != nil { + t.Fatalf("GetAttachmentByID: %v", err) + } + if att.ID != "att-001" { + t.Errorf("ID = %q, want 'att-001'", att.ID) + } + if att.Filename != "photo.png" { + t.Errorf("Filename = %q, want 'photo.png'", att.Filename) + } + if att.MimeType != "image/png" { + t.Errorf("MimeType = %q, want 'image/png'", att.MimeType) + } + if att.Size != 12345 { + t.Errorf("Size = %d, want 12345", att.Size) + } + if att.MessageID != nil { + t.Errorf("MessageID = %v, want nil (unlinked)", att.MessageID) + } +} + +// ─── LinkAttachmentsToMessage ──────────────────────────────────────────────── + +func TestLinkAttachmentsToMessage_Empty(t *testing.T) { + database := openMigratedMemory(t) + + n, err := database.LinkAttachmentsToMessage(1, nil) + if err != nil { + t.Fatalf("LinkAttachmentsToMessage(nil): %v", err) + } + if n != 0 { + t.Errorf("expected 0 rows affected, got %d", n) + } +} + +func TestLinkAttachmentsToMessage_LinksUnlinked(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "linkuser") + chID := seedChannel(t, database, "linkchan") + msgID, _ := database.CreateMessage(chID, userID, "with attachment", nil) + + // Insert two unlinked attachments. + for _, id := range []string{"att-a", "att-b"} { + _, err := database.Exec( + `INSERT INTO attachments (id, filename, stored_as, mime_type, size) + VALUES (?, ?, ?, ?, ?)`, + id, "file.txt", "stored.txt", "text/plain", 100, + ) + if err != nil { + t.Fatalf("inserting attachment %s: %v", id, err) + } + } + + n, err := database.LinkAttachmentsToMessage(msgID, []string{"att-a", "att-b"}) + if err != nil { + t.Fatalf("LinkAttachmentsToMessage: %v", err) + } + if n != 2 { + t.Errorf("expected 2 rows affected, got %d", n) + } + + // Verify linkage. + att, _ := database.GetAttachmentByID("att-a") + if att.MessageID == nil || *att.MessageID != msgID { + t.Errorf("att-a MessageID = %v, want %d", att.MessageID, msgID) + } +} + +func TestLinkAttachmentsToMessage_SkipsAlreadyLinked(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "linkuser2") + chID := seedChannel(t, database, "linkchan2") + msg1, _ := database.CreateMessage(chID, userID, "msg1", nil) + msg2, _ := database.CreateMessage(chID, userID, "msg2", nil) + + _, _ = database.Exec( + `INSERT INTO attachments (id, filename, stored_as, mime_type, size, message_id) + VALUES (?, ?, ?, ?, ?, ?)`, + "att-linked", "file.txt", "stored.txt", "text/plain", 100, msg1, + ) + + // Try to re-link to a different message — should skip (WHERE message_id IS NULL). + n, err := database.LinkAttachmentsToMessage(msg2, []string{"att-linked"}) + if err != nil { + t.Fatalf("LinkAttachmentsToMessage: %v", err) + } + if n != 0 { + t.Errorf("expected 0 rows (already linked), got %d", n) + } +} + +// ─── GetAttachmentsByMessageIDs ────────────────────────────────────────────── + +func TestGetAttachmentsByMessageIDs_Empty(t *testing.T) { + database := openMigratedMemory(t) + + result, err := database.GetAttachmentsByMessageIDs(nil) + if err != nil { + t.Fatalf("GetAttachmentsByMessageIDs(nil): %v", err) + } + if len(result) != 0 { + t.Errorf("expected empty map, got %d entries", len(result)) + } +} + +func TestGetAttachmentsByMessageIDs_GroupsByMessage(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "attuser") + chID := seedChannel(t, database, "attchan") + msg1, _ := database.CreateMessage(chID, userID, "msg1", nil) + msg2, _ := database.CreateMessage(chID, userID, "msg2", nil) + + // Two attachments on msg1, one on msg2. + for _, row := range []struct { + id string + msgID int64 + }{ + {"att-1a", msg1}, + {"att-1b", msg1}, + {"att-2a", msg2}, + } { + _, err := database.Exec( + `INSERT INTO attachments (id, filename, stored_as, mime_type, size, message_id) + VALUES (?, ?, ?, ?, ?, ?)`, + row.id, "f.txt", "s.txt", "text/plain", 50, row.msgID, + ) + if err != nil { + t.Fatalf("insert %s: %v", row.id, err) + } + } + + result, err := database.GetAttachmentsByMessageIDs([]int64{msg1, msg2}) + if err != nil { + t.Fatalf("GetAttachmentsByMessageIDs: %v", err) + } + if len(result[msg1]) != 2 { + t.Errorf("msg1 attachments = %d, want 2", len(result[msg1])) + } + if len(result[msg2]) != 1 { + t.Errorf("msg2 attachments = %d, want 1", len(result[msg2])) + } + // Verify URL format. + for _, ai := range result[msg1] { + if ai.URL == "" { + t.Error("attachment URL should not be empty") + } + } +} diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go new file mode 100644 index 00000000..76109dc7 --- /dev/null +++ b/Server/db/auth_queries.go @@ -0,0 +1,375 @@ +package db + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" +) + +// ─── User Operations ────────────────────────────────────────────────────────── + +// CreateUser inserts a new user record and returns the assigned ID. +func (d *DB) CreateUser(username, passwordHash string, roleID int) (int64, error) { + res, err := d.sqlDB.Exec( + `INSERT INTO users (username, password, role_id) VALUES (?, ?, ?)`, + username, passwordHash, roleID, + ) + if err != nil { + return 0, fmt.Errorf("CreateUser: %w", err) + } + return res.LastInsertId() +} + +// GetUserByUsername returns the user with the given username (case-insensitive), +// or nil if not found. +func (d *DB) GetUserByUsername(username string) (*User, error) { + row := d.sqlDB.QueryRow( + `SELECT id, username, password, avatar, role_id, totp_secret, status, + created_at, last_seen, banned, ban_reason, ban_expires + FROM users WHERE username = ? COLLATE NOCASE`, + username, + ) + return scanUser(row) +} + +// GetUserByID returns the user with the given ID, or nil if not found. +func (d *DB) GetUserByID(id int64) (*User, error) { + row := d.sqlDB.QueryRow( + `SELECT id, username, password, avatar, role_id, totp_secret, status, + created_at, last_seen, banned, ban_reason, ban_expires + FROM users WHERE id = ?`, + id, + ) + return scanUser(row) +} + +// scanUser reads a User from a *sql.Row, returning nil (not an error) when the +// row is not found. +func scanUser(row *sql.Row) (*User, error) { + u := &User{} + var banned int + err := row.Scan( + &u.ID, &u.Username, &u.PasswordHash, &u.Avatar, &u.RoleID, + &u.TOTPSecret, &u.Status, &u.CreatedAt, &u.LastSeen, + &banned, &u.BanReason, &u.BanExpires, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scanUser: %w", err) + } + u.Banned = banned != 0 + return u, nil +} + +// UpdateUserStatus sets the status column for the given user ID. +func (d *DB) UpdateUserStatus(id int64, status string) error { + _, err := d.sqlDB.Exec( + `UPDATE users SET status = ?, last_seen = datetime('now') WHERE id = ?`, + status, id, + ) + if err != nil { + return fmt.Errorf("UpdateUserStatus: %w", err) + } + return nil +} + +// ResetAllUserStatuses sets all users to "offline". Called on server startup +// to clear stale statuses from a previous run or crash. +func (d *DB) ResetAllUserStatuses() error { + _, err := d.sqlDB.Exec(`UPDATE users SET status = 'offline' WHERE status != 'offline'`) + if err != nil { + return fmt.Errorf("ResetAllUserStatuses: %w", err) + } + return nil +} + +// BanUser marks a user as banned with an optional expiry. Pass nil for a +// permanent ban. +func (d *DB) BanUser(id int64, reason string, expires *time.Time) error { + var expiresStr *string + if expires != nil { + s := expires.UTC().Format("2006-01-02T15:04:05Z") + expiresStr = &s + } + _, err := d.sqlDB.Exec( + `UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?`, + reason, expiresStr, id, + ) + if err != nil { + return fmt.Errorf("BanUser: %w", err) + } + return nil +} + +// UnbanUser removes the ban from a user. +func (d *DB) UnbanUser(id int64) error { + _, err := d.sqlDB.Exec( + `UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?`, + id, + ) + if err != nil { + return fmt.Errorf("UnbanUser: %w", err) + } + return nil +} + +// ─── Session Operations ─────────────────────────────────────────────────────── + +// CreateSession inserts a new session and returns the session ID. +// tokenHash must already be hashed (never store plaintext tokens). +func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) { + expiresAt := time.Now().Add(sessionTTL).UTC().Format("2006-01-02T15:04:05Z") + res, err := d.sqlDB.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) + VALUES (?, ?, ?, ?, ?)`, + userID, tokenHash, device, ip, expiresAt, + ) + if err != nil { + return 0, fmt.Errorf("CreateSession: %w", err) + } + return res.LastInsertId() +} + +// GetSessionByTokenHash retrieves a session by its hashed token, or nil if +// not found. +func (d *DB) GetSessionByTokenHash(tokenHash string) (*Session, error) { + row := d.sqlDB.QueryRow( + `SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at + FROM sessions WHERE token = ?`, + tokenHash, + ) + s := &Session{} + err := row.Scan( + &s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP, + &s.CreatedAt, &s.LastUsed, &s.ExpiresAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetSessionByTokenHash: %w", err) + } + return s, nil +} + +// SessionWithBanStatus combines session data with user ban fields +// in a single query, avoiding two sequential DB round-trips. +type SessionWithBanStatus struct { + Session + Banned bool + BanReason *string + BanExpires *string +} + +// GetSessionWithBanStatus returns the session joined with the user's ban +// status in a single query. Returns nil, nil when not found. +func (d *DB) GetSessionWithBanStatus(tokenHash string) (*SessionWithBanStatus, error) { + row := d.sqlDB.QueryRow( + `SELECT s.id, s.user_id, s.token, s.device, s.ip_address, + s.created_at, s.last_used, s.expires_at, + u.banned, u.ban_reason, u.ban_expires + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ?`, + tokenHash, + ) + r := &SessionWithBanStatus{} + var banned int + err := row.Scan( + &r.ID, &r.UserID, &r.TokenHash, &r.Device, &r.IP, + &r.CreatedAt, &r.LastUsed, &r.ExpiresAt, + &banned, &r.BanReason, &r.BanExpires, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetSessionWithBanStatus: %w", err) + } + r.Banned = banned != 0 + return r, nil +} + +// DeleteSession removes the session with the given token hash. +func (d *DB) DeleteSession(tokenHash string) error { + _, err := d.sqlDB.Exec(`DELETE FROM sessions WHERE token = ?`, tokenHash) + if err != nil { + return fmt.Errorf("DeleteSession: %w", err) + } + return nil +} + +// DeleteExpiredSessions removes all sessions whose expires_at is in the past. +// Compares using strftime to handle both ISO-8601 and SQLite datetime formats. +func (d *DB) DeleteExpiredSessions() error { + _, err := d.sqlDB.Exec( + `DELETE FROM sessions WHERE strftime('%s', expires_at) < strftime('%s', 'now')`, + ) + if err != nil { + return fmt.Errorf("DeleteExpiredSessions: %w", err) + } + return nil +} + +// TouchSession updates last_used for the session with the given token hash. +func (d *DB) TouchSession(tokenHash string) error { + _, err := d.sqlDB.Exec( + `UPDATE sessions SET last_used = datetime('now') WHERE token = ?`, + tokenHash, + ) + if err != nil { + return fmt.Errorf("TouchSession: %w", err) + } + return nil +} + +// ─── Invite Operations ──────────────────────────────────────────────────────── + +// CreateInvite generates a random invite code, persists it, and returns the +// code. maxUses=0 means unlimited. expiresAt=nil means never expires. +func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { + code, err := generateInviteCode() + if err != nil { + return "", fmt.Errorf("CreateInvite generate code: %w", err) + } + + var maxUsesVal *int + if maxUses > 0 { + maxUsesVal = &maxUses + } + var expiresStr *string + if expiresAt != nil { + s := expiresAt.UTC().Format("2006-01-02T15:04:05Z") + expiresStr = &s + } + + _, err = d.sqlDB.Exec( + `INSERT INTO invites (code, created_by, max_uses, expires_at) VALUES (?, ?, ?, ?)`, + code, createdBy, maxUsesVal, expiresStr, + ) + if err != nil { + return "", fmt.Errorf("CreateInvite insert: %w", err) + } + return code, nil +} + +// GetInvite returns the invite for the given code, or nil if not found. +func (d *DB) GetInvite(code string) (*Invite, error) { + row := d.sqlDB.QueryRow( + `SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at + FROM invites WHERE code = ?`, + code, + ) + inv := &Invite{} + var revoked int + err := row.Scan( + &inv.ID, &inv.Code, &inv.CreatedBy, &inv.MaxUses, + &inv.Uses, &inv.ExpiresAt, &revoked, &inv.CreatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetInvite: %w", err) + } + inv.Revoked = revoked != 0 + return inv, nil +} + +// UseInviteAtomic validates and increments the use_count in a single SQL +// statement, eliminating the TOCTOU race that exists when GetInvite and +// UseInvite are called as separate operations. +// +// The UPDATE only matches rows where: +// - the code exists +// - revoked = 0 +// - max_uses IS NULL (unlimited) OR uses < max_uses +// - expires_at IS NULL (never) OR expires_at > now +// +// If zero rows are affected the invite is missing, revoked, expired, or +// exhausted — an error is returned in all such cases. +func (d *DB) UseInviteAtomic(code string) error { + result, err := d.sqlDB.Exec( + `UPDATE invites SET use_count = use_count + 1 + WHERE code = ? AND revoked = 0 + AND (max_uses IS NULL OR use_count < max_uses) + AND (expires_at IS NULL OR strftime('%s', expires_at) > strftime('%s', 'now'))`, + code, + ) + if err != nil { + return fmt.Errorf("UseInviteAtomic: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("UseInviteAtomic rows: %w", err) + } + if rows == 0 { + return fmt.Errorf("UseInviteAtomic: invite not found, revoked, expired, or exhausted") + } + return nil +} + +// RevokeInvite marks an invite as revoked. +func (d *DB) RevokeInvite(code string) error { + _, err := d.sqlDB.Exec(`UPDATE invites SET revoked = 1 WHERE code = ?`, code) + if err != nil { + return fmt.Errorf("RevokeInvite: %w", err) + } + return nil +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +// MemberSummary is a lightweight user shape for the ready payload. +type MemberSummary struct { + ID int64 `json:"id"` + Username string `json:"username"` + Avatar *string `json:"avatar"` + Status string `json:"status"` + Role string `json:"role"` +} + +// ListMembers returns all non-banned users as lightweight summaries. +func (d *DB) ListMembers() ([]MemberSummary, error) { + rows, err := d.sqlDB.Query( + `SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) + FROM users u + JOIN roles r ON u.role_id = r.id + WHERE u.banned = 0 + ORDER BY u.username ASC`, + ) + if err != nil { + return nil, fmt.Errorf("ListMembers: %w", err) + } + defer rows.Close() //nolint:errcheck + + var members []MemberSummary + for rows.Next() { + var m MemberSummary + if err := rows.Scan(&m.ID, &m.Username, &m.Avatar, &m.Status, &m.Role); err != nil { + return nil, fmt.Errorf("ListMembers scan: %w", err) + } + members = append(members, m) + } + if rows.Err() != nil { + return nil, fmt.Errorf("ListMembers rows: %w", rows.Err()) + } + if members == nil { + members = []MemberSummary{} + } + return members, nil +} + +// generateInviteCode produces a random 8-byte (16-char hex) code. +func generateInviteCode() (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/Server/db/auth_queries_test.go b/Server/db/auth_queries_test.go new file mode 100644 index 00000000..11f31d1b --- /dev/null +++ b/Server/db/auth_queries_test.go @@ -0,0 +1,730 @@ +package db_test + +import ( + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/db" +) + +// newTestDB opens an in-memory SQLite database and runs migrations from the +// embedded FS so tests are fully self-contained. +func newTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + // Build a minimal migration FS with the initial schema. + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: testSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// testSchema mirrors the production migration but kept inline so tests are +// portable and don't depend on the real migrations embed. +var testSchema = []byte(` +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT, + permissions INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES + (1, 'Owner', '#E74C3C', 2147483647, 100, 0), + (2, 'Admin', '#F39C12', 1073741823, 80, 0), + (3, 'Moderator', '#3498DB', 1048575, 60, 0), + (4, 'Member', NULL, 1635, 40, 1); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + avatar TEXT, + role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, + status TEXT NOT NULL DEFAULT 'offline', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); + +CREATE TABLE IF NOT EXISTS invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + redeemed_by INTEGER REFERENCES users(id), + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); +`) + +// ─── User tests ────────────────────────────────────────────────────────────── + +func TestCreateUser_Success(t *testing.T) { + database := newTestDB(t) + id, err := database.CreateUser("alice", "hash123", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if id <= 0 { + t.Errorf("CreateUser returned id = %d, want > 0", id) + } +} + +func TestCreateUser_DuplicateUsername(t *testing.T) { + database := newTestDB(t) + if _, err := database.CreateUser("bob", "hash1", 4); err != nil { + t.Fatalf("first CreateUser: %v", err) + } + _, err := database.CreateUser("bob", "hash2", 4) + if err == nil { + t.Error("CreateUser() with duplicate username returned nil error, want error") + } +} + +func TestCreateUser_CaseInsensitiveDuplicate(t *testing.T) { + database := newTestDB(t) + if _, err := database.CreateUser("Charlie", "hash1", 4); err != nil { + t.Fatalf("first CreateUser: %v", err) + } + _, err := database.CreateUser("charlie", "hash2", 4) + if err == nil { + t.Error("CreateUser() with case-insensitive duplicate returned nil error, want error") + } +} + +func TestGetUserByUsername_Found(t *testing.T) { + database := newTestDB(t) + _, _ = database.CreateUser("dave", "hashDave", 4) + + user, err := database.GetUserByUsername("dave") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if user.Username != "dave" { + t.Errorf("Username = %q, want %q", user.Username, "dave") + } + if user.PasswordHash != "hashDave" { + t.Errorf("PasswordHash = %q, want %q", user.PasswordHash, "hashDave") + } +} + +func TestGetUserByUsername_CaseInsensitive(t *testing.T) { + database := newTestDB(t) + _, _ = database.CreateUser("Eve", "hashEve", 4) + + user, err := database.GetUserByUsername("EVE") + if err != nil { + t.Fatalf("GetUserByUsername case-insensitive: %v", err) + } + if user == nil { + t.Fatal("GetUserByUsername returned nil for case-insensitive match") + } +} + +func TestGetUserByUsername_NotFound(t *testing.T) { + database := newTestDB(t) + user, err := database.GetUserByUsername("nobody") + if err != nil { + t.Fatalf("GetUserByUsername(not found): %v", err) + } + if user != nil { + t.Error("GetUserByUsername returned non-nil for missing user") + } +} + +func TestGetUserByID_Found(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("frank", "hashFrank", 4) + + user, err := database.GetUserByID(id) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if user.ID != id { + t.Errorf("ID = %d, want %d", user.ID, id) + } +} + +func TestGetUserByID_NotFound(t *testing.T) { + database := newTestDB(t) + user, err := database.GetUserByID(999) + if err != nil { + t.Fatalf("GetUserByID(not found): %v", err) + } + if user != nil { + t.Error("GetUserByID returned non-nil for missing user") + } +} + +func TestUpdateUserStatus(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("grace", "hash", 4) + + if err := database.UpdateUserStatus(id, "online"); err != nil { + t.Fatalf("UpdateUserStatus: %v", err) + } + user, _ := database.GetUserByID(id) + if user.Status != "online" { + t.Errorf("Status = %q, want %q", user.Status, "online") + } +} + +func TestBanUser_Permanent(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("hank", "hash", 4) + + if err := database.BanUser(id, "spam", nil); err != nil { + t.Fatalf("BanUser: %v", err) + } + user, _ := database.GetUserByID(id) + if !user.Banned { + t.Error("Banned = false after BanUser, want true") + } + if user.BanExpires != nil { + t.Errorf("BanExpires = %v, want nil for permanent ban", user.BanExpires) + } +} + +func TestBanUser_Temporary(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("ivan", "hash", 4) + expires := time.Now().Add(24 * time.Hour) + + if err := database.BanUser(id, "temp ban", &expires); err != nil { + t.Fatalf("BanUser (temp): %v", err) + } + user, _ := database.GetUserByID(id) + if !user.Banned { + t.Error("Banned = false after temp ban") + } + if user.BanExpires == nil { + t.Error("BanExpires = nil for temp ban, want non-nil") + } +} + +// ─── Session tests ──────────────────────────────────────────────────────────── + +func TestCreateSession_Success(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("jack", "hash", 4) + + id, err := database.CreateSession(uid, "tokenHash1", "GoTest/1.0", "127.0.0.1") + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if id <= 0 { + t.Errorf("CreateSession id = %d, want > 0", id) + } +} + +func TestGetSessionByTokenHash_Found(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("kate", "hash", 4) + _, _ = database.CreateSession(uid, "myTokenHash", "GoTest/1.0", "127.0.0.1") + + sess, err := database.GetSessionByTokenHash("myTokenHash") + if err != nil { + t.Fatalf("GetSessionByTokenHash: %v", err) + } + if sess == nil { + t.Fatal("GetSessionByTokenHash returned nil for existing session") + } + if sess.UserID != uid { + t.Errorf("UserID = %d, want %d", sess.UserID, uid) + } +} + +func TestGetSessionByTokenHash_NotFound(t *testing.T) { + database := newTestDB(t) + sess, err := database.GetSessionByTokenHash("nonexistent") + if err != nil { + t.Fatalf("GetSessionByTokenHash(not found): %v", err) + } + if sess != nil { + t.Error("GetSessionByTokenHash returned non-nil for missing session") + } +} + +func TestGetSessionWithBanStatus_Found(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("zara", "hash", 4) + _, _ = database.CreateSession(uid, "banCheckToken", "GoTest/1.0", "127.0.0.1") + + result, err := database.GetSessionWithBanStatus("banCheckToken") + if err != nil { + t.Fatalf("GetSessionWithBanStatus: %v", err) + } + if result == nil { + t.Fatal("GetSessionWithBanStatus returned nil for existing session") + } + if result.UserID != uid { + t.Errorf("UserID = %d, want %d", result.UserID, uid) + } + if result.Banned { + t.Error("expected user not banned") + } +} + +func TestGetSessionWithBanStatus_BannedUser(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("banned-zara", "hash", 4) + _, _ = database.CreateSession(uid, "bannedToken", "GoTest/1.0", "127.0.0.1") + if err := database.BanUser(uid, "rule violation", nil); err != nil { + t.Fatalf("BanUser: %v", err) + } + + result, err := database.GetSessionWithBanStatus("bannedToken") + if err != nil { + t.Fatalf("GetSessionWithBanStatus: %v", err) + } + if result == nil { + t.Fatal("GetSessionWithBanStatus returned nil for existing session") + } + if !result.Banned { + t.Error("expected Banned = true for banned user") + } + if result.BanReason == nil || *result.BanReason != "rule violation" { + t.Errorf("BanReason = %v, want 'rule violation'", result.BanReason) + } +} + +func TestGetSessionWithBanStatus_NotFound(t *testing.T) { + database := newTestDB(t) + result, err := database.GetSessionWithBanStatus("nonexistent") + if err != nil { + t.Fatalf("GetSessionWithBanStatus(not found): %v", err) + } + if result != nil { + t.Error("GetSessionWithBanStatus returned non-nil for missing session") + } +} + +func TestDeleteSession(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("leo", "hash", 4) + _, _ = database.CreateSession(uid, "delToken", "GoTest/1.0", "127.0.0.1") + + if err := database.DeleteSession("delToken"); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + sess, _ := database.GetSessionByTokenHash("delToken") + if sess != nil { + t.Error("Session still exists after DeleteSession") + } +} + +func TestDeleteExpiredSessions(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("mia", "hash", 4) + + // Insert an already-expired session directly via Exec. + // Use SQLite datetime format (space separator) to match what datetime('now') produces. + pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05") + _, err := database.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, + uid, "expiredToken", "test", "127.0.0.1", pastTime, + ) + if err != nil { + t.Fatalf("inserting expired session: %v", err) + } + + // Insert a valid session through the normal path. + _, _ = database.CreateSession(uid, "validToken", "GoTest/1.0", "127.0.0.1") + + if err := database.DeleteExpiredSessions(); err != nil { + t.Fatalf("DeleteExpiredSessions: %v", err) + } + + expired, _ := database.GetSessionByTokenHash("expiredToken") + if expired != nil { + t.Error("Expired session still exists after DeleteExpiredSessions") + } + valid, _ := database.GetSessionByTokenHash("validToken") + if valid == nil { + t.Error("Valid session was deleted by DeleteExpiredSessions") + } +} + +func TestTouchSession(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("noah", "hash", 4) + _, _ = database.CreateSession(uid, "touchToken", "GoTest/1.0", "127.0.0.1") + + sess1, _ := database.GetSessionByTokenHash("touchToken") + time.Sleep(2 * time.Millisecond) + + if err := database.TouchSession("touchToken"); err != nil { + t.Fatalf("TouchSession: %v", err) + } + + sess2, _ := database.GetSessionByTokenHash("touchToken") + if sess1.LastUsed == sess2.LastUsed { + // last_used should have advanced; if they're equal the touch had no effect + // (This can be flaky at millisecond resolution, but is a reasonable sanity check.) + t.Log("TouchSession: last_used unchanged (may be a timing issue on fast machines)") + } +} + +// ─── Invite tests ───────────────────────────────────────────────────────────── + +func TestCreateInvite_Success(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("olivia", "hash", 4) + + code, err := database.CreateInvite(uid, 0, nil) + if err != nil { + t.Fatalf("CreateInvite: %v", err) + } + if len(code) == 0 { + t.Error("CreateInvite returned empty code") + } +} + +func TestGetInvite_Found(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("pedro", "hash", 4) + code, _ := database.CreateInvite(uid, 5, nil) + + inv, err := database.GetInvite(code) + if err != nil { + t.Fatalf("GetInvite: %v", err) + } + if inv == nil { + t.Fatal("GetInvite returned nil for existing code") + } + if inv.Code != code { + t.Errorf("Code = %q, want %q", inv.Code, code) + } + if inv.MaxUses == nil || *inv.MaxUses != 5 { + t.Errorf("MaxUses = %v, want 5", inv.MaxUses) + } +} + +func TestGetInvite_NotFound(t *testing.T) { + database := newTestDB(t) + inv, err := database.GetInvite("bogus") + if err != nil { + t.Fatalf("GetInvite(not found): %v", err) + } + if inv != nil { + t.Error("GetInvite returned non-nil for missing code") + } +} + +func TestRevokeInvite(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("uma", "hash", 4) + code, _ := database.CreateInvite(uid, 0, nil) + + if err := database.RevokeInvite(code); err != nil { + t.Fatalf("RevokeInvite: %v", err) + } + + inv, _ := database.GetInvite(code) + if !inv.Revoked { + t.Error("Revoked = false after RevokeInvite, want true") + } +} + +func TestCreateInvite_UnlimitedUses(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("vera", "hash", 4) + code, _ := database.CreateInvite(uid, 0, nil) // 0 = unlimited + + inv, _ := database.GetInvite(code) + if inv.MaxUses != nil { + t.Errorf("MaxUses = %v, want nil for unlimited", inv.MaxUses) + } +} + +// ─── UseInviteAtomic tests ───────────────────────────────────────────────────── + +// TestUseInviteAtomic_Success verifies a valid unlimited invite is accepted and +// its use_count incremented in one operation. +func TestUseInviteAtomic_Success(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("atomic_user1", "hash", 4) + code, _ := database.CreateInvite(uid, 0, nil) + + if err := database.UseInviteAtomic(code); err != nil { + t.Fatalf("UseInviteAtomic: %v", err) + } + + inv, _ := database.GetInvite(code) + if inv.Uses != 1 { + t.Errorf("Uses = %d, want 1", inv.Uses) + } +} + +// TestUseInviteAtomic_IncrementsUses verifies the count advances correctly over +// multiple sequential calls. +func TestUseInviteAtomic_IncrementsUses(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("atomic_user2", "hash", 4) + code, _ := database.CreateInvite(uid, 5, nil) + + for i := range 3 { + if err := database.UseInviteAtomic(code); err != nil { + t.Fatalf("UseInviteAtomic iteration %d: %v", i, err) + } + } + + inv, _ := database.GetInvite(code) + if inv.Uses != 3 { + t.Errorf("Uses = %d, want 3", inv.Uses) + } +} + +// TestUseInviteAtomic_Revoked returns an error for a revoked invite without +// modifying the database. +func TestUseInviteAtomic_Revoked(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("atomic_user3", "hash", 4) + code, _ := database.CreateInvite(uid, 0, nil) + _ = database.RevokeInvite(code) + + if err := database.UseInviteAtomic(code); err == nil { + t.Error("UseInviteAtomic returned nil error for revoked invite, want error") + } + + // use_count must not have changed. + inv, _ := database.GetInvite(code) + if inv.Uses != 0 { + t.Errorf("Uses = %d after revoked attempt, want 0", inv.Uses) + } +} + +// TestUseInviteAtomic_Expired returns an error for an expired invite. +func TestUseInviteAtomic_Expired(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("atomic_user4", "hash", 4) + + past := time.Now().Add(-time.Hour) + code, _ := database.CreateInvite(uid, 0, &past) + + if err := database.UseInviteAtomic(code); err == nil { + t.Error("UseInviteAtomic returned nil error for expired invite, want error") + } +} + +// TestUseInviteAtomic_ExceedsMaxUses returns an error when the invite has +// reached its maximum use count. +func TestUseInviteAtomic_ExceedsMaxUses(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("atomic_user5", "hash", 4) + code, _ := database.CreateInvite(uid, 1, nil) + + if err := database.UseInviteAtomic(code); err != nil { + t.Fatalf("UseInviteAtomic first use: %v", err) + } + if err := database.UseInviteAtomic(code); err == nil { + t.Error("UseInviteAtomic returned nil error after exceeding max_uses, want error") + } +} + +// TestUseInviteAtomic_NotFound returns an error for a completely unknown code. +func TestUseInviteAtomic_NotFound(t *testing.T) { + database := newTestDB(t) + + if err := database.UseInviteAtomic("doesnotexist"); err == nil { + t.Error("UseInviteAtomic returned nil error for unknown code, want error") + } +} + +// TestUseInviteAtomic_ConcurrentSameCode simulates two goroutines racing to +// redeem a single-use invite. Exactly one must succeed and exactly one must +// fail; the use_count must end up at 1. +func TestUseInviteAtomic_ConcurrentSameCode(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("atomic_user6", "hash", 4) + code, _ := database.CreateInvite(uid, 1, nil) + + type result struct{ err error } + results := make(chan result, 2) + + for range 2 { + go func() { + results <- result{err: database.UseInviteAtomic(code)} + }() + } + + r1, r2 := <-results, <-results + successes := 0 + if r1.err == nil { + successes++ + } + if r2.err == nil { + successes++ + } + if successes != 1 { + t.Errorf("concurrent redemptions: %d succeeded, want exactly 1", successes) + } + + inv, _ := database.GetInvite(code) + if inv.Uses != 1 { + t.Errorf("use_count = %d after concurrent race, want 1", inv.Uses) + } +} + +// ─── UnbanUser ────────────────────────────────────────────────────────────── + +func TestUnbanUser_ClearsBan(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("unban_target", "hash", 4) + + if err := database.BanUser(id, "spam", nil); err != nil { + t.Fatalf("BanUser: %v", err) + } + + user, _ := database.GetUserByID(id) + if !user.Banned { + t.Fatal("user should be banned before unban") + } + + if err := database.UnbanUser(id); err != nil { + t.Fatalf("UnbanUser: %v", err) + } + + user, _ = database.GetUserByID(id) + if user.Banned { + t.Error("Banned = true after UnbanUser, want false") + } + if user.BanReason != nil { + t.Errorf("BanReason = %v, want nil after UnbanUser", user.BanReason) + } + if user.BanExpires != nil { + t.Errorf("BanExpires = %v, want nil after UnbanUser", user.BanExpires) + } +} + +func TestUnbanUser_NonexistentUser(t *testing.T) { + database := newTestDB(t) + + // Unbanning nonexistent user should not error. + if err := database.UnbanUser(99999); err != nil { + t.Errorf("UnbanUser(nonexistent) error: %v", err) + } +} + +// ─── ResetAllUserStatuses ─────────────────────────────────────────────────── + +func TestResetAllUserStatuses(t *testing.T) { + database := newTestDB(t) + id1, _ := database.CreateUser("status_u1", "hash", 4) + id2, _ := database.CreateUser("status_u2", "hash", 4) + + _ = database.UpdateUserStatus(id1, "online") + _ = database.UpdateUserStatus(id2, "dnd") + + if err := database.ResetAllUserStatuses(); err != nil { + t.Fatalf("ResetAllUserStatuses: %v", err) + } + + u1, _ := database.GetUserByID(id1) + u2, _ := database.GetUserByID(id2) + if u1.Status != "offline" { + t.Errorf("user1 status = %q, want 'offline'", u1.Status) + } + if u2.Status != "offline" { + t.Errorf("user2 status = %q, want 'offline'", u2.Status) + } +} + +func TestResetAllUserStatuses_AlreadyOffline(t *testing.T) { + database := newTestDB(t) + _, _ = database.CreateUser("offline_user", "hash", 4) + + // Should not error when all users are already offline. + if err := database.ResetAllUserStatuses(); err != nil { + t.Errorf("ResetAllUserStatuses: %v", err) + } +} + +// ─── ListMembers ──────────────────────────────────────────────────────────── + +func TestListMembers_Empty(t *testing.T) { + database := newTestDB(t) + + members, err := database.ListMembers() + if err != nil { + t.Fatalf("ListMembers: %v", err) + } + if len(members) != 0 { + t.Errorf("ListMembers() = %d, want 0", len(members)) + } +} + +func TestListMembers_ExcludesBanned(t *testing.T) { + database := newTestDB(t) + id1, _ := database.CreateUser("member_visible", "hash", 4) + id2, _ := database.CreateUser("member_banned", "hash", 4) + _ = database.BanUser(id2, "test ban", nil) + _ = id1 // suppress unused + + members, err := database.ListMembers() + if err != nil { + t.Fatalf("ListMembers: %v", err) + } + if len(members) != 1 { + t.Fatalf("ListMembers() = %d, want 1 (banned excluded)", len(members)) + } + if members[0].Username != "member_visible" { + t.Errorf("Username = %q, want 'member_visible'", members[0].Username) + } + if members[0].Role == "" { + t.Error("Role should not be empty") + } +} + +func TestListMembers_SortedByUsername(t *testing.T) { + database := newTestDB(t) + _, _ = database.CreateUser("zeta_user", "hash", 4) + _, _ = database.CreateUser("alpha_user", "hash", 4) + _, _ = database.CreateUser("mid_user", "hash", 4) + + members, err := database.ListMembers() + if err != nil { + t.Fatalf("ListMembers: %v", err) + } + if len(members) != 3 { + t.Fatalf("ListMembers() = %d, want 3", len(members)) + } + if members[0].Username != "alpha_user" { + t.Errorf("first member = %q, want 'alpha_user' (sorted)", members[0].Username) + } + if members[2].Username != "zeta_user" { + t.Errorf("last member = %q, want 'zeta_user' (sorted)", members[2].Username) + } +} diff --git a/Server/db/backup_test.go b/Server/db/backup_test.go new file mode 100644 index 00000000..5c54d29a --- /dev/null +++ b/Server/db/backup_test.go @@ -0,0 +1,151 @@ +package db_test + +import ( + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/owncord/server/db" +) + +// newBackupTestDB opens a file-backed database suitable for VACUUM INTO tests. +// VACUUM INTO requires a file-backed source database; :memory: produces an +// empty-but-valid backup file which is sufficient for validation tests. +func newBackupFileDB(t *testing.T) (*db.DB, string) { + t.Helper() + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "source.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: adminTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database, tmpDir +} + +// ─── BackupToSafe path-validation tests ───────────────────────────────────── + +// TestBackupToSafe_ValidPath verifies a properly-named backup file is created. +func TestBackupToSafe_ValidPath(t *testing.T) { + database, tmpDir := newBackupFileDB(t) + + backupDir := filepath.Join(tmpDir, "backups") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + backupPath := filepath.Join(backupDir, "chatserver_20260315_120000.db") + if err := database.BackupToSafe(backupPath, backupDir); err != nil { + t.Fatalf("BackupToSafe() with valid path returned error: %v", err) + } + + info, err := os.Stat(backupPath) + if err != nil { + t.Fatalf("backup file does not exist after BackupToSafe: %v", err) + } + if info.Size() == 0 { + t.Error("backup file is empty, expected non-empty SQLite file") + } +} + +// TestBackupToSafe_RejectsPathOutsideRoot ensures a path outside the safe root +// is rejected. +func TestBackupToSafe_RejectsPathOutsideRoot(t *testing.T) { + database, tmpDir := newBackupFileDB(t) + + backupDir := filepath.Join(tmpDir, "backups") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + // Try to write outside backupDir + escapePath := filepath.Join(tmpDir, "escaped.db") + err := database.BackupToSafe(escapePath, backupDir) + if err == nil { + t.Error("BackupToSafe() should reject path outside safe root, got nil") + } +} + +// TestBackupToSafe_RejectsSingleQuote ensures a path containing a single-quote +// is rejected before the SQL is executed (prevents SQL injection). +func TestBackupToSafe_RejectsSingleQuote(t *testing.T) { + database, tmpDir := newBackupFileDB(t) + + backupDir := filepath.Join(tmpDir, "backups") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + malicious := filepath.Join(backupDir, "evil'.db") + err := database.BackupToSafe(malicious, backupDir) + if err == nil { + t.Error("BackupToSafe() with single-quote in path should return error, got nil") + } +} + +// TestBackupToSafe_RejectsSemicolon ensures a semicolon in the path is rejected. +func TestBackupToSafe_RejectsSemicolon(t *testing.T) { + database, tmpDir := newBackupFileDB(t) + + backupDir := filepath.Join(tmpDir, "backups") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + malicious := filepath.Join(backupDir, "evil;drop.db") + err := database.BackupToSafe(malicious, backupDir) + if err == nil { + t.Error("BackupToSafe() with semicolon in path should return error, got nil") + } +} + +// TestBackupToSafe_RejectsSQLComment ensures a path containing "--" is rejected. +func TestBackupToSafe_RejectsSQLComment(t *testing.T) { + database, tmpDir := newBackupFileDB(t) + + backupDir := filepath.Join(tmpDir, "backups") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + malicious := filepath.Join(backupDir, "evil--comment.db") + err := database.BackupToSafe(malicious, backupDir) + if err == nil { + t.Error("BackupToSafe() with '--' in path should return error, got nil") + } +} + +// TestBackupToSafe_RejectsNullByte ensures a path containing a null byte is rejected. +func TestBackupToSafe_RejectsNullByte(t *testing.T) { + database, tmpDir := newBackupFileDB(t) + + backupDir := filepath.Join(tmpDir, "backups") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + malicious := filepath.Join(backupDir, "evil\x00.db") + err := database.BackupToSafe(malicious, backupDir) + if err == nil { + t.Error("BackupToSafe() with null byte in path should return error, got nil") + } +} + +// TestBackupToSafe_RejectsDoubleQuote ensures a path containing a double-quote +// is rejected. +func TestBackupToSafe_RejectsDoubleQuote(t *testing.T) { + database, tmpDir := newBackupFileDB(t) + + backupDir := filepath.Join(tmpDir, "backups") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + malicious := filepath.Join(backupDir, `evil".db`) + err := database.BackupToSafe(malicious, backupDir) + if err == nil { + t.Error("BackupToSafe() with double-quote in path should return error, got nil") + } +} diff --git a/Server/db/channel_queries.go b/Server/db/channel_queries.go new file mode 100644 index 00000000..99a31cda --- /dev/null +++ b/Server/db/channel_queries.go @@ -0,0 +1,196 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" +) + +// ListChannels returns all channels ordered by position. +func (d *DB) ListChannels() ([]Channel, error) { + rows, err := d.sqlDB.Query( + `SELECT id, name, type, COALESCE(category,''), COALESCE(topic,''), + position, slow_mode, archived, created_at + FROM channels ORDER BY position ASC, id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("ListChannels: %w", err) + } + defer rows.Close() //nolint:errcheck + + var channels []Channel + for rows.Next() { + ch, scanErr := scanChannel(rows) + if scanErr != nil { + return nil, fmt.Errorf("ListChannels scan: %w", scanErr) + } + channels = append(channels, ch) + } + if rows.Err() != nil { + return nil, fmt.Errorf("ListChannels rows: %w", rows.Err()) + } + if channels == nil { + channels = []Channel{} + } + return channels, nil +} + +// GetChannel returns the channel with the given id, or nil if not found. +func (d *DB) GetChannel(id int64) (*Channel, error) { + row := d.sqlDB.QueryRow( + `SELECT id, name, type, COALESCE(category,''), COALESCE(topic,''), + position, slow_mode, archived, created_at, + COALESCE(voice_max_users, 0), + voice_quality, + mixing_threshold, + COALESCE(voice_max_video, 0) + FROM channels WHERE id = ?`, + id, + ) + ch := &Channel{} + var archived int + err := row.Scan( + &ch.ID, &ch.Name, &ch.Type, &ch.Category, &ch.Topic, + &ch.Position, &ch.SlowMode, &archived, &ch.CreatedAt, + &ch.VoiceMaxUsers, &ch.VoiceQuality, &ch.MixingThreshold, &ch.VoiceMaxVideo, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetChannel: %w", err) + } + ch.Archived = archived != 0 + return ch, nil +} + +// CreateChannel inserts a new channel and returns the assigned ID. +func (d *DB) CreateChannel(name, chanType, category, topic string, position int) (int64, error) { + res, err := d.sqlDB.Exec( + `INSERT INTO channels (name, type, category, topic, position) VALUES (?, ?, ?, ?, ?)`, + name, chanType, nullableString(category), nullableString(topic), position, + ) + if err != nil { + return 0, fmt.Errorf("CreateChannel: %w", err) + } + return res.LastInsertId() +} + +// UpdateChannel modifies name, topic, and slow_mode for the given channel. +func (d *DB) UpdateChannel(id int64, name, topic string, slowMode int) error { + _, err := d.sqlDB.Exec( + `UPDATE channels SET name = ?, topic = ?, slow_mode = ? WHERE id = ?`, + name, nullableString(topic), slowMode, id, + ) + if err != nil { + return fmt.Errorf("UpdateChannel: %w", err) + } + return nil +} + +// SetChannelSlowMode updates only the slow_mode field for the given channel. +func (d *DB) SetChannelSlowMode(id int64, slowMode int) error { + _, err := d.sqlDB.Exec( + `UPDATE channels SET slow_mode = ? WHERE id = ?`, + slowMode, id, + ) + if err != nil { + return fmt.Errorf("SetChannelSlowMode: %w", err) + } + return nil +} + +// SetChannelVoiceMaxUsers updates the voice_max_users field for the given channel. +func (d *DB) SetChannelVoiceMaxUsers(id int64, maxUsers int) error { + _, err := d.sqlDB.Exec(`UPDATE channels SET voice_max_users = ? WHERE id = ?`, maxUsers, id) + if err != nil { + return fmt.Errorf("SetChannelVoiceMaxUsers: %w", err) + } + return nil +} + +// DeleteChannel removes the channel row (cascades to messages, overrides, etc.). +func (d *DB) DeleteChannel(id int64) error { + _, err := d.sqlDB.Exec(`DELETE FROM channels WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("DeleteChannel: %w", err) + } + return nil +} + +// GetChannelPermissions returns the allow/deny override bits for a role on a +// channel. Returns (0, 0, nil) when no override exists. +func (d *DB) GetChannelPermissions(channelID, roleID int64) (allow, deny int64, err error) { + row := d.sqlDB.QueryRow( + `SELECT allow, deny FROM channel_overrides WHERE channel_id = ? AND role_id = ?`, + channelID, roleID, + ) + scanErr := row.Scan(&allow, &deny) + if errors.Is(scanErr, sql.ErrNoRows) { + return 0, 0, nil + } + if scanErr != nil { + return 0, 0, fmt.Errorf("GetChannelPermissions: %w", scanErr) + } + return allow, deny, nil +} + +// ChannelOverride holds the allow/deny permission bits for a single channel. +type ChannelOverride struct { + Allow int64 + Deny int64 +} + +// GetAllChannelPermissionsForRole returns all channel permission overrides for +// a role in a single query, keyed by channel ID. Eliminates N+1 queries when +// filtering channels by permission. +func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOverride, error) { + rows, err := d.sqlDB.Query( + `SELECT channel_id, allow, deny FROM channel_overrides WHERE role_id = ?`, + roleID, + ) + if err != nil { + return nil, fmt.Errorf("GetAllChannelPermissionsForRole: %w", err) + } + defer rows.Close() //nolint:errcheck + + result := make(map[int64]ChannelOverride) + for rows.Next() { + var chID int64 + var o ChannelOverride + if scanErr := rows.Scan(&chID, &o.Allow, &o.Deny); scanErr != nil { + return nil, fmt.Errorf("GetAllChannelPermissionsForRole scan: %w", scanErr) + } + result[chID] = o + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetAllChannelPermissionsForRole rows: %w", rows.Err()) + } + return result, nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// scanChannel scans a single channel row from *sql.Rows. +func scanChannel(rows *sql.Rows) (Channel, error) { + var ch Channel + var archived int + err := rows.Scan( + &ch.ID, &ch.Name, &ch.Type, &ch.Category, &ch.Topic, + &ch.Position, &ch.SlowMode, &archived, &ch.CreatedAt, + ) + if err != nil { + return Channel{}, err + } + ch.Archived = archived != 0 + return ch, nil +} + +// nullableString returns nil when s is empty, otherwise a pointer to s. +// Used so empty strings are stored as NULL in optional TEXT columns. +func nullableString(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/Server/db/channel_queries_test.go b/Server/db/channel_queries_test.go new file mode 100644 index 00000000..3f2fcbca --- /dev/null +++ b/Server/db/channel_queries_test.go @@ -0,0 +1,293 @@ +package db_test + +import ( + "testing" + + "github.com/owncord/server/db" +) + +// openMigratedMemory opens an in-memory DB and runs the full migration. +func openMigratedMemory(t *testing.T) *db.DB { + t.Helper() + database := openMemory(t) + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + return database +} + +// ─── ListChannels ───────────────────────────────────────────────────────────── + +func TestListChannels_Empty(t *testing.T) { + database := openMigratedMemory(t) + + channels, err := database.ListChannels() + if err != nil { + t.Fatalf("ListChannels() error: %v", err) + } + if len(channels) != 0 { + t.Errorf("expected 0 channels, got %d", len(channels)) + } +} + +func TestListChannels_ReturnsAll(t *testing.T) { + database := openMigratedMemory(t) + + if _, err := database.CreateChannel("general", "text", "", "General chat", 0); err != nil { + t.Fatalf("CreateChannel general: %v", err) + } + if _, err := database.CreateChannel("announcements", "text", "", "", 1); err != nil { + t.Fatalf("CreateChannel announcements: %v", err) + } + + channels, err := database.ListChannels() + if err != nil { + t.Fatalf("ListChannels() error: %v", err) + } + if len(channels) != 2 { + t.Errorf("expected 2 channels, got %d", len(channels)) + } +} + +// ─── GetChannel ─────────────────────────────────────────────────────────────── + +func TestGetChannel_NotFound(t *testing.T) { + database := openMigratedMemory(t) + + ch, err := database.GetChannel(9999) + if err != nil { + t.Fatalf("GetChannel() error: %v", err) + } + if ch != nil { + t.Error("expected nil for non-existent channel") + } +} + +func TestGetChannel_Found(t *testing.T) { + database := openMigratedMemory(t) + + id, err := database.CreateChannel("general", "text", "Public", "hello", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + ch, err := database.GetChannel(id) + if err != nil { + t.Fatalf("GetChannel: %v", err) + } + if ch == nil { + t.Fatal("expected channel, got nil") + } + if ch.Name != "general" { + t.Errorf("Name = %q, want 'general'", ch.Name) + } + if ch.Type != "text" { + t.Errorf("Type = %q, want 'text'", ch.Type) + } + if ch.Category != "Public" { + t.Errorf("Category = %q, want 'Public'", ch.Category) + } + if ch.Topic != "hello" { + t.Errorf("Topic = %q, want 'hello'", ch.Topic) + } + if ch.Position != 0 { + t.Errorf("Position = %d, want 0", ch.Position) + } +} + +// ─── CreateChannel ──────────────────────────────────────────────────────────── + +func TestCreateChannel_ReturnsID(t *testing.T) { + database := openMigratedMemory(t) + + id, err := database.CreateChannel("test", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if id <= 0 { + t.Errorf("expected positive ID, got %d", id) + } +} + +func TestCreateChannel_UniqueIDs(t *testing.T) { + database := openMigratedMemory(t) + + id1, _ := database.CreateChannel("ch1", "text", "", "", 0) + id2, _ := database.CreateChannel("ch2", "text", "", "", 1) + if id1 == id2 { + t.Error("expected different IDs for different channels") + } +} + +func TestCreateChannel_EmptyCategory(t *testing.T) { + database := openMigratedMemory(t) + + id, err := database.CreateChannel("nocategory", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel with empty category: %v", err) + } + ch, _ := database.GetChannel(id) + if ch.Category != "" { + t.Errorf("Category = %q, want ''", ch.Category) + } +} + +// ─── UpdateChannel ──────────────────────────────────────────────────────────── + +func TestUpdateChannel_ChangesNameAndTopic(t *testing.T) { + database := openMigratedMemory(t) + + id, _ := database.CreateChannel("old", "text", "", "old topic", 0) + + if err := database.UpdateChannel(id, "new", "new topic", 5); err != nil { + t.Fatalf("UpdateChannel: %v", err) + } + + ch, _ := database.GetChannel(id) + if ch.Name != "new" { + t.Errorf("Name = %q, want 'new'", ch.Name) + } + if ch.Topic != "new topic" { + t.Errorf("Topic = %q, want 'new topic'", ch.Topic) + } + if ch.SlowMode != 5 { + t.Errorf("SlowMode = %d, want 5", ch.SlowMode) + } +} + +func TestUpdateChannel_NonExistent(t *testing.T) { + database := openMigratedMemory(t) + // Should not error even for non-existent row (0 rows affected is still ok). + err := database.UpdateChannel(9999, "x", "y", 0) + if err != nil { + t.Errorf("UpdateChannel non-existent should not error: %v", err) + } +} + +// ─── DeleteChannel ──────────────────────────────────────────────────────────── + +func TestDeleteChannel_RemovesChannel(t *testing.T) { + database := openMigratedMemory(t) + + id, _ := database.CreateChannel("todelete", "text", "", "", 0) + + if err := database.DeleteChannel(id); err != nil { + t.Fatalf("DeleteChannel: %v", err) + } + + ch, err := database.GetChannel(id) + if err != nil { + t.Fatalf("GetChannel after delete: %v", err) + } + if ch != nil { + t.Error("expected nil after deletion") + } +} + +func TestDeleteChannel_NonExistent(t *testing.T) { + database := openMigratedMemory(t) + err := database.DeleteChannel(9999) + if err != nil { + t.Errorf("DeleteChannel non-existent should not error: %v", err) + } +} + +// ─── GetChannelPermissions ──────────────────────────────────────────────────── + +func TestGetChannelPermissions_Default(t *testing.T) { + database := openMigratedMemory(t) + + chID, _ := database.CreateChannel("perms", "text", "", "", 0) + + // No override set — should return 0, 0. + allow, deny, err := database.GetChannelPermissions(chID, 4) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("expected (0, 0), got (%d, %d)", allow, deny) + } +} + +func TestGetChannelPermissions_WithOverride(t *testing.T) { + database := openMigratedMemory(t) + + chID, _ := database.CreateChannel("perms2", "text", "", "", 0) + // Insert an override directly. + _, err := database.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`, + chID, 4, int64(0x400), int64(0x200), + ) + if err != nil { + t.Fatalf("insert override: %v", err) + } + + allow, deny, err := database.GetChannelPermissions(chID, 4) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0x400 { + t.Errorf("allow = %d, want 0x400", allow) + } + if deny != 0x200 { + t.Errorf("deny = %d, want 0x200", deny) + } +} + +// ─── SetChannelSlowMode ───────────────────────────────────────────────────── + +func TestSetChannelSlowMode(t *testing.T) { + database := openMigratedMemory(t) + chID, _ := database.CreateChannel("slowch", "text", "", "", 0) + + if err := database.SetChannelSlowMode(chID, 10); err != nil { + t.Fatalf("SetChannelSlowMode: %v", err) + } + + ch, _ := database.GetChannel(chID) + if ch.SlowMode != 10 { + t.Errorf("SlowMode = %d, want 10", ch.SlowMode) + } +} + +func TestSetChannelSlowMode_Zero(t *testing.T) { + database := openMigratedMemory(t) + chID, _ := database.CreateChannel("slowch2", "text", "", "", 0) + + _ = database.SetChannelSlowMode(chID, 30) + _ = database.SetChannelSlowMode(chID, 0) + + ch, _ := database.GetChannel(chID) + if ch.SlowMode != 0 { + t.Errorf("SlowMode = %d, want 0 (disabled)", ch.SlowMode) + } +} + +// ─── SetChannelVoiceMaxUsers ──────────────────────────────────────────────── + +func TestSetChannelVoiceMaxUsers(t *testing.T) { + database := openMigratedMemory(t) + chID, _ := database.CreateChannel("voicech", "voice", "", "", 0) + + if err := database.SetChannelVoiceMaxUsers(chID, 25); err != nil { + t.Fatalf("SetChannelVoiceMaxUsers: %v", err) + } + + ch, _ := database.GetChannel(chID) + if ch.VoiceMaxUsers != 25 { + t.Errorf("VoiceMaxUsers = %d, want 25", ch.VoiceMaxUsers) + } +} + +func TestSetChannelVoiceMaxUsers_Unlimited(t *testing.T) { + database := openMigratedMemory(t) + chID, _ := database.CreateChannel("voicech2", "voice", "", "", 0) + + _ = database.SetChannelVoiceMaxUsers(chID, 10) + _ = database.SetChannelVoiceMaxUsers(chID, 0) + + ch, _ := database.GetChannel(chID) + if ch.VoiceMaxUsers != 0 { + t.Errorf("VoiceMaxUsers = %d, want 0 (unlimited)", ch.VoiceMaxUsers) + } +} diff --git a/Server/db/db.go b/Server/db/db.go new file mode 100644 index 00000000..1bd66b6c --- /dev/null +++ b/Server/db/db.go @@ -0,0 +1,89 @@ +// Package db provides database access for the OwnCord server. +// It uses modernc.org/sqlite — a pure-Go SQLite driver requiring no CGO. +package db + +import ( + "database/sql" + "fmt" + + "github.com/owncord/server/migrations" + _ "modernc.org/sqlite" // register the sqlite3 driver +) + +// DB wraps *sql.DB and exposes the subset of methods needed by the server. +type DB struct { + sqlDB *sql.DB +} + +// Open opens (or creates) a SQLite database at path, enables WAL mode and +// foreign key enforcement, and returns a ready-to-use DB. +func Open(path string) (*DB, error) { + sqlDB, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("opening sqlite db: %w", err) + } + + // Verify the connection is actually usable. + if err := sqlDB.Ping(); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("pinging sqlite db: %w", err) + } + + // In-memory databases are per-connection in SQLite; pin to one connection + // so all callers share the same in-memory state. + if path == ":memory:" { + sqlDB.SetMaxOpenConns(1) + } + + // Enable WAL mode for better concurrent read performance. + if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL;"); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("enabling WAL mode: %w", err) + } + + // Enforce foreign key constraints. + if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON;"); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("enabling foreign keys: %w", err) + } + + return &DB{sqlDB: sqlDB}, nil +} + +// Migrate runs all SQL migration files from the embedded migrations FS in +// lexicographic order, applying each file exactly once. It delegates to +// MigrateFS (defined in migrate.go) which maintains the schema_versions +// tracking table. +func Migrate(database *DB) error { + return MigrateFS(database, migrations.FS) +} + +// Close releases the underlying database connection. +func (d *DB) Close() error { + return d.sqlDB.Close() +} + +// QueryRow executes a query that returns at most one row. +func (d *DB) QueryRow(query string, args ...any) *sql.Row { + return d.sqlDB.QueryRow(query, args...) +} + +// Exec executes a query that doesn't return rows. +func (d *DB) Exec(query string, args ...any) (sql.Result, error) { + return d.sqlDB.Exec(query, args...) +} + +// Query executes a query that returns multiple rows. +func (d *DB) Query(query string, args ...any) (*sql.Rows, error) { + return d.sqlDB.Query(query, args...) +} + +// Begin starts a database transaction. +func (d *DB) Begin() (*sql.Tx, error) { + return d.sqlDB.Begin() +} + +// SQLDb returns the underlying *sql.DB for cases requiring direct access. +func (d *DB) SQLDb() *sql.DB { + return d.sqlDB +} diff --git a/Server/db/db_test.go b/Server/db/db_test.go new file mode 100644 index 00000000..976dd2ce --- /dev/null +++ b/Server/db/db_test.go @@ -0,0 +1,472 @@ +package db_test + +import ( + "database/sql" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/db" +) + +// openMemory opens an in-memory database for testing. +func openMemory(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Open(':memory:') error: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return database +} + +func TestOpenInMemory(t *testing.T) { + database := openMemory(t) + if database == nil { + t.Fatal("Open returned nil DB") + } +} + +func TestOpenCreatesFile(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + defer database.Close() //nolint:errcheck + + if _, statErr := os.Stat(dbPath); os.IsNotExist(statErr) { + t.Error("Open() did not create the database file") + } +} + +func TestOpenInvalidPath(t *testing.T) { + // A path to a non-existent directory should return an error. + _, err := db.Open("/nonexistent/dir/that/does/not/exist/test.db") + if err == nil { + t.Error("Open() with invalid path should return error, got nil") + } +} + +func TestWALModeEnabled(t *testing.T) { + database := openMemory(t) + + var journalMode string + err := database.QueryRow("PRAGMA journal_mode;").Scan(&journalMode) + if err != nil { + t.Fatalf("PRAGMA journal_mode query error: %v", err) + } + // In-memory databases return "memory" even when WAL is requested, + // because WAL is not supported for in-memory DBs. File DBs return "wal". + // Accept both for in-memory test; the file-based test verifies WAL properly. + if journalMode != "memory" && journalMode != "wal" { + t.Errorf("journal_mode = %q, want 'wal' or 'memory'", journalMode) + } +} + +func TestWALModeEnabledOnFile(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "wal_test.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + defer database.Close() //nolint:errcheck + + var journalMode string + if err := database.QueryRow("PRAGMA journal_mode;").Scan(&journalMode); err != nil { + t.Fatalf("PRAGMA journal_mode query error: %v", err) + } + if journalMode != "wal" { + t.Errorf("journal_mode = %q, want 'wal'", journalMode) + } +} + +func TestForeignKeysEnabled(t *testing.T) { + database := openMemory(t) + + var fkEnabled int + if err := database.QueryRow("PRAGMA foreign_keys;").Scan(&fkEnabled); err != nil { + t.Fatalf("PRAGMA foreign_keys query error: %v", err) + } + if fkEnabled != 1 { + t.Errorf("foreign_keys = %d, want 1 (enabled)", fkEnabled) + } +} + +func TestMigrateCreatesAllTables(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + expectedTables := []string{ + "users", "sessions", "roles", "channels", "channel_overrides", + "messages", "attachments", "reactions", "invites", "read_states", + "audit_log", "login_attempts", "settings", "emoji", "sounds", + } + + for _, table := range expectedTables { + t.Run(table, func(t *testing.T) { + var name string + err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + table, + ).Scan(&name) + if err == sql.ErrNoRows { + t.Errorf("table %q not found after migration", table) + } else if err != nil { + t.Errorf("query error for table %q: %v", table, err) + } + }) + } +} + +func TestMigrateCreatesFTSTable(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + var name string + err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'", + ).Scan(&name) + if err == sql.ErrNoRows { + t.Error("messages_fts virtual table not found after migration") + } else if err != nil { + t.Errorf("query error: %v", err) + } +} + +func TestMigrateIsIdempotent(t *testing.T) { + database := openMemory(t) + + // Run migration twice — should not error. + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() first run error: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() second run error: %v", err) + } +} + +func TestMigrateInsertsDefaultRoles(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM roles").Scan(&count); err != nil { + t.Fatalf("COUNT roles error: %v", err) + } + if count < 4 { + t.Errorf("expected at least 4 default roles, got %d", count) + } +} + +func TestMigrateInsertsDefaultSettings(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + var value string + err := database.QueryRow("SELECT value FROM settings WHERE key='registration_open'").Scan(&value) + if err != nil { + t.Fatalf("settings query error: %v", err) + } + if value != "0" { + t.Errorf("registration_open = %q, want '0'", value) + } +} + +func TestMigrateCreatesIndexes(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + expectedIndexes := []string{ + "idx_sessions_token", + "idx_messages_channel", + "idx_invites_code", + "idx_audit_timestamp", + } + + for _, idx := range expectedIndexes { + t.Run(idx, func(t *testing.T) { + var name string + err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='index' AND name=?", + idx, + ).Scan(&name) + if err == sql.ErrNoRows { + t.Errorf("index %q not found after migration", idx) + } else if err != nil { + t.Errorf("query error for index %q: %v", idx, err) + } + }) + } +} + +func TestCloseIdempotent(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Open error: %v", err) + } + + if err := database.Close(); err != nil { + t.Errorf("Close() first call error: %v", err) + } +} + +func TestQueryRow(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + // Verify we can run a simple query via the exposed DB. + var schemaVersion string + err := database.QueryRow("SELECT value FROM settings WHERE key='schema_version'").Scan(&schemaVersion) + if err != nil { + t.Fatalf("QueryRow error: %v", err) + } + if schemaVersion == "" { + t.Error("schema_version should not be empty") + } +} + +func TestExec(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + // Insert a settings row using Exec. + _, err := database.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", "test_key", "test_val") + if err != nil { + t.Fatalf("Exec() error: %v", err) + } + + var val string + if err := database.QueryRow("SELECT value FROM settings WHERE key='test_key'").Scan(&val); err != nil { + t.Fatalf("QueryRow after Exec error: %v", err) + } + if val != "test_val" { + t.Errorf("value = %q, want 'test_val'", val) + } +} + +func TestQuery(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + rows, err := database.Query("SELECT key FROM settings") + if err != nil { + t.Fatalf("Query() error: %v", err) + } + defer rows.Close() //nolint:errcheck + + var count int + for rows.Next() { + count++ + var key string + if err := rows.Scan(&key); err != nil { + t.Fatalf("rows.Scan error: %v", err) + } + } + if count == 0 { + t.Error("Query() returned no rows from settings table") + } +} + +func TestBegin(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + tx, err := database.Begin() + if err != nil { + t.Fatalf("Begin() error: %v", err) + } + + _, err = tx.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('tx_key', 'tx_val')") + if err != nil { + _ = tx.Rollback() + t.Fatalf("tx.Exec error: %v", err) + } + + if err := tx.Rollback(); err != nil { + t.Fatalf("tx.Rollback error: %v", err) + } + + // After rollback, tx_key should not exist. + var val string + err = database.QueryRow("SELECT value FROM settings WHERE key='tx_key'").Scan(&val) + if err == nil { + t.Error("tx_key should not exist after rollback") + } +} + +func TestSQLDb(t *testing.T) { + database := openMemory(t) + sqlDB := database.SQLDb() + if sqlDB == nil { + t.Error("SQLDb() returned nil") + } +} + +// failReadFS implements fs.FS with a ReadDir that returns a file but +// ReadFile always errors — used to test the read-file error path in MigrateFS. +type failReadFS struct{} + +func (failReadFS) Open(name string) (fs.File, error) { + if name == "." { + return &fakeDir{}, nil + } + return nil, fmt.Errorf("read error for %s", name) +} + +type fakeDir struct{ pos int } + +func (d *fakeDir) Read([]byte) (int, error) { return 0, io.EOF } +func (d *fakeDir) Close() error { return nil } +func (d *fakeDir) Stat() (fs.FileInfo, error) { + return fakeDirInfo{}, nil +} +func (d *fakeDir) ReadDir(n int) ([]fs.DirEntry, error) { + if d.pos > 0 { + return nil, io.EOF + } + d.pos++ + return []fs.DirEntry{fakeDirEntry{}}, nil +} + +type fakeDirInfo struct{} + +func (fakeDirInfo) Name() string { return "." } +func (fakeDirInfo) Size() int64 { return 0 } +func (fakeDirInfo) Mode() fs.FileMode { return fs.ModeDir | 0o755 } +func (fakeDirInfo) ModTime() time.Time { return time.Time{} } +func (fakeDirInfo) IsDir() bool { return true } +func (fakeDirInfo) Sys() any { return nil } + +type fakeDirEntry struct{} + +func (fakeDirEntry) Name() string { return "001_fail.sql" } +func (fakeDirEntry) IsDir() bool { return false } +func (fakeDirEntry) Type() fs.FileMode { return 0 } +func (fakeDirEntry) Info() (fs.FileInfo, error) { return fakeFileInfo{}, nil } + +type fakeFileInfo struct{} + +func (fakeFileInfo) Name() string { return "001_fail.sql" } +func (fakeFileInfo) Size() int64 { return 0 } +func (fakeFileInfo) Mode() fs.FileMode { return 0o644 } +func (fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (fakeFileInfo) IsDir() bool { return false } +func (fakeFileInfo) Sys() any { return nil } + +func TestMigrateFSReadFileError(t *testing.T) { + database := openMemory(t) + + err := db.MigrateFS(database, failReadFS{}) + if err == nil { + t.Error("MigrateFS() should return error when ReadFile fails") + } +} + +func TestMigrateFSInvalidSQL(t *testing.T) { + database := openMemory(t) + + // Create an in-memory FS with invalid SQL to trigger an exec error. + badFS := fstest.MapFS{ + "001_bad.sql": &fstest.MapFile{ + Data: []byte("THIS IS NOT VALID SQL !!!@@@###"), + }, + } + + err := db.MigrateFS(database, badFS) + if err == nil { + t.Error("MigrateFS() should return error for invalid SQL, got nil") + } +} + +func TestMigrateFSSkipsNonSQL(t *testing.T) { + database := openMemory(t) + + // FS with non-.sql files should be skipped without error. + mixedFS := fstest.MapFS{ + "README.md": &fstest.MapFile{Data: []byte("not sql")}, + "001_ok.sql": &fstest.MapFile{ + Data: []byte("CREATE TABLE IF NOT EXISTS test_skip (id INTEGER PRIMARY KEY);"), + }, + } + + if err := db.MigrateFS(database, mixedFS); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + // The table from the .sql file should exist. + var name string + if err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name='test_skip'", + ).Scan(&name); err != nil { + t.Error("table test_skip not found after MigrateFS") + } +} + +func TestOpenPingFails(t *testing.T) { + // Providing a path in a non-existent directory should fail. + _, err := db.Open("/no/such/directory/db.sqlite") + if err == nil { + t.Error("Open() should fail for inaccessible path") + } +} + +func TestMigrateWALAndFKOnFile(t *testing.T) { + // Verify Open sets WAL and foreign_keys on a file-backed DB, then migrate. + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "migrate_test.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + defer database.Close() //nolint:errcheck + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + // Tables should exist. + var name string + if err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name='users'", + ).Scan(&name); err != nil { + t.Errorf("users table not found after migration on file db: %v", err) + } +} diff --git a/Server/db/invite_queries.go b/Server/db/invite_queries.go new file mode 100644 index 00000000..5265d137 --- /dev/null +++ b/Server/db/invite_queries.go @@ -0,0 +1,30 @@ +package db + +import "fmt" + +// ListInvites returns all invites ordered by creation time descending. +func (d *DB) ListInvites() ([]*Invite, error) { + rows, err := d.sqlDB.Query( + `SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at + FROM invites ORDER BY created_at DESC`, + ) + if err != nil { + return nil, fmt.Errorf("ListInvites: %w", err) + } + defer rows.Close() //nolint:errcheck + + var invites []*Invite + for rows.Next() { + inv := &Invite{} + var revoked int + if err := rows.Scan( + &inv.ID, &inv.Code, &inv.CreatedBy, &inv.MaxUses, + &inv.Uses, &inv.ExpiresAt, &revoked, &inv.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("ListInvites scan: %w", err) + } + inv.Revoked = revoked != 0 + invites = append(invites, inv) + } + return invites, rows.Err() +} diff --git a/Server/db/message_queries.go b/Server/db/message_queries.go new file mode 100644 index 00000000..4a4e7bd6 --- /dev/null +++ b/Server/db/message_queries.go @@ -0,0 +1,479 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// CreateMessage inserts a new message and returns the assigned ID. +// Content should already be sanitized before calling this function. +func (d *DB) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) { + res, err := d.sqlDB.Exec( + `INSERT INTO messages (channel_id, user_id, content, reply_to) VALUES (?, ?, ?, ?)`, + channelID, userID, content, replyTo, + ) + if err != nil { + return 0, fmt.Errorf("CreateMessage: %w", err) + } + return res.LastInsertId() +} + +// GetMessage returns the message with the given ID, or nil if not found. +// Soft-deleted messages are returned so callers can broadcast the deletion event. +func (d *DB) GetMessage(id int64) (*Message, error) { + row := d.sqlDB.QueryRow( + `SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp + FROM messages WHERE id = ?`, + id, + ) + return scanMessage(row) +} + +// GetMessages returns up to limit messages in a channel, ordered newest-first. +// When before > 0 only messages with id < before are returned (pagination). +func (d *DB) GetMessages(channelID, before int64, limit int) ([]MessageWithUser, error) { + var ( + rows *sql.Rows + err error + ) + if before > 0 { + rows, err = d.sqlDB.Query( + `SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, + m.edited_at, m.deleted, m.pinned, m.timestamp, + u.username, u.avatar + FROM messages m JOIN users u ON m.user_id = u.id + WHERE m.channel_id = ? AND m.id < ? AND m.deleted = 0 + ORDER BY m.id DESC LIMIT ?`, + channelID, before, limit, + ) + } else { + rows, err = d.sqlDB.Query( + `SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, + m.edited_at, m.deleted, m.pinned, m.timestamp, + u.username, u.avatar + FROM messages m JOIN users u ON m.user_id = u.id + WHERE m.channel_id = ? AND m.deleted = 0 + ORDER BY m.id DESC LIMIT ?`, + channelID, limit, + ) + } + if err != nil { + return nil, fmt.Errorf("GetMessages: %w", err) + } + defer rows.Close() //nolint:errcheck + + var msgs []MessageWithUser + for rows.Next() { + mwu, scanErr := scanMessageWithUser(rows) + if scanErr != nil { + return nil, fmt.Errorf("GetMessages scan: %w", scanErr) + } + msgs = append(msgs, mwu) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetMessages rows: %w", rows.Err()) + } + if msgs == nil { + msgs = []MessageWithUser{} + } + return msgs, nil +} + +// EditMessage updates the content and sets edited_at on the message. +// Returns an error if the message does not exist or userID does not match the owner. +func (d *DB) EditMessage(id, userID int64, content string) error { + msg, err := d.GetMessage(id) + if err != nil { + return err + } + if msg == nil { + return fmt.Errorf("EditMessage: message %d not found", id) + } + if msg.UserID != userID { + return fmt.Errorf("EditMessage: user %d does not own message %d", userID, id) + } + + _, err = d.sqlDB.Exec( + `UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ?`, + content, id, + ) + if err != nil { + return fmt.Errorf("EditMessage: %w", err) + } + return nil +} + +// DeleteMessage performs a soft delete (sets deleted=1) on the message. +// The calling user must be the message owner or ismod must be true. +func (d *DB) DeleteMessage(id, userID int64, ismod bool) error { + msg, err := d.GetMessage(id) + if err != nil { + return err + } + if msg == nil { + return fmt.Errorf("DeleteMessage: message %d not found", id) + } + if !ismod && msg.UserID != userID { + return fmt.Errorf("DeleteMessage: user %d does not own message %d", userID, id) + } + + _, err = d.sqlDB.Exec(`UPDATE messages SET deleted = 1 WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("DeleteMessage: %w", err) + } + return nil +} + +// AddReaction inserts a reaction. Returns an error on duplicate (same user+emoji+message). +func (d *DB) AddReaction(messageID, userID int64, emoji string) error { + _, err := d.sqlDB.Exec( + `INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)`, + messageID, userID, emoji, + ) + if err != nil { + return fmt.Errorf("AddReaction: %w", err) + } + return nil +} + +// RemoveReaction deletes a reaction. Returns an error if it does not exist. +func (d *DB) RemoveReaction(messageID, userID int64, emoji string) error { + res, err := d.sqlDB.Exec( + `DELETE FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?`, + messageID, userID, emoji, + ) + if err != nil { + return fmt.Errorf("RemoveReaction: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("RemoveReaction: reaction not found") + } + return nil +} + +// GetReactions returns aggregated reaction counts for a message. +// MeReacted is always false here (caller passes requesting userID if needed). +func (d *DB) GetReactions(messageID int64) ([]ReactionCount, error) { + rows, err := d.sqlDB.Query( + `SELECT emoji, COUNT(*) FROM reactions WHERE message_id = ? GROUP BY emoji`, + messageID, + ) + if err != nil { + return nil, fmt.Errorf("GetReactions: %w", err) + } + defer rows.Close() //nolint:errcheck + + var counts []ReactionCount + for rows.Next() { + var rc ReactionCount + if scanErr := rows.Scan(&rc.Emoji, &rc.Count); scanErr != nil { + return nil, fmt.Errorf("GetReactions scan: %w", scanErr) + } + counts = append(counts, rc) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetReactions rows: %w", rows.Err()) + } + if counts == nil { + counts = []ReactionCount{} + } + return counts, nil +} + +// SearchMessages performs a full-text search against the messages_fts virtual table. +// When channelID is non-nil the search is scoped to that channel. +// Deleted messages are excluded from results. +func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]MessageSearchResult, error) { + if query == "" { + return []MessageSearchResult{}, nil + } + if limit < 1 { + return []MessageSearchResult{}, nil + } + + var ( + rows *sql.Rows + err error + ) + + if channelID != nil { + rows, err = d.sqlDB.Query( + `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp + FROM messages_fts f + JOIN messages m ON f.rowid = m.id + JOIN channels c ON m.channel_id = c.id + JOIN users u ON m.user_id = u.id + WHERE messages_fts MATCH ? AND m.channel_id = ? AND m.deleted = 0 + ORDER BY rank LIMIT ?`, + query, *channelID, limit, + ) + } else { + rows, err = d.sqlDB.Query( + `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp + FROM messages_fts f + JOIN messages m ON f.rowid = m.id + JOIN channels c ON m.channel_id = c.id + JOIN users u ON m.user_id = u.id + WHERE messages_fts MATCH ? AND m.deleted = 0 + ORDER BY rank LIMIT ?`, + query, limit, + ) + } + if err != nil { + return nil, fmt.Errorf("SearchMessages: %w", err) + } + defer rows.Close() //nolint:errcheck + + var results []MessageSearchResult + for rows.Next() { + var r MessageSearchResult + if scanErr := rows.Scan(&r.MessageID, &r.ChannelID, &r.ChannelName, + &r.User.ID, &r.User.Username, &r.User.Avatar, + &r.Content, &r.Timestamp); scanErr != nil { + return nil, fmt.Errorf("SearchMessages scan: %w", scanErr) + } + results = append(results, r) + } + if rows.Err() != nil { + return nil, fmt.Errorf("SearchMessages rows: %w", rows.Err()) + } + if results == nil { + results = []MessageSearchResult{} + } + return results, nil +} + +// GetMessagesForAPI returns messages in the API.md response shape, including +// user object, reactions (with me flag), and attachments. +func (d *DB) GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]MessageAPIResponse, error) { + var ( + rows *sql.Rows + err error + ) + if before > 0 { + rows, err = d.sqlDB.Query( + `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, + m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp + FROM messages m JOIN users u ON m.user_id = u.id + WHERE m.channel_id = ? AND m.id < ? AND m.deleted = 0 + ORDER BY m.id DESC LIMIT ?`, + channelID, before, limit, + ) + } else { + rows, err = d.sqlDB.Query( + `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, + m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp + FROM messages m JOIN users u ON m.user_id = u.id + WHERE m.channel_id = ? AND m.deleted = 0 + ORDER BY m.id DESC LIMIT ?`, + channelID, limit, + ) + } + if err != nil { + return nil, fmt.Errorf("GetMessagesForAPI: %w", err) + } + defer rows.Close() //nolint:errcheck + + var msgs []MessageAPIResponse + var msgIDs []int64 + for rows.Next() { + var m MessageAPIResponse + var deleted, pinned int + if scanErr := rows.Scan( + &m.ID, &m.ChannelID, &m.User.ID, &m.User.Username, &m.User.Avatar, + &m.Content, &m.ReplyTo, &m.EditedAt, &deleted, &pinned, &m.Timestamp, + ); scanErr != nil { + return nil, fmt.Errorf("GetMessagesForAPI scan: %w", scanErr) + } + m.Deleted = deleted != 0 + m.Pinned = pinned != 0 + m.Attachments = []AttachmentInfo{} + m.Reactions = []ReactionInfo{} + msgs = append(msgs, m) + msgIDs = append(msgIDs, m.ID) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetMessagesForAPI rows: %w", rows.Err()) + } + if msgs == nil { + return []MessageAPIResponse{}, nil + } + + // Batch-fetch reactions for all message IDs. + reactMap, err := d.getReactionsBatch(msgIDs, requestingUserID) + if err != nil { + return nil, fmt.Errorf("GetMessagesForAPI reactions: %w", err) + } + for i := range msgs { + if r, ok := reactMap[msgs[i].ID]; ok { + msgs[i].Reactions = r + } + } + + // Batch-fetch attachments for all message IDs. + attMap, err := d.GetAttachmentsByMessageIDs(msgIDs) + if err != nil { + return nil, fmt.Errorf("GetMessagesForAPI attachments: %w", err) + } + for i := range msgs { + if a, ok := attMap[msgs[i].ID]; ok { + msgs[i].Attachments = a + } + } + + return msgs, nil +} + +// getReactionsBatch returns aggregated reactions for multiple messages. +func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int64][]ReactionInfo, error) { + if len(msgIDs) == 0 { + return map[int64][]ReactionInfo{}, nil + } + + // Build placeholders for IN clause. + args := make([]any, 0, len(msgIDs)+1) + var sb strings.Builder + for i, id := range msgIDs { + if i > 0 { + sb.WriteByte(',') + } + sb.WriteByte('?') + args = append(args, id) + } + placeholders := sb.String() + + // Query: aggregate count + check if requesting user reacted. + query := fmt.Sprintf( + `SELECT r.message_id, r.emoji, COUNT(*) as cnt, + MAX(CASE WHEN r.user_id = ? THEN 1 ELSE 0 END) as me + FROM reactions r + WHERE r.message_id IN (%s) + GROUP BY r.message_id, r.emoji`, + placeholders, + ) + args = append([]any{requestingUserID}, args...) + + rows, err := d.sqlDB.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("getReactionsBatch: %w", err) + } + defer rows.Close() //nolint:errcheck + + result := make(map[int64][]ReactionInfo) + for rows.Next() { + var msgID int64 + var ri ReactionInfo + var me int + if scanErr := rows.Scan(&msgID, &ri.Emoji, &ri.Count, &me); scanErr != nil { + return nil, fmt.Errorf("getReactionsBatch scan: %w", scanErr) + } + ri.Me = me != 0 + result[msgID] = append(result[msgID], ri) + } + if rows.Err() != nil { + return nil, fmt.Errorf("getReactionsBatch rows: %w", rows.Err()) + } + return result, nil +} + +// UpdateReadState upserts the read state for a user in a channel. +func (d *DB) UpdateReadState(userID, channelID, lastReadMessageID int64) error { + _, err := d.sqlDB.Exec( + `INSERT INTO read_states (user_id, channel_id, last_message_id) + VALUES (?, ?, ?) + ON CONFLICT(user_id, channel_id) DO UPDATE SET last_message_id = excluded.last_message_id`, + userID, channelID, lastReadMessageID, + ) + if err != nil { + return fmt.Errorf("UpdateReadState: %w", err) + } + return nil +} + +// GetChannelUnreadCounts returns per-channel unread counts and last message IDs +// for a given user. Only text channels with at least one message are included. +func (d *DB) GetChannelUnreadCounts(userID int64) (map[int64]ChannelUnread, error) { + rows, err := d.sqlDB.Query( + `SELECT c.id, + COALESCE(MAX(m.id), 0) AS last_msg_id, + COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = 0 THEN 1 END) AS unread + FROM channels c + LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = 0 + LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = ? + WHERE c.type = 'text' + GROUP BY c.id`, + userID, + ) + if err != nil { + return nil, fmt.Errorf("GetChannelUnreadCounts: %w", err) + } + defer rows.Close() //nolint:errcheck + + result := make(map[int64]ChannelUnread) + for rows.Next() { + var chID int64 + var cu ChannelUnread + if scanErr := rows.Scan(&chID, &cu.LastMessageID, &cu.UnreadCount); scanErr != nil { + return nil, fmt.Errorf("GetChannelUnreadCounts scan: %w", scanErr) + } + result[chID] = cu + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetChannelUnreadCounts rows: %w", rows.Err()) + } + return result, nil +} + +// GetLatestMessageID returns the highest message ID in a channel, or 0 if empty. +func (d *DB) GetLatestMessageID(channelID int64) (int64, error) { + var id int64 + err := d.sqlDB.QueryRow( + `SELECT COALESCE(MAX(id), 0) FROM messages WHERE channel_id = ? AND deleted = 0`, + channelID, + ).Scan(&id) + if err != nil { + return 0, fmt.Errorf("GetLatestMessageID: %w", err) + } + return id, nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// scanMessage scans a single message from *sql.Row. +func scanMessage(row *sql.Row) (*Message, error) { + m := &Message{} + var deleted, pinned int + err := row.Scan( + &m.ID, &m.ChannelID, &m.UserID, &m.Content, &m.ReplyTo, + &m.EditedAt, &deleted, &pinned, &m.Timestamp, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scanMessage: %w", err) + } + m.Deleted = deleted != 0 + m.Pinned = pinned != 0 + return m, nil +} + +// scanMessageWithUser scans a MessageWithUser from *sql.Rows. +func scanMessageWithUser(rows *sql.Rows) (MessageWithUser, error) { + var mwu MessageWithUser + var deleted, pinned int + err := rows.Scan( + &mwu.ID, &mwu.ChannelID, &mwu.UserID, &mwu.Content, &mwu.ReplyTo, + &mwu.EditedAt, &deleted, &pinned, &mwu.Timestamp, + &mwu.Username, &mwu.Avatar, + ) + if err != nil { + return MessageWithUser{}, err + } + mwu.Deleted = deleted != 0 + mwu.Pinned = pinned != 0 + return mwu, nil +} diff --git a/Server/db/message_queries_test.go b/Server/db/message_queries_test.go new file mode 100644 index 00000000..61ec9027 --- /dev/null +++ b/Server/db/message_queries_test.go @@ -0,0 +1,723 @@ +package db_test + +import ( + "testing" + + "github.com/owncord/server/db" +) + +// seedUser inserts a minimal test user and returns its ID. +func seedUser(t *testing.T, database *db.DB, username string) int64 { + t.Helper() + id, err := database.CreateUser(username, "hash", 4) + if err != nil { + t.Fatalf("seedUser(%q): %v", username, err) + } + return id +} + +// seedChannel inserts a minimal test channel and returns its ID. +func seedChannel(t *testing.T, database *db.DB, name string) int64 { + t.Helper() + id, err := database.CreateChannel(name, "text", "", "", 0) + if err != nil { + t.Fatalf("seedChannel(%q): %v", name, err) + } + return id +} + +// ─── CreateMessage ──────────────────────────────────────────────────────────── + +func TestCreateMessage_ReturnsID(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "alice") + chID := seedChannel(t, database, "general") + + id, err := database.CreateMessage(chID, userID, "hello", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if id <= 0 { + t.Errorf("expected positive ID, got %d", id) + } +} + +func TestCreateMessage_WithReplyTo(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "alice") + chID := seedChannel(t, database, "general") + + parentID, _ := database.CreateMessage(chID, userID, "parent", nil) + replyID, err := database.CreateMessage(chID, userID, "reply", &parentID) + if err != nil { + t.Fatalf("CreateMessage with reply: %v", err) + } + + msg, _ := database.GetMessage(replyID) + if msg.ReplyTo == nil || *msg.ReplyTo != parentID { + t.Errorf("ReplyTo = %v, want %d", msg.ReplyTo, parentID) + } +} + +func TestCreateMessage_ContentPreserved(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "bob") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(chID, userID, "test content", nil) + msg, _ := database.GetMessage(id) + if msg.Content != "test content" { + t.Errorf("Content = %q, want 'test content'", msg.Content) + } +} + +// ─── GetMessage ─────────────────────────────────────────────────────────────── + +func TestGetMessage_NotFound(t *testing.T) { + database := openMigratedMemory(t) + + msg, err := database.GetMessage(9999) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg != nil { + t.Error("expected nil for non-existent message") + } +} + +func TestGetMessage_Fields(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "carol") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(chID, userID, "hello world", nil) + + msg, err := database.GetMessage(id) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg == nil { + t.Fatal("expected message, got nil") + } + if msg.ChannelID != chID { + t.Errorf("ChannelID = %d, want %d", msg.ChannelID, chID) + } + if msg.UserID != userID { + t.Errorf("UserID = %d, want %d", msg.UserID, userID) + } + if msg.Deleted { + t.Error("expected Deleted=false for new message") + } + if msg.Pinned { + t.Error("expected Pinned=false for new message") + } + if msg.EditedAt != nil { + t.Error("expected EditedAt=nil for new message") + } +} + +// ─── GetMessages ────────────────────────────────────────────────────────────── + +func TestGetMessages_EmptyChannel(t *testing.T) { + database := openMigratedMemory(t) + chID := seedChannel(t, database, "empty") + + msgs, err := database.GetMessages(chID, 0, 50) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(msgs) != 0 { + t.Errorf("expected 0 messages, got %d", len(msgs)) + } +} + +func TestGetMessages_ReturnsMessages(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "dave") + chID := seedChannel(t, database, "ch") + + for i := range 3 { + _, err := database.CreateMessage(chID, userID, "msg", nil) + if err != nil { + t.Fatalf("CreateMessage %d: %v", i, err) + } + } + + msgs, err := database.GetMessages(chID, 0, 50) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(msgs) != 3 { + t.Errorf("expected 3 messages, got %d", len(msgs)) + } +} + +func TestGetMessages_LimitRespected(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "eve") + chID := seedChannel(t, database, "ch") + + for range 10 { + _, _ = database.CreateMessage(chID, userID, "msg", nil) + } + + msgs, _ := database.GetMessages(chID, 0, 5) + if len(msgs) != 5 { + t.Errorf("expected 5 messages (limit), got %d", len(msgs)) + } +} + +func TestGetMessages_BeforePagination(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "frank") + chID := seedChannel(t, database, "ch") + + var ids []int64 + for range 5 { + id, _ := database.CreateMessage(chID, userID, "msg", nil) + ids = append(ids, id) + } + + // Get messages before the 4th message (should get 3 messages: ids 0,1,2). + msgs, _ := database.GetMessages(chID, ids[3], 50) + if len(msgs) != 3 { + t.Errorf("expected 3 messages before id %d, got %d", ids[3], len(msgs)) + } +} + +func TestGetMessages_IncludesUsername(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "grace") + chID := seedChannel(t, database, "ch") + + _, _ = database.CreateMessage(chID, userID, "hi", nil) + msgs, _ := database.GetMessages(chID, 0, 50) + + if len(msgs) == 0 { + t.Fatal("expected messages") + } + if msgs[0].Username != "grace" { + t.Errorf("Username = %q, want 'grace'", msgs[0].Username) + } +} + +// ─── EditMessage ────────────────────────────────────────────────────────────── + +func TestEditMessage_OwnerCanEdit(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "henry") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(chID, userID, "original", nil) + + if err := database.EditMessage(id, userID, "updated"); err != nil { + t.Fatalf("EditMessage: %v", err) + } + + msg, _ := database.GetMessage(id) + if msg.Content != "updated" { + t.Errorf("Content = %q, want 'updated'", msg.Content) + } + if msg.EditedAt == nil { + t.Error("EditedAt should be set after edit") + } +} + +func TestEditMessage_NonOwnerCannotEdit(t *testing.T) { + database := openMigratedMemory(t) + ownerID := seedUser(t, database, "ivan") + otherID := seedUser(t, database, "julia") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(chID, ownerID, "original", nil) + + err := database.EditMessage(id, otherID, "hacked") + if err == nil { + t.Error("EditMessage by non-owner should return error") + } +} + +func TestEditMessage_NotFound(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "kim") + + err := database.EditMessage(9999, userID, "x") + if err == nil { + t.Error("EditMessage non-existent should return error") + } +} + +// ─── DeleteMessage ──────────────────────────────────────────────────────────── + +func TestDeleteMessage_OwnerCanDelete(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "larry") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(chID, userID, "bye", nil) + + if err := database.DeleteMessage(id, userID, false); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + + msg, _ := database.GetMessage(id) + if msg == nil { + t.Fatal("soft-deleted message should still exist in DB") + } + if !msg.Deleted { + t.Error("expected Deleted=true after soft delete") + } +} + +func TestDeleteMessage_ContentPreservedAfterSoftDelete(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "mia") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(chID, userID, "sensitive", nil) + _ = database.DeleteMessage(id, userID, false) + + msg, _ := database.GetMessage(id) + // Content preserved for broadcast (soft delete only flags deleted=1). + if msg.Content == "" { + t.Error("content should be preserved on soft delete for broadcast purposes") + } +} + +func TestDeleteMessage_NonOwnerBlockedWithoutMod(t *testing.T) { + database := openMigratedMemory(t) + ownerID := seedUser(t, database, "nate") + otherID := seedUser(t, database, "olivia") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(chID, ownerID, "msg", nil) + + err := database.DeleteMessage(id, otherID, false) + if err == nil { + t.Error("DeleteMessage by non-owner non-mod should return error") + } +} + +func TestDeleteMessage_ModCanDeleteAny(t *testing.T) { + database := openMigratedMemory(t) + ownerID := seedUser(t, database, "pete") + modID := seedUser(t, database, "quinn") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(chID, ownerID, "msg", nil) + + if err := database.DeleteMessage(id, modID, true); err != nil { + t.Fatalf("DeleteMessage by mod: %v", err) + } + + msg, _ := database.GetMessage(id) + if !msg.Deleted { + t.Error("expected Deleted=true after mod delete") + } +} + +func TestDeleteMessage_NotFound(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "rachel") + + err := database.DeleteMessage(9999, userID, true) + if err == nil { + t.Error("DeleteMessage non-existent should return error") + } +} + +// ─── Reactions ──────────────────────────────────────────────────────────────── + +func TestAddReaction_Success(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "sam") + chID := seedChannel(t, database, "ch") + msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + + if err := database.AddReaction(msgID, userID, "👍"); err != nil { + t.Fatalf("AddReaction: %v", err) + } +} + +func TestAddReaction_UniqueConstraint(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "tina") + chID := seedChannel(t, database, "ch") + msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + + _ = database.AddReaction(msgID, userID, "❤️") + err := database.AddReaction(msgID, userID, "❤️") + if err == nil { + t.Error("adding duplicate reaction should return error") + } +} + +func TestRemoveReaction_Success(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "uma") + chID := seedChannel(t, database, "ch") + msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + + _ = database.AddReaction(msgID, userID, "😂") + if err := database.RemoveReaction(msgID, userID, "😂"); err != nil { + t.Fatalf("RemoveReaction: %v", err) + } +} + +func TestRemoveReaction_NotFound(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "victor") + chID := seedChannel(t, database, "ch") + msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + + err := database.RemoveReaction(msgID, userID, "🔥") + if err == nil { + t.Error("removing non-existent reaction should return error") + } +} + +func TestGetReactions_Empty(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "wendy") + chID := seedChannel(t, database, "ch") + msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + + counts, err := database.GetReactions(msgID) + if err != nil { + t.Fatalf("GetReactions: %v", err) + } + if len(counts) != 0 { + t.Errorf("expected 0 reactions, got %d", len(counts)) + } +} + +func TestGetReactions_Counts(t *testing.T) { + database := openMigratedMemory(t) + u1 := seedUser(t, database, "xavier") + u2 := seedUser(t, database, "yvonne") + chID := seedChannel(t, database, "ch") + msgID, _ := database.CreateMessage(chID, u1, "hi", nil) + + _ = database.AddReaction(msgID, u1, "👍") + _ = database.AddReaction(msgID, u2, "👍") + _ = database.AddReaction(msgID, u1, "❤️") + + counts, _ := database.GetReactions(msgID) + if len(counts) != 2 { + t.Fatalf("expected 2 emoji types, got %d", len(counts)) + } + for _, rc := range counts { + switch rc.Emoji { + case "👍": + if rc.Count != 2 { + t.Errorf("👍 count = %d, want 2", rc.Count) + } + case "❤️": + if rc.Count != 1 { + t.Errorf("❤️ count = %d, want 1", rc.Count) + } + default: + t.Errorf("unexpected emoji %q", rc.Emoji) + } + } +} + +// ─── SearchMessages ─────────────────────────────────────────────────────────── + +func TestSearchMessages_FindsMatch(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "zara") + chID := seedChannel(t, database, "searchch") + + _, _ = database.CreateMessage(chID, userID, "hello world fts test", nil) + _, _ = database.CreateMessage(chID, userID, "unrelated content here", nil) + + results, err := database.SearchMessages("hello", nil, 10) + if err != nil { + t.Fatalf("SearchMessages: %v", err) + } + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + } + if results[0].Content != "hello world fts test" { + t.Errorf("Content = %q, want 'hello world fts test'", results[0].Content) + } +} + +func TestSearchMessages_FilterByChannel(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "adam") + ch1 := seedChannel(t, database, "ch1") + ch2 := seedChannel(t, database, "ch2") + + _, _ = database.CreateMessage(ch1, userID, "needle in channel 1", nil) + _, _ = database.CreateMessage(ch2, userID, "needle in channel 2", nil) + + results, _ := database.SearchMessages("needle", &ch1, 10) + if len(results) != 1 { + t.Errorf("expected 1 result in ch1, got %d", len(results)) + } + if results[0].ChannelID != ch1 { + t.Errorf("ChannelID = %d, want %d", results[0].ChannelID, ch1) + } +} + +func TestSearchMessages_NoResults(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "beth") + chID := seedChannel(t, database, "ch") + _, _ = database.CreateMessage(chID, userID, "hello there", nil) + + results, _ := database.SearchMessages("xyzzy", nil, 10) + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } +} + +func TestSearchMessages_LimitRespected(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "carl") + chID := seedChannel(t, database, "ch") + + for range 5 { + _, _ = database.CreateMessage(chID, userID, "searchable keyword content", nil) + } + + results, _ := database.SearchMessages("keyword", nil, 3) + if len(results) != 3 { + t.Errorf("expected 3 results (limit), got %d", len(results)) + } +} + +func TestSearchMessages_DeletedNotReturned(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "diana") + chID := seedChannel(t, database, "ch") + + id, _ := database.CreateMessage(chID, userID, "vanishing keyword message", nil) + _ = database.DeleteMessage(id, userID, false) + + results, _ := database.SearchMessages("vanishing", nil, 10) + if len(results) != 0 { + t.Errorf("expected 0 results (deleted excluded), got %d", len(results)) + } +} + +// ─── UpdateReadState ────────────────────────────────────────────────────────── + +func TestUpdateReadState_Upsert(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "ella") + chID := seedChannel(t, database, "ch") + msgID, _ := database.CreateMessage(chID, userID, "msg", nil) + + if err := database.UpdateReadState(userID, chID, msgID); err != nil { + t.Fatalf("UpdateReadState: %v", err) + } + + // Update again with higher message ID — should not error. + msgID2, _ := database.CreateMessage(chID, userID, "msg2", nil) + if err := database.UpdateReadState(userID, chID, msgID2); err != nil { + t.Fatalf("UpdateReadState second call: %v", err) + } +} + +// ─── GetMessagesForAPI ────────────────────────────────────────────────────── + +func TestGetMessagesForAPI_Empty(t *testing.T) { + database := openMigratedMemory(t) + chID := seedChannel(t, database, "apichan") + userID := seedUser(t, database, "apiuser") + + msgs, err := database.GetMessagesForAPI(chID, 0, 50, userID) + if err != nil { + t.Fatalf("GetMessagesForAPI: %v", err) + } + if len(msgs) != 0 { + t.Errorf("expected 0 messages, got %d", len(msgs)) + } +} + +func TestGetMessagesForAPI_ReturnsUserObject(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "apiuser2") + chID := seedChannel(t, database, "apichan2") + + _, _ = database.CreateMessage(chID, userID, "hello api", nil) + + msgs, err := database.GetMessagesForAPI(chID, 0, 50, userID) + if err != nil { + t.Fatalf("GetMessagesForAPI: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if msgs[0].User.Username != "apiuser2" { + t.Errorf("User.Username = %q, want 'apiuser2'", msgs[0].User.Username) + } + if msgs[0].User.ID != userID { + t.Errorf("User.ID = %d, want %d", msgs[0].User.ID, userID) + } + if msgs[0].Content != "hello api" { + t.Errorf("Content = %q, want 'hello api'", msgs[0].Content) + } +} + +func TestGetMessagesForAPI_BeforePagination(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "apipage") + chID := seedChannel(t, database, "apich") + + var ids []int64 + for range 5 { + id, _ := database.CreateMessage(chID, userID, "msg", nil) + ids = append(ids, id) + } + + msgs, err := database.GetMessagesForAPI(chID, ids[3], 50, userID) + if err != nil { + t.Fatalf("GetMessagesForAPI with before: %v", err) + } + if len(msgs) != 3 { + t.Errorf("expected 3 messages before id %d, got %d", ids[3], len(msgs)) + } +} + +func TestGetMessagesForAPI_WithReactions(t *testing.T) { + database := openMigratedMemory(t) + u1 := seedUser(t, database, "reactuser1") + u2 := seedUser(t, database, "reactuser2") + chID := seedChannel(t, database, "reactchan") + + msgID, _ := database.CreateMessage(chID, u1, "react me", nil) + _ = database.AddReaction(msgID, u1, "👍") + _ = database.AddReaction(msgID, u2, "👍") + + msgs, err := database.GetMessagesForAPI(chID, 0, 50, u1) + if err != nil { + t.Fatalf("GetMessagesForAPI: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if len(msgs[0].Reactions) != 1 { + t.Fatalf("expected 1 reaction type, got %d", len(msgs[0].Reactions)) + } + if msgs[0].Reactions[0].Count != 2 { + t.Errorf("reaction count = %d, want 2", msgs[0].Reactions[0].Count) + } + if !msgs[0].Reactions[0].Me { + t.Error("Me should be true for requesting user who reacted") + } +} + +func TestGetMessagesForAPI_ExcludesDeleted(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "apidel") + chID := seedChannel(t, database, "apidelchan") + + id, _ := database.CreateMessage(chID, userID, "deleted msg", nil) + _ = database.DeleteMessage(id, userID, false) + _, _ = database.CreateMessage(chID, userID, "visible msg", nil) + + msgs, err := database.GetMessagesForAPI(chID, 0, 50, userID) + if err != nil { + t.Fatalf("GetMessagesForAPI: %v", err) + } + if len(msgs) != 1 { + t.Errorf("expected 1 message (deleted excluded), got %d", len(msgs)) + } +} + +// ─── GetChannelUnreadCounts ───────────────────────────────────────────────── + +func TestGetChannelUnreadCounts_NoMessages(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "unreaduser") + _ = seedChannel(t, database, "unreadchan") + + counts, err := database.GetChannelUnreadCounts(userID) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + // Should return entries for text channels even with 0 messages. + if counts == nil { + t.Fatal("GetChannelUnreadCounts returned nil") + } +} + +func TestGetChannelUnreadCounts_WithUnreadMessages(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "unreaduser2") + chID := seedChannel(t, database, "unreadchan2") + + // Create 3 messages, mark first as read. + msg1, _ := database.CreateMessage(chID, userID, "msg1", nil) + _, _ = database.CreateMessage(chID, userID, "msg2", nil) + _, _ = database.CreateMessage(chID, userID, "msg3", nil) + + _ = database.UpdateReadState(userID, chID, msg1) + + counts, err := database.GetChannelUnreadCounts(userID) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + cu, ok := counts[chID] + if !ok { + t.Fatalf("channel %d not in unread counts", chID) + } + if cu.UnreadCount != 2 { + t.Errorf("UnreadCount = %d, want 2", cu.UnreadCount) + } +} + +// ─── GetLatestMessageID ───────────────────────────────────────────────────── + +func TestGetLatestMessageID_Empty(t *testing.T) { + database := openMigratedMemory(t) + chID := seedChannel(t, database, "latestchan") + + id, err := database.GetLatestMessageID(chID) + if err != nil { + t.Fatalf("GetLatestMessageID: %v", err) + } + if id != 0 { + t.Errorf("expected 0 for empty channel, got %d", id) + } +} + +func TestGetLatestMessageID_ReturnsHighest(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "latestuser") + chID := seedChannel(t, database, "latestchan2") + + _, _ = database.CreateMessage(chID, userID, "first", nil) + _, _ = database.CreateMessage(chID, userID, "second", nil) + lastID, _ := database.CreateMessage(chID, userID, "third", nil) + + id, err := database.GetLatestMessageID(chID) + if err != nil { + t.Fatalf("GetLatestMessageID: %v", err) + } + if id != lastID { + t.Errorf("GetLatestMessageID = %d, want %d", id, lastID) + } +} + +func TestGetLatestMessageID_ExcludesDeleted(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "latestdel") + chID := seedChannel(t, database, "latestdelchan") + + id1, _ := database.CreateMessage(chID, userID, "keep", nil) + id2, _ := database.CreateMessage(chID, userID, "delete me", nil) + _ = database.DeleteMessage(id2, userID, false) + + latestID, err := database.GetLatestMessageID(chID) + if err != nil { + t.Fatalf("GetLatestMessageID: %v", err) + } + if latestID != id1 { + t.Errorf("GetLatestMessageID = %d, want %d (deleted excluded)", latestID, id1) + } +} diff --git a/Server/db/migrate.go b/Server/db/migrate.go new file mode 100644 index 00000000..f1f147d8 --- /dev/null +++ b/Server/db/migrate.go @@ -0,0 +1,186 @@ +package db + +// migrate.go — tracked migration runner for the OwnCord server. +// +// Each .sql file in the provided FS is applied exactly once. The +// schema_versions table records every applied migration filename and the UTC +// timestamp at which it was applied. +// +// Seeding for existing databases +// -------------------------------- +// When the server is first upgraded to include migration tracking, existing +// databases will have all schema tables in place but no schema_versions table. +// Without seeding, every migration would re-run and could destroy data. +// +// The seeding heuristic: if schema_versions does not exist AND the "users" +// table already exists, we assume all migrations in the current FS have +// already been applied. We create schema_versions and insert every migration +// filename without executing the SQL, so subsequent runs treat them as done. + +import ( + "fmt" + "io/fs" + "sort" + "strings" +) + +const createSchemaVersions = ` +CREATE TABLE IF NOT EXISTS schema_versions ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) +)` + +// ensureSchemaVersions creates the tracking table if it does not yet exist. +func ensureSchemaVersions(d *DB) error { + if _, err := d.sqlDB.Exec(createSchemaVersions); err != nil { + return fmt.Errorf("creating schema_versions: %w", err) + } + return nil +} + +// isExistingDatabase reports whether the database was previously migrated +// without tracking — detected by the presence of the "users" table. +func isExistingDatabase(d *DB) (bool, error) { + var name string + err := d.sqlDB.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name='users'", + ).Scan(&name) + if err != nil { + // sql.ErrNoRows means the table does not exist. + return false, nil + } + return true, nil +} + +// schemaVersionsExists reports whether the schema_versions table is present. +func schemaVersionsExists(d *DB) (bool, error) { + var name string + err := d.sqlDB.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_versions'", + ).Scan(&name) + if err != nil { + return false, nil + } + return true, nil +} + +// isApplied reports whether a migration filename has already been recorded. +func isApplied(d *DB, filename string) (bool, error) { + var v string + err := d.sqlDB.QueryRow( + "SELECT version FROM schema_versions WHERE version = ?", filename, + ).Scan(&v) + if err != nil { + return false, nil + } + return true, nil +} + +// recordApplied inserts a migration filename into schema_versions. +func recordApplied(d *DB, filename string) error { + _, err := d.sqlDB.Exec( + "INSERT INTO schema_versions (version) VALUES (?)", filename, + ) + if err != nil { + return fmt.Errorf("recording migration %s: %w", filename, err) + } + return nil +} + +// sqlFilenames returns all .sql entries from the FS sorted lexicographically. +func sqlFilenames(fsys fs.FS) ([]string, error) { + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return nil, fmt.Errorf("reading migrations dir: %w", err) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + names := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + names = append(names, e.Name()) + } + } + return names, nil +} + +// seedExistingDatabase inserts all migration filenames into schema_versions +// without executing them. This is called once when upgrading a pre-tracking +// database. +func seedExistingDatabase(d *DB, filenames []string) error { + for _, name := range filenames { + if err := recordApplied(d, name); err != nil { + return fmt.Errorf("seeding %s: %w", name, err) + } + } + return nil +} + +// MigrateFS runs tracked migrations from the provided FS. +// +// Behaviour: +// 1. Create schema_versions if absent. +// 2. If this is the first run with tracking on an existing database (users +// table exists but schema_versions was just created), seed all filenames +// so they are not re-executed. +// 3. For each .sql file in lexicographic order: skip if already recorded, +// otherwise execute the SQL and record the filename. +func MigrateFS(database *DB, fsys fs.FS) error { + // Determine tracking state before we create schema_versions. + svExists, err := schemaVersionsExists(database) + if err != nil { + return err + } + + // Create the tracking table (idempotent). + if err := ensureSchemaVersions(database); err != nil { + return err + } + + // Collect filenames first — needed for both seeding and normal application. + filenames, err := sqlFilenames(fsys) + if err != nil { + return err + } + + // Seeding path: schema_versions did not exist AND users table does, which + // means this is an existing database being upgraded to tracked migrations. + if !svExists { + existing, checkErr := isExistingDatabase(database) + if checkErr != nil { + return checkErr + } + if existing { + return seedExistingDatabase(database, filenames) + } + } + + // Normal path: apply any migration not yet recorded. + for _, name := range filenames { + applied, applyErr := isApplied(database, name) + if applyErr != nil { + return applyErr + } + if applied { + continue + } + + raw, readErr := fs.ReadFile(fsys, name) + if readErr != nil { + return fmt.Errorf("reading migration %s: %w", name, readErr) + } + + if _, execErr := database.sqlDB.Exec(string(raw)); execErr != nil { + return fmt.Errorf("executing migration %s: %w", name, execErr) + } + + if err := recordApplied(database, name); err != nil { + return err + } + } + + return nil +} diff --git a/Server/db/migrate_test.go b/Server/db/migrate_test.go new file mode 100644 index 00000000..1213627b --- /dev/null +++ b/Server/db/migrate_test.go @@ -0,0 +1,695 @@ +package db_test + +// migrate_test.go — TDD tests for the tracked migration system. +// +// RED phase: these tests are written before the implementation exists. +// They verify the contract of MigrateFS after it gains schema_versions tracking. +// +// Test matrix: +// TestMigrate_SchemaVersionsTableCreated — schema_versions exists after first run +// TestMigrate_AllMigrationsRecorded — every applied file is recorded +// TestMigrate_SkipsAlreadyApplied — second call skips files already in schema_versions +// TestMigrate_AppliesNewMigrationsOnly — only new files are applied on subsequent runs +// TestMigrate_OrderIsLexicographic — migrations execute in sorted filename order +// TestMigrate_SeedExistingDatabase — existing DB (no schema_versions) is seeded +// TestMigrate_SeedDoesNotReRunMigrations — seeded migrations are not re-executed +// TestMigrate_SchemaVersionsAppliedAtRecorded — applied_at column is populated +// TestMigrate_EmptyFSSucceeds — empty FS is fine, no error +// TestMigrate_InvalidSQLReturnsError — bad SQL still surfaces as an error +// TestMigrate_ReadFileErrorReturnsError — FS read failure surfaces as an error +// TestMigrate_PartialRunRecordsOnlyApplied — failure mid-run leaves earlier files recorded +// TestMigrate_AppliedAtIsISO8601 — applied_at timestamp format is valid + +import ( + "database/sql" + "fmt" + "io/fs" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/db" +) + +// failReadDirFS is an fs.FS whose root Open succeeds but ReadDir always errors. +// This exercises the sqlFilenames ReadDir error path. +type failReadDirFS struct{} + +func (failReadDirFS) Open(name string) (fs.File, error) { + if name == "." { + return &badDirFile{}, nil + } + return nil, fmt.Errorf("no files") +} + +type badDirFile struct{} + +func (badDirFile) Read([]byte) (int, error) { return 0, fmt.Errorf("not a file") } +func (badDirFile) Close() error { return nil } +func (badDirFile) Stat() (fs.FileInfo, error) { return fakeDirInfo{}, nil } +func (badDirFile) ReadDir(int) ([]fs.DirEntry, error) { + return nil, fmt.Errorf("readdir always fails") +} + +// ---- helpers ---------------------------------------------------------------- + +// countVersions returns the number of rows in schema_versions. +func countVersions(t *testing.T, database *db.DB) int { + t.Helper() + var n int + err := database.QueryRow("SELECT COUNT(*) FROM schema_versions").Scan(&n) + if err != nil { + t.Fatalf("counting schema_versions: %v", err) + } + return n +} + +// hasVersion reports whether a specific filename is recorded in schema_versions. +func hasVersion(t *testing.T, database *db.DB, filename string) bool { + t.Helper() + var v string + err := database.QueryRow( + "SELECT version FROM schema_versions WHERE version = ?", filename, + ).Scan(&v) + if err == sql.ErrNoRows { + return false + } + if err != nil { + t.Fatalf("querying schema_versions for %q: %v", filename, err) + } + return true +} + +// tableExists reports whether a table (or virtual table) exists in sqlite_master. +func tableExists(t *testing.T, database *db.DB, name string) bool { + t.Helper() + var n string + err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", name, + ).Scan(&n) + if err == sql.ErrNoRows { + return false + } + if err != nil { + t.Fatalf("checking table %q: %v", name, err) + } + return true +} + +// simpleFS builds an fstest.MapFS with the provided filename→SQL pairs. +func simpleFS(pairs ...string) fstest.MapFS { + if len(pairs)%2 != 0 { + panic("simpleFS requires an even number of arguments (name, sql, ...)") + } + m := fstest.MapFS{} + for i := 0; i < len(pairs); i += 2 { + m[pairs[i]] = &fstest.MapFile{Data: []byte(pairs[i+1])} + } + return m +} + +// ---- tests ------------------------------------------------------------------ + +// TestMigrate_SchemaVersionsTableCreated verifies that MigrateFS creates the +// schema_versions tracking table on first run. +func TestMigrate_SchemaVersionsTableCreated(t *testing.T) { + database := openMemory(t) + + fsys := simpleFS( + "001_create_foo.sql", "CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY);", + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + if !tableExists(t, database, "schema_versions") { + t.Error("schema_versions table was not created by MigrateFS") + } +} + +// TestMigrate_AllMigrationsRecorded verifies that every applied .sql file +// gets a row inserted into schema_versions. +func TestMigrate_AllMigrationsRecorded(t *testing.T) { + database := openMemory(t) + + fsys := simpleFS( + "001_alpha.sql", "CREATE TABLE IF NOT EXISTS alpha (id INTEGER PRIMARY KEY);", + "002_beta.sql", "CREATE TABLE IF NOT EXISTS beta (id INTEGER PRIMARY KEY);", + "003_gamma.sql", "CREATE TABLE IF NOT EXISTS gamma (id INTEGER PRIMARY KEY);", + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + for _, name := range []string{"001_alpha.sql", "002_beta.sql", "003_gamma.sql"} { + if !hasVersion(t, database, name) { + t.Errorf("migration %q not recorded in schema_versions", name) + } + } +} + +// TestMigrate_SkipsAlreadyApplied verifies that a second call to MigrateFS +// with the same FS does not re-execute already-applied migrations. +func TestMigrate_SkipsAlreadyApplied(t *testing.T) { + database := openMemory(t) + + // This migration inserts a row; if re-run it would violate UNIQUE. + fsys := simpleFS( + "001_unique.sql", ` + CREATE TABLE IF NOT EXISTS unique_check (val TEXT UNIQUE); + INSERT INTO unique_check (val) VALUES ('singleton'); + `, + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() first run error: %v", err) + } + + // Second run — must not fail even though the INSERT would conflict if re-run. + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() second run error (migration was re-executed): %v", err) + } + + // Confirm the row exists exactly once. + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM unique_check WHERE val='singleton'").Scan(&count); err != nil { + t.Fatalf("counting unique_check: %v", err) + } + if count != 1 { + t.Errorf("unique_check has %d rows, want exactly 1 — migration was re-run", count) + } +} + +// TestMigrate_AppliesNewMigrationsOnly verifies that when a new file is added +// to the FS, only that file is applied on the second call. +func TestMigrate_AppliesNewMigrationsOnly(t *testing.T) { + database := openMemory(t) + + fsFirst := simpleFS( + "001_base.sql", "CREATE TABLE IF NOT EXISTS base_tbl (id INTEGER PRIMARY KEY);", + ) + + if err := db.MigrateFS(database, fsFirst); err != nil { + t.Fatalf("MigrateFS() first run error: %v", err) + } + + versionsAfterFirst := countVersions(t, database) + + // Add a second migration. + fsSecond := simpleFS( + "001_base.sql", "CREATE TABLE IF NOT EXISTS base_tbl (id INTEGER PRIMARY KEY);", + "002_extra.sql", "CREATE TABLE IF NOT EXISTS extra_tbl (id INTEGER PRIMARY KEY);", + ) + + if err := db.MigrateFS(database, fsSecond); err != nil { + t.Fatalf("MigrateFS() second run error: %v", err) + } + + versionsAfterSecond := countVersions(t, database) + + if versionsAfterSecond != versionsAfterFirst+1 { + t.Errorf( + "expected %d version rows after second run, got %d", + versionsAfterFirst+1, versionsAfterSecond, + ) + } + + if !hasVersion(t, database, "002_extra.sql") { + t.Error("002_extra.sql not recorded after second run") + } + + if !tableExists(t, database, "extra_tbl") { + t.Error("extra_tbl not created by second run") + } +} + +// TestMigrate_OrderIsLexicographic verifies that migrations are applied in +// sorted filename order, not insertion or readdir order. +func TestMigrate_OrderIsLexicographic(t *testing.T) { + database := openMemory(t) + + // 002 creates the table; 001 tries to insert into it. + // If run out of order (002 before 001) the INSERT would fail with "no such table". + // With lexicographic ordering 001 runs first and creates the table, + // then 002 inserts into it — so we verify the correct order by checking + // the table was created before the insert was attempted. + fsys := simpleFS( + "002_insert.sql", "INSERT INTO order_check (label) VALUES ('second');", + "001_create.sql", "CREATE TABLE IF NOT EXISTS order_check (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT);", + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + var label string + if err := database.QueryRow("SELECT label FROM order_check LIMIT 1").Scan(&label); err != nil { + t.Fatalf("selecting from order_check: %v", err) + } + if label != "second" { + t.Errorf("label = %q, want 'second'", label) + } +} + +// TestMigrate_SeedExistingDatabase verifies that when schema_versions does not +// exist but other known tables do (simulating an existing DB from before +// tracking was added), all current migration filenames are seeded so they are +// not re-executed. +func TestMigrate_SeedExistingDatabase(t *testing.T) { + database := openMemory(t) + + // Manually create a table to simulate a previously-migrated database + // that does not yet have schema_versions. + if _, err := database.Exec( + "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);", + ); err != nil { + t.Fatalf("setup: creating users table: %v", err) + } + + // This migration would drop and recreate users; if it runs it will wipe data. + // The seeding logic must prevent it from running. + fsys := simpleFS( + "001_initial.sql", ` + DROP TABLE IF EXISTS users; + CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT); + `, + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + // The migration must be recorded (seeded). + if !hasVersion(t, database, "001_initial.sql") { + t.Error("001_initial.sql should be seeded into schema_versions for existing DB") + } + + // The users table must still have its original schema (no 'name' column), + // proving the DROP/CREATE did not run. + _, err := database.Exec("INSERT INTO users (id) VALUES (42)") + if err != nil { + t.Errorf("users table appears to have been recreated (DROP ran): %v", err) + } +} + +// TestMigrate_SeedDoesNotReRunMigrations is a companion to the seeding test: +// after seeding, a subsequent MigrateFS call with the same FS must be a no-op. +// The seeding heuristic triggers on the presence of the "users" sentinel table, +// so we create that table to simulate a pre-tracking database. +func TestMigrate_SeedDoesNotReRunMigrations(t *testing.T) { + database := openMemory(t) + + // Simulate an existing DB: create the "users" sentinel table so the seeding + // heuristic fires, plus the table that the migration would modify. + if _, err := database.Exec( + "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);", + ); err != nil { + t.Fatalf("setup users: %v", err) + } + if _, err := database.Exec( + "CREATE TABLE IF NOT EXISTS existing (id INTEGER PRIMARY KEY);", + ); err != nil { + t.Fatalf("setup existing: %v", err) + } + + // This migration would INSERT into existing; if it runs, count becomes 1. + fsys := simpleFS( + "001_existing.sql", ` + CREATE TABLE IF NOT EXISTS existing (id INTEGER PRIMARY KEY); + INSERT INTO existing (id) VALUES (1); + `, + ) + + // First call — seeds because schema_versions is absent AND users table exists. + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() first run error: %v", err) + } + + // Second call — must be a no-op (migration is already recorded). + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() second run error: %v", err) + } + + // existing table should be empty — the INSERT was never executed (seeded only). + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM existing").Scan(&count); err != nil { + t.Fatalf("counting existing: %v", err) + } + if count != 0 { + t.Errorf("existing has %d rows, want 0 — seeded migration was re-executed", count) + } +} + +// TestMigrate_SchemaVersionsAppliedAtRecorded verifies that applied_at is +// populated for every recorded migration. +func TestMigrate_SchemaVersionsAppliedAtRecorded(t *testing.T) { + database := openMemory(t) + + fsys := simpleFS( + "001_ts.sql", "CREATE TABLE IF NOT EXISTS ts_test (id INTEGER PRIMARY KEY);", + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + var appliedAt string + err := database.QueryRow( + "SELECT applied_at FROM schema_versions WHERE version = '001_ts.sql'", + ).Scan(&appliedAt) + if err != nil { + t.Fatalf("querying applied_at: %v", err) + } + if appliedAt == "" { + t.Error("applied_at should not be empty") + } +} + +// TestMigrate_AppliedAtIsISO8601 verifies applied_at is a parseable datetime. +func TestMigrate_AppliedAtIsISO8601(t *testing.T) { + database := openMemory(t) + + fsys := simpleFS( + "001_dt.sql", "CREATE TABLE IF NOT EXISTS dt_test (id INTEGER PRIMARY KEY);", + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + var appliedAt string + if err := database.QueryRow( + "SELECT applied_at FROM schema_versions WHERE version = '001_dt.sql'", + ).Scan(&appliedAt); err != nil { + t.Fatalf("querying applied_at: %v", err) + } + + // SQLite datetime('now') produces "YYYY-MM-DD HH:MM:SS". + formats := []string{ + "2006-01-02 15:04:05", + time.RFC3339, + } + var parsed bool + for _, f := range formats { + if _, err := time.Parse(f, appliedAt); err == nil { + parsed = true + break + } + } + if !parsed { + t.Errorf("applied_at %q is not a recognised datetime format", appliedAt) + } +} + +// TestMigrate_EmptyFSSucceeds verifies that an empty FS returns no error and +// still creates the schema_versions table. +func TestMigrate_EmptyFSSucceeds(t *testing.T) { + database := openMemory(t) + + fsys := fstest.MapFS{} + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() with empty FS error: %v", err) + } + + if !tableExists(t, database, "schema_versions") { + t.Error("schema_versions should be created even for empty FS") + } + + if countVersions(t, database) != 0 { + t.Error("schema_versions should be empty for empty FS") + } +} + +// TestMigrate_InvalidSQLReturnsError verifies that a migration with invalid +// SQL causes MigrateFS to return a non-nil error. +func TestMigrate_InvalidSQLReturnsError(t *testing.T) { + database := openMemory(t) + + fsys := simpleFS( + "001_bad.sql", "THIS IS NOT VALID SQL !!!@@@###", + ) + + err := db.MigrateFS(database, fsys) + if err == nil { + t.Error("MigrateFS() should return error for invalid SQL, got nil") + } +} + +// TestMigrate_ReadFileErrorReturnsError verifies that an FS read failure +// surfaces as an error from MigrateFS. +func TestMigrate_ReadFileErrorReturnsError(t *testing.T) { + database := openMemory(t) + + err := db.MigrateFS(database, failReadFS{}) + if err == nil { + t.Error("MigrateFS() should return error when ReadFile fails") + } +} + +// TestMigrate_PartialRunRecordsOnlyApplied verifies that if the second +// migration in a set fails, only the first is recorded in schema_versions. +func TestMigrate_PartialRunRecordsOnlyApplied(t *testing.T) { + database := openMemory(t) + + fsys := simpleFS( + "001_good.sql", "CREATE TABLE IF NOT EXISTS partial_good (id INTEGER PRIMARY KEY);", + "002_bad.sql", "THIS IS DEFINITELY NOT SQL;", + ) + + _ = db.MigrateFS(database, fsys) // we expect an error; ignore it here + + if !hasVersion(t, database, "001_good.sql") { + t.Error("001_good.sql should be recorded even though 002 failed") + } + if hasVersion(t, database, "002_bad.sql") { + t.Error("002_bad.sql should NOT be recorded because it failed") + } +} + +// TestMigrate_NonSQLFilesSkipped verifies that files without a .sql extension +// are skipped and not recorded in schema_versions. +func TestMigrate_NonSQLFilesSkipped(t *testing.T) { + database := openMemory(t) + + fsys := fstest.MapFS{ + "README.md": {Data: []byte("not sql")}, + "001_ok.sql": {Data: []byte("CREATE TABLE IF NOT EXISTS ns_test (id INTEGER PRIMARY KEY);")}, + "002_ok.go": {Data: []byte("package migrations")}, + } + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + if hasVersion(t, database, "README.md") { + t.Error("README.md should not be recorded in schema_versions") + } + if hasVersion(t, database, "002_ok.go") { + t.Error("002_ok.go should not be recorded in schema_versions") + } + if !hasVersion(t, database, "001_ok.sql") { + t.Error("001_ok.sql should be recorded in schema_versions") + } +} + +// TestMigrate_WithRealMigrations is an integration smoke test: run the +// production migration set through the tracked MigrateFS and verify the +// schema_versions table contains exactly one row per .sql file in the FS. +func TestMigrate_WithRealMigrations(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + if !tableExists(t, database, "schema_versions") { + t.Fatal("schema_versions not created by Migrate()") + } + + // Count .sql files in the embedded FS by running Migrate again (no-op) and + // inspecting the version count. We just verify the count is > 0. + n := countVersions(t, database) + if n == 0 { + t.Error("schema_versions is empty after running production migrations") + } +} + +// TestMigrate_WithRealMigrationsIdempotent verifies the production migration +// set can be run twice without error via the tracked path. +func TestMigrate_WithRealMigrationsIdempotent(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() first run error: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() second run error: %v", err) + } +} + +// TestMigrate_SeedDetectionUsesKnownTable verifies the seeding heuristic: it +// must detect an existing DB by the presence of a known table (e.g. "users"), +// not by an arbitrary table name. +func TestMigrate_SeedDetectionUsesKnownTable(t *testing.T) { + database := openMemory(t) + + // Create only an unrelated table — not one of the known sentinel tables. + if _, err := database.Exec( + "CREATE TABLE IF NOT EXISTS unrelated (id INTEGER PRIMARY KEY);", + ); err != nil { + t.Fatalf("setup: %v", err) + } + + fsys := simpleFS( + "001_new.sql", "CREATE TABLE IF NOT EXISTS new_table (id INTEGER PRIMARY KEY);", + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + // Since "users" table was absent, seeding must NOT have occurred and the + // migration must have actually been applied. + if !tableExists(t, database, "new_table") { + t.Error("new_table should exist — migration was not seeded, so it must have run") + } +} + +// TestMigrate_ErrorMessageContainsFilename verifies that when a migration +// fails, the returned error message includes the filename for easier debugging. +func TestMigrate_ErrorMessageContainsFilename(t *testing.T) { + database := openMemory(t) + + fsys := simpleFS( + "042_broken.sql", "INVALID SQL STATEMENT;", + ) + + err := db.MigrateFS(database, fsys) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "042_broken.sql") { + t.Errorf("error %q does not mention the failing filename", err.Error()) + } +} + +// TestMigrate_ReadDirErrorReturnsError verifies that when the FS returns an +// error from ReadDir, MigrateFS propagates it as a non-nil error. +func TestMigrate_ReadDirErrorReturnsError(t *testing.T) { + database := openMemory(t) + + err := db.MigrateFS(database, failReadDirFS{}) + if err == nil { + t.Error("MigrateFS() should return error when ReadDir fails") + } +} + +// TestMigrate_SchemaVersionsHasPrimaryKey verifies the schema_versions table +// uses version as PRIMARY KEY, preventing duplicate rows for the same file. +func TestMigrate_SchemaVersionsHasPrimaryKey(t *testing.T) { + database := openMemory(t) + + fsys := simpleFS( + "001_pk.sql", "CREATE TABLE IF NOT EXISTS pk_test (id INTEGER PRIMARY KEY);", + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + // Attempting a duplicate insert must fail. + _, err := database.Exec( + "INSERT INTO schema_versions (version, applied_at) VALUES ('001_pk.sql', datetime('now'))", + ) + if err == nil { + t.Error("duplicate insert into schema_versions should fail — PRIMARY KEY not enforced") + } +} + +// TestMigrate_SeedRecordsAllFilesFromFS verifies that seeding writes a row for +// every .sql file in the FS, including when there are multiple files. +func TestMigrate_SeedRecordsAllFilesFromFS(t *testing.T) { + database := openMemory(t) + + // Create the users sentinel to trigger seeding on first call. + if _, err := database.Exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);"); err != nil { + t.Fatalf("setup: %v", err) + } + + fsys := simpleFS( + "001_a.sql", "CREATE TABLE IF NOT EXISTS seed_a (id INTEGER PRIMARY KEY);", + "002_b.sql", "CREATE TABLE IF NOT EXISTS seed_b (id INTEGER PRIMARY KEY);", + "003_c.sql", "CREATE TABLE IF NOT EXISTS seed_c (id INTEGER PRIMARY KEY);", + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + for _, name := range []string{"001_a.sql", "002_b.sql", "003_c.sql"} { + if !hasVersion(t, database, name) { + t.Errorf("%q not seeded into schema_versions", name) + } + } + + // Tables from the seeded migrations must NOT have been created + // (seeding records without executing). + for _, tbl := range []string{"seed_a", "seed_b", "seed_c"} { + if tableExists(t, database, tbl) { + t.Errorf("table %q should not exist — migration was seeded, not executed", tbl) + } + } +} + +// TestMigrate_FreshDatabaseRunsAllMigrations verifies that a completely fresh +// database (no tables at all) runs every migration without seeding. +func TestMigrate_FreshDatabaseRunsAllMigrations(t *testing.T) { + database := openMemory(t) + + fsys := simpleFS( + "001_fresh.sql", "CREATE TABLE IF NOT EXISTS fresh_a (id INTEGER PRIMARY KEY);", + "002_fresh.sql", "CREATE TABLE IF NOT EXISTS fresh_b (id INTEGER PRIMARY KEY);", + ) + + if err := db.MigrateFS(database, fsys); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + for _, tbl := range []string{"fresh_a", "fresh_b"} { + if !tableExists(t, database, tbl) { + t.Errorf("table %q should exist on fresh database run", tbl) + } + } + + if countVersions(t, database) != 2 { + t.Errorf("expected 2 version rows, got %d", countVersions(t, database)) + } +} + +// TestMigrate_LargeNumberOfMigrations verifies that MigrateFS can handle +// a large set of migrations without degrading or skipping any. +func TestMigrate_LargeNumberOfMigrations(t *testing.T) { + database := openMemory(t) + + const n = 50 + pairs := make([]string, 0, n*2) + for i := 1; i <= n; i++ { + name := fmt.Sprintf("%03d_large.sql", i) + sql := fmt.Sprintf("CREATE TABLE IF NOT EXISTS large_%03d (id INTEGER PRIMARY KEY);", i) + pairs = append(pairs, name, sql) + } + + if err := db.MigrateFS(database, simpleFS(pairs...)); err != nil { + t.Fatalf("MigrateFS() error with %d migrations: %v", n, err) + } + + got := countVersions(t, database) + if got != n { + t.Errorf("schema_versions has %d rows, want %d", got, n) + } +} diff --git a/Server/db/models.go b/Server/db/models.go new file mode 100644 index 00000000..e7e70132 --- /dev/null +++ b/Server/db/models.go @@ -0,0 +1,196 @@ +package db + +import "time" + +// User represents a row in the users table. +type User struct { + ID int64 + Username string + PasswordHash string + Avatar *string + RoleID int64 + TOTPSecret *string + Status string + CreatedAt string + LastSeen *string + Banned bool + BanReason *string + BanExpires *string +} + +// Session represents a row in the sessions table. +type Session struct { + ID int64 + UserID int64 + TokenHash string + Device string + IP string + CreatedAt string + LastUsed string + ExpiresAt string +} + +// Invite represents a row in the invites table. +type Invite struct { + ID int64 + Code string + CreatedBy int64 + Uses int + MaxUses *int + ExpiresAt *string + Revoked bool + CreatedAt string +} + +// Role represents a row in the roles table. +type Role struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color *string `json:"color"` + Permissions int64 `json:"permissions"` + Position int `json:"position"` + IsDefault bool `json:"is_default"` +} + +// Channel represents a row in the channels table. +type Channel struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + Topic string `json:"topic"` + Position int `json:"position"` + SlowMode int `json:"slow_mode"` + Archived bool `json:"archived"` + CreatedAt string `json:"created_at"` + VoiceMaxUsers int `json:"voice_max_users"` + VoiceQuality *string `json:"voice_quality,omitempty"` + MixingThreshold *int `json:"mixing_threshold,omitempty"` + VoiceMaxVideo int `json:"voice_max_video"` +} + +// Message represents a row in the messages table. +type Message struct { + ID int64 + ChannelID int64 + UserID int64 + Content string + ReplyTo *int64 + EditedAt *string + Deleted bool + Pinned bool + Timestamp string +} + +// MessageWithUser joins a Message with the author's public fields. +type MessageWithUser struct { + Message + Username string + Avatar *string +} + +// ReactionCount is an aggregated reaction count for a single emoji. +type ReactionCount struct { + Emoji string + Count int + MeReacted bool +} + +// MessageSearchResult is a row returned by the FTS5 message search. +type MessageSearchResult struct { + MessageID int64 `json:"message_id"` + ChannelID int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + User UserPublic `json:"user"` + Content string `json:"content"` + Timestamp string `json:"timestamp"` +} + +// UserPublic is the public-facing user shape for API responses. +type UserPublic struct { + ID int64 `json:"id"` + Username string `json:"username"` + Avatar *string `json:"avatar,omitempty"` +} + +// MessageAPIResponse matches the API.md shape for GET /channels/{id}/messages. +type MessageAPIResponse struct { + ID int64 `json:"id"` + ChannelID int64 `json:"channel_id"` + User UserPublic `json:"user"` + Content string `json:"content"` + ReplyTo *int64 `json:"reply_to"` + Attachments []AttachmentInfo `json:"attachments"` + Reactions []ReactionInfo `json:"reactions"` + Pinned bool `json:"pinned"` + EditedAt *string `json:"edited_at"` + Deleted bool `json:"deleted"` + Timestamp string `json:"timestamp"` +} + +// AttachmentInfo is the attachment shape in API responses. +type AttachmentInfo struct { + ID string `json:"id"` + Filename string `json:"filename"` + Size int64 `json:"size"` + Mime string `json:"mime"` + URL string `json:"url"` +} + +// ReactionInfo is the reaction shape in API responses. +type ReactionInfo struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Me bool `json:"me"` +} + +// VoiceState represents a row in the voice_states table. +// It tracks which voice channel a user is in and their current audio state. +type VoiceState struct { + UserID int64 `json:"user_id"` + ChannelID int64 `json:"channel_id"` + Username string `json:"username"` + Muted bool `json:"muted"` + Deafened bool `json:"deafened"` + Speaking bool `json:"speaking"` + Camera bool `json:"camera"` + Screenshare bool `json:"screenshare"` +} + +// ChannelUnread holds per-user unread data for a single channel. +type ChannelUnread struct { + LastMessageID int64 `json:"last_message_id"` + UnreadCount int `json:"unread_count"` +} + +// ServerStats contains aggregate counts for the admin dashboard. +type ServerStats struct { + UserCount int64 `json:"user_count"` + MessageCount int64 `json:"message_count"` + ChannelCount int64 `json:"channel_count"` + InviteCount int64 `json:"invite_count"` + DBSizeBytes int64 `json:"db_size_bytes"` + OnlineCount int `json:"online_count"` +} + +// UserWithRole extends User with the name of the user's role. +type UserWithRole struct { + User + RoleName string `json:"role_name"` +} + +// AuditEntry represents a single row from the audit_log table joined with the +// actor's username. +type AuditEntry struct { + ID int64 `json:"id"` + ActorID int64 `json:"actor_id"` + ActorName string `json:"actor_name"` + Action string `json:"action"` + TargetType string `json:"target_type"` + TargetID int64 `json:"target_id"` + Detail string `json:"detail"` + CreatedAt string `json:"created_at"` +} + +// sessionTTL is the duration a session remains valid after creation. +const sessionTTL = 30 * 24 * time.Hour diff --git a/Server/db/role_invite_queries_test.go b/Server/db/role_invite_queries_test.go new file mode 100644 index 00000000..05251270 --- /dev/null +++ b/Server/db/role_invite_queries_test.go @@ -0,0 +1,156 @@ +package db_test + +import ( + "testing" +) + +// ─── GetRoleByID tests ──────────────────────────────────────────────────────── + +func TestGetRoleByID_Found(t *testing.T) { + database := newTestDB(t) + + role, err := database.GetRoleByID(4) // Member — inserted by migration + if err != nil { + t.Fatalf("GetRoleByID: %v", err) + } + if role == nil { + t.Fatal("GetRoleByID returned nil for Member role") + } + if role.Name != "Member" { + t.Errorf("Name = %q, want %q", role.Name, "Member") + } + if role.Permissions == 0 { + t.Error("Member permissions = 0, want non-zero") + } +} + +func TestGetRoleByID_NotFound(t *testing.T) { + database := newTestDB(t) + + role, err := database.GetRoleByID(9999) + if err != nil { + t.Fatalf("GetRoleByID(not found): %v", err) + } + if role != nil { + t.Error("GetRoleByID returned non-nil for missing role") + } +} + +func TestGetRoleByID_OwnerHasAllPermissions(t *testing.T) { + database := newTestDB(t) + + role, err := database.GetRoleByID(1) // Owner + if err != nil { + t.Fatalf("GetRoleByID Owner: %v", err) + } + if role == nil { + t.Fatal("GetRoleByID returned nil for Owner role") + } + // Owner has permissions = 0x7FFFFFFF = 2147483647 + if role.Permissions != 2147483647 { + t.Errorf("Owner Permissions = %d, want 2147483647", role.Permissions) + } +} + +func TestGetRoleByID_IsDefaultField(t *testing.T) { + database := newTestDB(t) + + owner, _ := database.GetRoleByID(1) + member, _ := database.GetRoleByID(4) + + if owner.IsDefault { + t.Error("Owner.IsDefault = true, want false") + } + // Member is the default role (is_default=1 in the migration). + if !member.IsDefault { + t.Error("Member.IsDefault = false, want true (Member is the default role for new users)") + } +} + +// ─── ListRoles tests ────────────────────────────────────────────────────────── + +func TestListRoles_ReturnsFourDefaultRoles(t *testing.T) { + database := newTestDB(t) + + roles, err := database.ListRoles() + if err != nil { + t.Fatalf("ListRoles: %v", err) + } + if len(roles) != 4 { + t.Errorf("ListRoles count = %d, want 4", len(roles)) + } +} + +func TestListRoles_OrderedByPositionDesc(t *testing.T) { + database := newTestDB(t) + + roles, err := database.ListRoles() + if err != nil { + t.Fatalf("ListRoles: %v", err) + } + + for i := 1; i < len(roles); i++ { + if roles[i].Position > roles[i-1].Position { + t.Errorf("ListRoles not ordered by position DESC: index %d (%d) > index %d (%d)", + i, roles[i].Position, i-1, roles[i-1].Position) + } + } +} + +// ─── ListInvites tests ──────────────────────────────────────────────────────── + +func TestListInvites_Empty(t *testing.T) { + database := newTestDB(t) + + invites, err := database.ListInvites() + if err != nil { + t.Fatalf("ListInvites empty: %v", err) + } + if len(invites) != 0 { + t.Errorf("ListInvites empty = %d items, want 0", len(invites)) + } +} + +func TestListInvites_Multiple(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("listowner", "hash", 4) + + _, _ = database.CreateInvite(uid, 1, nil) + _, _ = database.CreateInvite(uid, 5, nil) + _, _ = database.CreateInvite(uid, 0, nil) + + invites, err := database.ListInvites() + if err != nil { + t.Fatalf("ListInvites multiple: %v", err) + } + if len(invites) != 3 { + t.Errorf("ListInvites count = %d, want 3", len(invites)) + } +} + +func TestListInvites_IncludesRevokedInvites(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("revokelistowner", "hash", 4) + + code, _ := database.CreateInvite(uid, 1, nil) + _ = database.RevokeInvite(code) + _, _ = database.CreateInvite(uid, 0, nil) // active + + invites, err := database.ListInvites() + if err != nil { + t.Fatalf("ListInvites with revoked: %v", err) + } + if len(invites) != 2 { + t.Errorf("ListInvites count = %d, want 2", len(invites)) + } + + var revokedCount int + for _, inv := range invites { + if inv.Revoked { + revokedCount++ + } + } + if revokedCount != 1 { + t.Errorf("ListInvites revoked count = %d, want 1", revokedCount) + } +} diff --git a/Server/db/role_queries.go b/Server/db/role_queries.go new file mode 100644 index 00000000..2b7a6b4e --- /dev/null +++ b/Server/db/role_queries.go @@ -0,0 +1,49 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" +) + +// GetRoleByID returns the role with the given ID, or nil if not found. +func (d *DB) GetRoleByID(id int64) (*Role, error) { + row := d.sqlDB.QueryRow( + `SELECT id, name, color, permissions, position, is_default FROM roles WHERE id = ?`, + id, + ) + r := &Role{} + var isDefault int + err := row.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetRoleByID: %w", err) + } + r.IsDefault = isDefault != 0 + return r, nil +} + +// ListRoles returns all roles ordered by position descending. +func (d *DB) ListRoles() ([]*Role, error) { + rows, err := d.sqlDB.Query( + `SELECT id, name, color, permissions, position, is_default FROM roles ORDER BY position DESC`, + ) + if err != nil { + return nil, fmt.Errorf("ListRoles: %w", err) + } + defer rows.Close() //nolint:errcheck + + var roles []*Role + for rows.Next() { + r := &Role{} + var isDefault int + if err := rows.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault); err != nil { + return nil, fmt.Errorf("ListRoles scan: %w", err) + } + r.IsDefault = isDefault != 0 + roles = append(roles, r) + } + return roles, rows.Err() +} diff --git a/Server/db/voice_queries.go b/Server/db/voice_queries.go new file mode 100644 index 00000000..31b5a00f --- /dev/null +++ b/Server/db/voice_queries.go @@ -0,0 +1,263 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" +) + +// JoinVoiceChannel inserts or replaces the user's voice state for the given +// channel. If the user is already in a different channel, the old row is +// replaced. Muted, deafened, and speaking are reset to false on join. +func (d *DB) JoinVoiceChannel(userID, channelID int64) error { + _, err := d.sqlDB.Exec( + `INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare) + VALUES (?, ?, 0, 0, 0, 0, 0) + ON CONFLICT(user_id) DO UPDATE SET + channel_id = excluded.channel_id, + muted = 0, + deafened = 0, + speaking = 0, + camera = 0, + screenshare = 0, + joined_at = datetime('now')`, + userID, channelID, + ) + if err != nil { + return fmt.Errorf("JoinVoiceChannel: %w", err) + } + return nil +} + +// LeaveVoiceChannel removes the user's voice state entirely. +// It is safe to call when the user is not in any voice channel. +func (d *DB) LeaveVoiceChannel(userID int64) error { + _, err := d.sqlDB.Exec(`DELETE FROM voice_states WHERE user_id = ?`, userID) + if err != nil { + return fmt.Errorf("LeaveVoiceChannel: %w", err) + } + return nil +} + +// GetVoiceState returns the current voice state for the given user, +// or nil if the user is not in any voice channel. +func (d *DB) GetVoiceState(userID int64) (*VoiceState, error) { + row := d.sqlDB.QueryRow( + `SELECT vs.user_id, vs.channel_id, u.username, + vs.muted, vs.deafened, vs.speaking, + vs.camera, vs.screenshare + FROM voice_states vs + JOIN users u ON u.id = vs.user_id + WHERE vs.user_id = ?`, + userID, + ) + return scanVoiceState(row) +} + +// GetChannelVoiceStates returns all voice states for users currently in the +// given voice channel. +func (d *DB) GetChannelVoiceStates(channelID int64) ([]VoiceState, error) { + rows, err := d.sqlDB.Query( + `SELECT vs.user_id, vs.channel_id, u.username, + vs.muted, vs.deafened, vs.speaking, + vs.camera, vs.screenshare + FROM voice_states vs + JOIN users u ON u.id = vs.user_id + WHERE vs.channel_id = ? + ORDER BY vs.joined_at ASC`, + channelID, + ) + if err != nil { + return nil, fmt.Errorf("GetChannelVoiceStates: %w", err) + } + defer rows.Close() //nolint:errcheck + + var states []VoiceState + for rows.Next() { + vs, scanErr := scanVoiceStateRow(rows) + if scanErr != nil { + return nil, fmt.Errorf("GetChannelVoiceStates scan: %w", scanErr) + } + states = append(states, vs) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetChannelVoiceStates rows: %w", rows.Err()) + } + if states == nil { + states = []VoiceState{} + } + return states, nil +} + +// GetAllVoiceStates returns voice states across all voice channels in a single +// query. Used at startup to build the ready payload without N+1 per-channel queries. +func (d *DB) GetAllVoiceStates() ([]VoiceState, error) { + rows, err := d.sqlDB.Query( + `SELECT vs.user_id, vs.channel_id, u.username, + vs.muted, vs.deafened, vs.speaking, + vs.camera, vs.screenshare + FROM voice_states vs + JOIN users u ON u.id = vs.user_id + ORDER BY vs.channel_id, vs.joined_at ASC`, + ) + if err != nil { + return nil, fmt.Errorf("GetAllVoiceStates: %w", err) + } + defer rows.Close() //nolint:errcheck + + var states []VoiceState + for rows.Next() { + vs, scanErr := scanVoiceStateRow(rows) + if scanErr != nil { + return nil, fmt.Errorf("GetAllVoiceStates scan: %w", scanErr) + } + states = append(states, vs) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetAllVoiceStates rows: %w", rows.Err()) + } + if states == nil { + states = []VoiceState{} + } + return states, nil +} + +// UpdateVoiceMute sets the muted field for the given user's voice state. +// It is safe to call when the user is not in any channel (no-op). +func (d *DB) UpdateVoiceMute(userID int64, muted bool) error { + muteInt := boolToInt(muted) + _, err := d.sqlDB.Exec( + `UPDATE voice_states SET muted = ? WHERE user_id = ?`, + muteInt, userID, + ) + if err != nil { + return fmt.Errorf("UpdateVoiceMute: %w", err) + } + return nil +} + +// UpdateVoiceDeafen sets the deafened field for the given user's voice state. +// It is safe to call when the user is not in any channel (no-op). +func (d *DB) UpdateVoiceDeafen(userID int64, deafened bool) error { + deafenInt := boolToInt(deafened) + _, err := d.sqlDB.Exec( + `UPDATE voice_states SET deafened = ? WHERE user_id = ?`, + deafenInt, userID, + ) + if err != nil { + return fmt.Errorf("UpdateVoiceDeafen: %w", err) + } + return nil +} + +// ClearVoiceState removes a user's voice state on disconnect. +// Equivalent to LeaveVoiceChannel but named to clarify the disconnect use case. +func (d *DB) ClearVoiceState(userID int64) error { + _, err := d.sqlDB.Exec(`DELETE FROM voice_states WHERE user_id = ?`, userID) + if err != nil { + return fmt.Errorf("ClearVoiceState: %w", err) + } + return nil +} + +// ClearAllVoiceStates removes all voice state rows. Called on server startup +// to clear stale state from a previous run. +func (d *DB) ClearAllVoiceStates() error { + _, err := d.sqlDB.Exec(`DELETE FROM voice_states`) + if err != nil { + return fmt.Errorf("ClearAllVoiceStates: %w", err) + } + return nil +} + +// UpdateVoiceCamera sets the camera field for the given user's voice state. +func (d *DB) UpdateVoiceCamera(userID int64, camera bool) error { + _, err := d.sqlDB.Exec( + `UPDATE voice_states SET camera = ? WHERE user_id = ?`, + boolToInt(camera), userID, + ) + if err != nil { + return fmt.Errorf("UpdateVoiceCamera: %w", err) + } + return nil +} + +// UpdateVoiceScreenshare sets the screenshare field for the given user's voice state. +func (d *DB) UpdateVoiceScreenshare(userID int64, screenshare bool) error { + _, err := d.sqlDB.Exec( + `UPDATE voice_states SET screenshare = ? WHERE user_id = ?`, + boolToInt(screenshare), userID, + ) + if err != nil { + return fmt.Errorf("UpdateVoiceScreenshare: %w", err) + } + return nil +} + +// CountChannelVoiceUsers returns the number of users currently in the given +// voice channel. +func (d *DB) CountChannelVoiceUsers(channelID int64) (int, error) { + var count int + err := d.sqlDB.QueryRow( + `SELECT COUNT(*) FROM voice_states WHERE channel_id = ?`, + channelID, + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf("CountChannelVoiceUsers: %w", err) + } + return count, nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// scanVoiceState scans a single *sql.Row into a VoiceState. +// Returns nil (not an error) when the row is not found. +func scanVoiceState(row *sql.Row) (*VoiceState, error) { + vs := &VoiceState{} + var muted, deafened, speaking, camera, screenshare int + err := row.Scan( + &vs.UserID, &vs.ChannelID, &vs.Username, + &muted, &deafened, &speaking, + &camera, &screenshare, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scanVoiceState: %w", err) + } + vs.Muted = muted != 0 + vs.Deafened = deafened != 0 + vs.Speaking = speaking != 0 + vs.Camera = camera != 0 + vs.Screenshare = screenshare != 0 + return vs, nil +} + +// scanVoiceStateRow scans a single row from *sql.Rows into a VoiceState. +func scanVoiceStateRow(rows *sql.Rows) (VoiceState, error) { + vs := VoiceState{} + var muted, deafened, speaking, camera, screenshare int + err := rows.Scan( + &vs.UserID, &vs.ChannelID, &vs.Username, + &muted, &deafened, &speaking, + &camera, &screenshare, + ) + if err != nil { + return vs, fmt.Errorf("scanVoiceStateRow: %w", err) + } + vs.Muted = muted != 0 + vs.Deafened = deafened != 0 + vs.Speaking = speaking != 0 + vs.Camera = camera != 0 + vs.Screenshare = screenshare != 0 + return vs, nil +} + +// boolToInt converts a bool to 0/1 for SQLite storage. +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/Server/db/voice_queries_test.go b/Server/db/voice_queries_test.go new file mode 100644 index 00000000..17f68cdd --- /dev/null +++ b/Server/db/voice_queries_test.go @@ -0,0 +1,687 @@ +package db_test + +import ( + "testing" + "testing/fstest" + + "github.com/owncord/server/db" +) + +var channelSchema = []byte(` +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 10 +); +`) + +// newVoiceTestDB opens an in-memory DB with users, channels, and voice_states. +func newVoiceTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: testSchema}, + "002_channels.sql": {Data: channelSchema}, + "003_voice.sql": {Data: []byte(` +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); +`)}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// seedVoiceUser creates a user and returns its ID. +func seedVoiceUser(t *testing.T, database *db.DB, username string) int64 { + t.Helper() + id, err := database.CreateUser(username, "hash", 4) + if err != nil { + t.Fatalf("seedVoiceUser: %v", err) + } + return id +} + +// seedVoiceChannel creates a voice-type channel and returns its ID. +func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 { + t.Helper() + id, err := database.CreateChannel(name, "voice", "", "", 0) + if err != nil { + t.Fatalf("seedVoiceChannel: %v", err) + } + return id +} + +// ─── JoinVoiceChannel ───────────────────────────────────────────────────────── + +func TestVoice_JoinVoiceChannel_Success(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "alice") + chanID := seedVoiceChannel(t, database, "general-voice") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil { + t.Fatal("GetVoiceState returned nil after join") + } + if state.UserID != userID { + t.Errorf("UserID = %d, want %d", state.UserID, userID) + } + if state.ChannelID != chanID { + t.Errorf("ChannelID = %d, want %d", state.ChannelID, chanID) + } + if state.Muted { + t.Error("Muted = true after join, want false") + } + if state.Deafened { + t.Error("Deafened = true after join, want false") + } +} + +func TestVoice_JoinVoiceChannel_ReplacesExistingState(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "bob") + chan1 := seedVoiceChannel(t, database, "voice-1") + chan2 := seedVoiceChannel(t, database, "voice-2") + + if err := database.JoinVoiceChannel(userID, chan1); err != nil { + t.Fatalf("first JoinVoiceChannel: %v", err) + } + // Join a different channel — should replace the old state. + if err := database.JoinVoiceChannel(userID, chan2); err != nil { + t.Fatalf("second JoinVoiceChannel: %v", err) + } + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil { + t.Fatal("GetVoiceState returned nil after re-join") + } + if state.ChannelID != chan2 { + t.Errorf("ChannelID = %d, want %d (new channel)", state.ChannelID, chan2) + } +} + +func TestVoice_JoinVoiceChannel_SameChannel_Idempotent(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "carol") + chanID := seedVoiceChannel(t, database, "voice-same") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("first join: %v", err) + } + // Joining same channel again should not error. + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("second join same channel: %v", err) + } +} + +// ─── LeaveVoiceChannel ──────────────────────────────────────────────────────── + +func TestVoice_LeaveVoiceChannel_ClearsState(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "dave") + chanID := seedVoiceChannel(t, database, "voice-leave") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.LeaveVoiceChannel(userID); err != nil { + t.Fatalf("LeaveVoiceChannel: %v", err) + } + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState after leave: %v", err) + } + if state != nil { + t.Error("GetVoiceState returned non-nil after leave, want nil") + } +} + +func TestVoice_LeaveVoiceChannel_NoState_NoError(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "eve") + + // Leaving when not in any channel should not error. + if err := database.LeaveVoiceChannel(userID); err != nil { + t.Fatalf("LeaveVoiceChannel (not in channel): %v", err) + } +} + +// ─── GetVoiceState ──────────────────────────────────────────────────────────── + +func TestVoice_GetVoiceState_NotFound(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "frank") + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState(not found): %v", err) + } + if state != nil { + t.Error("GetVoiceState returned non-nil for user not in voice") + } +} + +func TestVoice_GetVoiceState_IncludesUsername(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "grace") + chanID := seedVoiceChannel(t, database, "voice-username") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil { + t.Fatal("GetVoiceState returned nil") + } + if state.Username != "grace" { + t.Errorf("Username = %q, want %q", state.Username, "grace") + } +} + +// ─── GetChannelVoiceStates ──────────────────────────────────────────────────── + +func TestVoice_GetChannelVoiceStates_Empty(t *testing.T) { + database := newVoiceTestDB(t) + chanID := seedVoiceChannel(t, database, "empty-voice") + + states, err := database.GetChannelVoiceStates(chanID) + if err != nil { + t.Fatalf("GetChannelVoiceStates: %v", err) + } + if len(states) != 0 { + t.Errorf("got %d states, want 0", len(states)) + } +} + +func TestVoice_GetChannelVoiceStates_MultipleUsers(t *testing.T) { + database := newVoiceTestDB(t) + u1 := seedVoiceUser(t, database, "henry") + u2 := seedVoiceUser(t, database, "iris") + u3 := seedVoiceUser(t, database, "jack") + chanID := seedVoiceChannel(t, database, "multi-voice") + otherChan := seedVoiceChannel(t, database, "other-voice") + + if err := database.JoinVoiceChannel(u1, chanID); err != nil { + t.Fatalf("join u1: %v", err) + } + if err := database.JoinVoiceChannel(u2, chanID); err != nil { + t.Fatalf("join u2: %v", err) + } + // u3 joins a different channel — should not appear. + if err := database.JoinVoiceChannel(u3, otherChan); err != nil { + t.Fatalf("join u3: %v", err) + } + + states, err := database.GetChannelVoiceStates(chanID) + if err != nil { + t.Fatalf("GetChannelVoiceStates: %v", err) + } + if len(states) != 2 { + t.Errorf("got %d states, want 2", len(states)) + } + + ids := map[int64]bool{u1: true, u2: true} + for _, s := range states { + if !ids[s.UserID] { + t.Errorf("unexpected user_id %d in channel states", s.UserID) + } + } +} + +// ─── UpdateVoiceMute ────────────────────────────────────────────────────────── + +func TestVoice_UpdateVoiceMute_True(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "kate") + chanID := seedVoiceChannel(t, database, "voice-mute") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceMute(userID, true); err != nil { + t.Fatalf("UpdateVoiceMute(true): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || !state.Muted { + t.Error("Muted = false after UpdateVoiceMute(true)") + } +} + +func TestVoice_UpdateVoiceMute_False(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "leo") + chanID := seedVoiceChannel(t, database, "voice-unmute") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceMute(userID, true); err != nil { + t.Fatalf("UpdateVoiceMute(true): %v", err) + } + if err := database.UpdateVoiceMute(userID, false); err != nil { + t.Fatalf("UpdateVoiceMute(false): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || state.Muted { + t.Error("Muted = true after UpdateVoiceMute(false), want false") + } +} + +func TestVoice_UpdateVoiceMute_NotInChannel_NoError(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "mia") + + // Muting when not in a channel should not error. + if err := database.UpdateVoiceMute(userID, true); err != nil { + t.Fatalf("UpdateVoiceMute for non-member: %v", err) + } +} + +// ─── UpdateVoiceDeafen ──────────────────────────────────────────────────────── + +func TestVoice_UpdateVoiceDeafen_True(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "noah") + chanID := seedVoiceChannel(t, database, "voice-deafen") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceDeafen(userID, true); err != nil { + t.Fatalf("UpdateVoiceDeafen(true): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || !state.Deafened { + t.Error("Deafened = false after UpdateVoiceDeafen(true)") + } +} + +func TestVoice_UpdateVoiceDeafen_False(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "olivia") + chanID := seedVoiceChannel(t, database, "voice-undeafen") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceDeafen(userID, true); err != nil { + t.Fatalf("UpdateVoiceDeafen(true): %v", err) + } + if err := database.UpdateVoiceDeafen(userID, false); err != nil { + t.Fatalf("UpdateVoiceDeafen(false): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || state.Deafened { + t.Error("Deafened = true after UpdateVoiceDeafen(false), want false") + } +} + +// ─── ClearVoiceState ────────────────────────────────────────────────────────── + +func TestVoice_ClearVoiceState_RemovesState(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "pedro") + chanID := seedVoiceChannel(t, database, "voice-clear") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.ClearVoiceState(userID); err != nil { + t.Fatalf("ClearVoiceState: %v", err) + } + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState after clear: %v", err) + } + if state != nil { + t.Error("GetVoiceState returned non-nil after ClearVoiceState") + } +} + +func TestVoice_ClearVoiceState_NotInChannel_NoError(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "quinn") + + if err := database.ClearVoiceState(userID); err != nil { + t.Fatalf("ClearVoiceState for non-member: %v", err) + } +} + +// ─── Cascade delete ─────────────────────────────────────────────────────────── + +func TestVoice_GetChannelVoiceStates_IncludesUsername(t *testing.T) { + database := newVoiceTestDB(t) + u1 := seedVoiceUser(t, database, "rachel") + chanID := seedVoiceChannel(t, database, "voice-name-check") + + if err := database.JoinVoiceChannel(u1, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + states, err := database.GetChannelVoiceStates(chanID) + if err != nil { + t.Fatalf("GetChannelVoiceStates: %v", err) + } + if len(states) != 1 { + t.Fatalf("got %d states, want 1", len(states)) + } + if states[0].Username != "rachel" { + t.Errorf("Username = %q, want %q", states[0].Username, "rachel") + } +} + +// ─── UpdateVoiceCamera ──────────────────────────────────────────────────────── + +func TestVoice_UpdateVoiceCamera_True(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "cam-on") + chanID := seedVoiceChannel(t, database, "voice-camera") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceCamera(userID, true); err != nil { + t.Fatalf("UpdateVoiceCamera(true): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || !state.Camera { + t.Error("Camera = false after UpdateVoiceCamera(true)") + } +} + +func TestVoice_UpdateVoiceCamera_False(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "cam-off") + chanID := seedVoiceChannel(t, database, "voice-camera-off") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceCamera(userID, true); err != nil { + t.Fatalf("UpdateVoiceCamera(true): %v", err) + } + if err := database.UpdateVoiceCamera(userID, false); err != nil { + t.Fatalf("UpdateVoiceCamera(false): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || state.Camera { + t.Error("Camera = true after UpdateVoiceCamera(false), want false") + } +} + +func TestVoice_UpdateVoiceCamera_NotInChannel_NoError(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "cam-noop") + + if err := database.UpdateVoiceCamera(userID, true); err != nil { + t.Fatalf("UpdateVoiceCamera for non-member: %v", err) + } +} + +// ─── UpdateVoiceScreenshare ────────────────────────────────────────────────── + +func TestVoice_UpdateVoiceScreenshare_True(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "share-on") + chanID := seedVoiceChannel(t, database, "voice-screen") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + t.Fatalf("UpdateVoiceScreenshare(true): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || !state.Screenshare { + t.Error("Screenshare = false after UpdateVoiceScreenshare(true)") + } +} + +func TestVoice_UpdateVoiceScreenshare_False(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "share-off") + chanID := seedVoiceChannel(t, database, "voice-screen-off") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + t.Fatalf("UpdateVoiceScreenshare(true): %v", err) + } + if err := database.UpdateVoiceScreenshare(userID, false); err != nil { + t.Fatalf("UpdateVoiceScreenshare(false): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || state.Screenshare { + t.Error("Screenshare = true after UpdateVoiceScreenshare(false), want false") + } +} + +// ─── CountChannelVoiceUsers ────────────────────────────────────────────────── + +func TestVoice_CountChannelVoiceUsers_Empty(t *testing.T) { + database := newVoiceTestDB(t) + chanID := seedVoiceChannel(t, database, "count-empty") + + count, err := database.CountChannelVoiceUsers(chanID) + if err != nil { + t.Fatalf("CountChannelVoiceUsers: %v", err) + } + if count != 0 { + t.Errorf("count = %d, want 0", count) + } +} + +func TestVoice_CountChannelVoiceUsers_Multiple(t *testing.T) { + database := newVoiceTestDB(t) + u1 := seedVoiceUser(t, database, "count1") + u2 := seedVoiceUser(t, database, "count2") + u3 := seedVoiceUser(t, database, "count3") + chanID := seedVoiceChannel(t, database, "count-multi") + otherChan := seedVoiceChannel(t, database, "count-other") + + if err := database.JoinVoiceChannel(u1, chanID); err != nil { + t.Fatalf("join u1: %v", err) + } + if err := database.JoinVoiceChannel(u2, chanID); err != nil { + t.Fatalf("join u2: %v", err) + } + // u3 joins a different channel — should not be counted. + if err := database.JoinVoiceChannel(u3, otherChan); err != nil { + t.Fatalf("join u3: %v", err) + } + + count, err := database.CountChannelVoiceUsers(chanID) + if err != nil { + t.Fatalf("CountChannelVoiceUsers: %v", err) + } + if count != 2 { + t.Errorf("count = %d, want 2", count) + } +} + +// ─── ClearAllVoiceStates ───────────────────────────────────────────────────── + +func TestVoice_ClearAllVoiceStates_RemovesAll(t *testing.T) { + database := newVoiceTestDB(t) + u1 := seedVoiceUser(t, database, "clear1") + u2 := seedVoiceUser(t, database, "clear2") + chan1 := seedVoiceChannel(t, database, "clear-ch1") + chan2 := seedVoiceChannel(t, database, "clear-ch2") + + if err := database.JoinVoiceChannel(u1, chan1); err != nil { + t.Fatalf("join u1: %v", err) + } + if err := database.JoinVoiceChannel(u2, chan2); err != nil { + t.Fatalf("join u2: %v", err) + } + + if err := database.ClearAllVoiceStates(); err != nil { + t.Fatalf("ClearAllVoiceStates: %v", err) + } + + s1, _ := database.GetVoiceState(u1) + s2, _ := database.GetVoiceState(u2) + if s1 != nil || s2 != nil { + t.Error("voice states still exist after ClearAllVoiceStates") + } +} + +func TestVoice_ClearAllVoiceStates_EmptyTable_NoError(t *testing.T) { + database := newVoiceTestDB(t) + + if err := database.ClearAllVoiceStates(); err != nil { + t.Fatalf("ClearAllVoiceStates on empty table: %v", err) + } +} + +// ─── JoinVoiceChannel resets camera/screenshare ────────────────────────────── + +func TestVoice_JoinVoiceChannel_ResetsCameraAndScreenshare(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "reset-av") + chan1 := seedVoiceChannel(t, database, "voice-reset1") + chan2 := seedVoiceChannel(t, database, "voice-reset2") + + // Join, enable camera and screenshare. + if err := database.JoinVoiceChannel(userID, chan1); err != nil { + t.Fatalf("first join: %v", err) + } + if err := database.UpdateVoiceCamera(userID, true); err != nil { + t.Fatalf("UpdateVoiceCamera: %v", err) + } + if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + t.Fatalf("UpdateVoiceScreenshare: %v", err) + } + + // Join a different channel — camera and screenshare should be reset. + if err := database.JoinVoiceChannel(userID, chan2); err != nil { + t.Fatalf("second join: %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil { + t.Fatal("GetVoiceState returned nil after re-join") + } + if state.Camera { + t.Error("Camera should be reset to false on re-join") + } + if state.Screenshare { + t.Error("Screenshare should be reset to false on re-join") + } +} + +// ─── Camera/Screenshare in GetVoiceState ───────────────────────────────────── + +func TestVoice_GetVoiceState_IncludesCameraAndScreenshare(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "av-fields") + chanID := seedVoiceChannel(t, database, "voice-av-fields") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + // Initially both should be false. + state, _ := database.GetVoiceState(userID) + if state == nil { + t.Fatal("GetVoiceState returned nil") + } + if state.Camera { + t.Error("Camera should be false after join") + } + if state.Screenshare { + t.Error("Screenshare should be false after join") + } + + // Enable both. + _ = database.UpdateVoiceCamera(userID, true) + _ = database.UpdateVoiceScreenshare(userID, true) + + state, _ = database.GetVoiceState(userID) + if state == nil { + t.Fatal("GetVoiceState returned nil after update") + } + if !state.Camera { + t.Error("Camera should be true after UpdateVoiceCamera(true)") + } + if !state.Screenshare { + t.Error("Screenshare should be true after UpdateVoiceScreenshare(true)") + } +} + +// ─── Camera/Screenshare in GetChannelVoiceStates ───────────────────────────── + +func TestVoice_GetChannelVoiceStates_IncludesCameraAndScreenshare(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "chan-av") + chanID := seedVoiceChannel(t, database, "voice-chan-av") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + _ = database.UpdateVoiceCamera(userID, true) + + states, err := database.GetChannelVoiceStates(chanID) + if err != nil { + t.Fatalf("GetChannelVoiceStates: %v", err) + } + if len(states) != 1 { + t.Fatalf("got %d states, want 1", len(states)) + } + if !states[0].Camera { + t.Error("Camera should be true in GetChannelVoiceStates") + } + if states[0].Screenshare { + t.Error("Screenshare should be false in GetChannelVoiceStates") + } +} diff --git a/Server/go.mod b/Server/go.mod new file mode 100644 index 00000000..089986ca --- /dev/null +++ b/Server/go.mod @@ -0,0 +1,59 @@ +module github.com/owncord/server + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/knadh/koanf/parsers/yaml v1.1.0 + github.com/knadh/koanf/providers/env v1.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/providers/structs v1.0.0 + github.com/knadh/koanf/v2 v2.3.3 + github.com/microcosm-cc/bluemonday v1.0.27 + go.yaml.in/yaml/v3 v3.0.3 + golang.org/x/crypto v0.49.0 + golang.org/x/mod v0.34.0 + modernc.org/sqlite v1.46.1 + nhooyr.io/websocket v1.8.17 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/structs v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/ice/v4 v4.2.1 // indirect + github.com/pion/interceptor v0.1.44 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/rtp v1.10.1 // indirect + github.com/pion/sctp v1.9.2 // indirect + github.com/pion/sdp/v3 v3.0.18 // indirect + github.com/pion/srtp/v3 v3.0.10 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.1.4 // indirect + github.com/pion/webrtc/v4 v4.2.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.10.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/Server/go.sum b/Server/go.sum new file mode 100644 index 00000000..9f916854 --- /dev/null +++ b/Server/go.sum @@ -0,0 +1,144 @@ +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ5KvfgMbio+23u4= +github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w= +github.com/knadh/koanf/v2 v2.3.3 h1:jLJC8XCRfLC7n4F+ZKKdBsbq1bfXTpuFhf4L7t94D94= +github.com/knadh/koanf/v2 v2.3.3/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY= +github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c= +github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I= +github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= +github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo= +github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8= +github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= +github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= +github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= +github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= +github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= +github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE= +github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/Server/main.go b/Server/main.go new file mode 100644 index 00000000..8d038238 --- /dev/null +++ b/Server/main.go @@ -0,0 +1,290 @@ +// OwnCord chat server — self-hosted, Windows-native. +// Build: go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . +package main + +import ( + "context" + "errors" + "fmt" + "io" + stdlog "log" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "runtime" + "strings" + "syscall" + "time" + + "github.com/owncord/server/admin" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// version is overridden at build time via -ldflags "-X main.version=1.0.0". +var version = "dev" + +func main() { + // Create ring buffer for admin log viewer, then build a multi-handler + // that tees log records to both stdout (INFO+) and the ring buffer (DEBUG+). + logBuf := admin.NewRingBuffer(2000) + stdoutHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}) + multiHandler := admin.NewMultiHandler(stdoutHandler, logBuf, slog.LevelDebug) + log := slog.New(multiHandler) + slog.SetDefault(log) + + if err := run(log, logBuf); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "\n [ERROR] %v\n\n", err) + log.Error("server exited with error", "error", err) + os.Exit(1) + } +} + +// run is the real entrypoint — separated for testability. +func run(log *slog.Logger, logBuf *admin.RingBuffer) error { + // Clean up old binary from a previous update. + if exePath, err := os.Executable(); err == nil { + oldPath := exePath + ".old" + if _, statErr := os.Stat(oldPath); statErr == nil { + if rmErr := os.Remove(oldPath); rmErr != nil { + log.Warn("failed to remove old binary", "path", oldPath, "error", rmErr) + } else { + log.Info("removed old binary from previous update", "path", oldPath) + } + } + } + + // ── 1. Load configuration ────────────────────────────────────────────── + cfg, err := config.Load("config.yaml") + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + // ── 2. Ensure data directory exists ──────────────────────────────────── + if mkdirErr := os.MkdirAll(cfg.Server.DataDir, 0o755); mkdirErr != nil { + return fmt.Errorf("creating data dir %s: %w", cfg.Server.DataDir, mkdirErr) + } + + // ── 3. TLS ──────────────────────────────────────────────────────────── + tlsResult, err := auth.LoadOrGenerate(cfg.TLS) + if err != nil { + return fmt.Errorf("configuring TLS: %w", err) + } + tlsCfg := tlsResult.TLSConfig + + // Print startup banner first so it appears above all init logs. + printBanner(cfg, version, tlsCfg != nil) + + // ── 4. Open database + run migrations ───────────────────────────────── + database, err := db.Open(cfg.Database.Path) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer database.Close() //nolint:errcheck + + if err := db.Migrate(database); err != nil { + return fmt.Errorf("running migrations: %w", err) + } + + // Clear stale state from a previous run or crash. + if err := database.ResetAllUserStatuses(); err != nil { + log.Warn("failed to reset stale user statuses", "error", err) + } else { + log.Info("reset all user statuses to offline") + } + if err := database.ClearAllVoiceStates(); err != nil { + log.Warn("failed to clear stale voice states", "error", err) + } else { + log.Info("cleared stale voice states") + } + + // ── 5. Build HTTP router ─────────────────────────────────────────────── + router, hub := api.NewRouter(cfg, database, version, logBuf) + + // ── 6. Start server ──────────────────────────────────────────────────── + addr := fmt.Sprintf(":%d", cfg.Server.Port) + srv := &http.Server{ + Addr: addr, + Handler: router, + TLSConfig: tlsCfg, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + ErrorLog: stdlog.New(io.Discard, "", 0), // suppress TLS handshake noise + } + + // ── 6b. ACME HTTP challenge server on :80 ───────────────────────────── + // When using Let's Encrypt (tls.mode: acme), an HTTP server on port 80 + // is needed for HTTP-01 challenge validation and HTTP→HTTPS redirect. + var acmeSrv *http.Server + if tlsResult.HTTPHandler != nil { + acmeSrv = &http.Server{ + Addr: ":80", + Handler: tlsResult.HTTPHandler, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + go func() { + log.Info("ACME HTTP challenge server starting on :80") + if err := acmeSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("ACME HTTP server error", "error", err) + } + }() + } + + // ── 7. Background maintenance ──────────────────────────────────────── + // Periodically purge expired sessions to prevent unbounded growth. + stopMaintenance := make(chan struct{}) + go func() { + ticker := time.NewTicker(15 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := database.DeleteExpiredSessions(); err != nil { + log.Warn("failed to delete expired sessions", "error", err) + } + case <-stopMaintenance: + return + } + } + }() + + // Listen for OS signals for graceful shutdown. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // Start serving in a goroutine. + serveErr := make(chan error, 1) + go func() { + log.Info("server starting", "addr", addr, "tls", tlsCfg != nil, "version", version) + + for attempt := 0; attempt < 20; attempt++ { + var listenErr error + if tlsCfg != nil { + listenErr = srv.ListenAndServeTLS("", "") + } else { + listenErr = srv.ListenAndServe() + } + if listenErr != nil && !errors.Is(listenErr, http.ErrServerClosed) { + // Check if it's an "address already in use" error (port not released yet from old process) + if attempt < 19 && isAddrInUse(listenErr) { + log.Warn("port in use, retrying...", "attempt", attempt+1, "error", listenErr) + time.Sleep(500 * time.Millisecond) + continue + } + serveErr <- listenErr + } + break + } + close(serveErr) + }() + + // Wait for shutdown signal or server error. + select { + case err := <-serveErr: + if err != nil { + return fmt.Errorf("server error: %w", err) + } + case <-ctx.Done(): + log.Info("shutdown signal received, draining connections (30s timeout)") + } + + // Graceful shutdown. + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if acmeSrv != nil { + if err := acmeSrv.Shutdown(shutdownCtx); err != nil { + log.Warn("ACME HTTP server shutdown error", "error", err) + } + } + + // Stop the WebSocket hub: close all PeerConnections, voice rooms, and + // notify connected clients before draining HTTP connections. + hub.GracefulStop() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("graceful shutdown: %w", err) + } + + close(stopMaintenance) + log.Info("server stopped cleanly") + return nil +} + +// isAddrInUse checks if an error is an "address already in use" error. +func isAddrInUse(err error) bool { + return err != nil && (strings.Contains(err.Error(), "address already in use") || strings.Contains(err.Error(), "Only one usage of each socket address")) +} + +// printBanner writes the startup banner to stderr (so it doesn't mix with +// JSON-structured log output on stdout). +func printBanner(cfg *config.Config, ver string, tls bool) { + scheme := "http" + if tls { + scheme = "https" + } + + localIP := getOutboundIP() + port := cfg.Server.Port + baseURL := fmt.Sprintf("%s://%s:%d", scheme, localIP, port) + adminURL := baseURL + "/admin" + + tlsStatus := "disabled" + if tls { + tlsStatus = "enabled" + } + + banner := fmt.Sprintf(` + + ___ ____ _ + / _ \__ ___ __ / ___|___ _ __ __| | + | | | \ \ /\ / / '_ \| | / _ \| '__/ _`+"`"+` | + | |_| |\ V V /| | | | |__| (_) | | | (_| | + \___/ \_/\_/ |_| |_|\____\___/|_| \__,_| + + ───────────────────────────────────────────── + Server %s + Version %s + TLS %s + Platform %s/%s + ───────────────────────────────────────────── + API %s/api/v1/info + WebSocket %s/api/v1/ws + Admin %s + Health %s/health + ───────────────────────────────────────────── + Press Ctrl+C to stop the server. + +`, cfg.Server.Name, ver, tlsStatus, runtime.GOOS, runtime.GOARCH, + baseURL, wsURL(scheme, localIP, port), adminURL, baseURL) + + _, _ = fmt.Fprint(os.Stderr, banner) +} + +// wsURL builds the WebSocket URL with the correct scheme. +func wsURL(httpScheme, ip string, port int) string { + ws := "ws" + if httpScheme == "https" { + ws = "wss" + } + return fmt.Sprintf("%s://%s:%d", ws, ip, port) +} + +// getOutboundIP returns the preferred outbound IP of this machine by dialing +// a known external address (no actual connection is made with UDP). +func getOutboundIP() string { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return "localhost" + } + defer conn.Close() //nolint:errcheck + addr := conn.LocalAddr().(*net.UDPAddr) + return addr.IP.String() +} + diff --git a/Server/migrations/001_initial_schema.sql b/Server/migrations/001_initial_schema.sql new file mode 100644 index 00000000..e0cc11f0 --- /dev/null +++ b/Server/migrations/001_initial_schema.sql @@ -0,0 +1,201 @@ +-- Migration 001: Initial schema +-- All tables for the OwnCord server database. + +-- Roles must be created before users (foreign key dependency). +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT, + permissions INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +-- Insert default roles on first run. +INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) +VALUES + (1, 'Owner', '#E74C3C', 0x7FFFFFFF, 100, 0), + (2, 'Admin', '#F39C12', 0x3FFFFFFF, 80, 0), + (3, 'Moderator', '#3498DB', 0x000FFFFF, 60, 0), + (4, 'Member', NULL, 0x00000663, 40, 1); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + avatar TEXT, + role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, + status TEXT NOT NULL DEFAULT 'offline', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS channel_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + UNIQUE(channel_id, role_id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, + edited_at TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id); + +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + content, + content='messages', + content_rowid='id' +); + +CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; + +CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); +END; + +CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; + +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + stored_as TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + UNIQUE(message_id, user_id, emoji) +); + +CREATE TABLE IF NOT EXISTS invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + redeemed_by INTEGER REFERENCES users(id), + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); + +CREATE TABLE IF NOT EXISTS read_states ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + last_message_id INTEGER NOT NULL DEFAULT 0, + mention_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, channel_id) +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT, + target_id INTEGER, + details TEXT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp DESC); + +CREATE TABLE IF NOT EXISTS login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT NOT NULL, + username TEXT, + success INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_login_ip ON login_attempts(ip_address, timestamp); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +-- Default server settings. +INSERT OR IGNORE INTO settings (key, value) VALUES + ('server_name', 'OwnCord Server'), + ('server_icon', ''), + ('motd', 'Welcome!'), + ('max_upload_bytes', '26214400'), + ('voice_quality', 'high'), + ('require_2fa', '0'), + ('registration_open', '0'), + ('backup_schedule', 'daily'), + ('backup_retention', '7'), + ('schema_version', '1'); + +CREATE TABLE IF NOT EXISTS emoji ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + shortcode TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL, + uploaded_by INTEGER NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS sounds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + filename TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + uploaded_by INTEGER NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/Server/migrations/002_voice_states.sql b/Server/migrations/002_voice_states.sql new file mode 100644 index 00000000..939093a2 --- /dev/null +++ b/Server/migrations/002_voice_states.sql @@ -0,0 +1,12 @@ +-- Phase 5: Voice state tracking table. +-- Stores which voice channel each user is currently connected to, +-- along with their mute/deafen/speaking state. +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); diff --git a/Server/migrations/003_audit_log.sql b/Server/migrations/003_audit_log.sql new file mode 100644 index 00000000..6155ddbe --- /dev/null +++ b/Server/migrations/003_audit_log.sql @@ -0,0 +1,35 @@ +-- Migration 003: Re-create audit_log with Phase-6 canonical column names. +-- +-- Phase-1 audit_log used: user_id (nullable), action, target_type, target_id, +-- details, timestamp +-- Phase-6 audit_log uses: actor_id (NOT NULL DEFAULT 0), action, target_type, +-- target_id, detail, created_at +-- +-- IDEMPOTENCY +-- ----------- +-- This migration is safe to re-run: +-- 1. CREATE TABLE IF NOT EXISTS audit_log_v6 → no-op if already exists +-- 2. DROP TABLE IF EXISTS audit_log → no-op if already gone +-- 3. ALTER TABLE audit_log_v6 RENAME TO audit_log → recreates the table +-- +-- On second run audit_log_v6 is created fresh (empty), the current audit_log +-- is dropped, and audit_log_v6 is renamed. Audit log data is not preserved +-- across re-runs, which is acceptable for a development-phase migration. + +CREATE TABLE IF NOT EXISTS audit_log_v6 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL DEFAULT 0, + action TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT '', + target_id INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +DROP TABLE IF EXISTS audit_log; + +ALTER TABLE audit_log_v6 RENAME TO audit_log; + +-- Keep the legacy index name so db_test.go TestMigrateCreatesIndexes passes. +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); diff --git a/Server/migrations/003_voice_optimization.sql b/Server/migrations/003_voice_optimization.sql new file mode 100644 index 00000000..e1b882b3 --- /dev/null +++ b/Server/migrations/003_voice_optimization.sql @@ -0,0 +1,9 @@ +-- Phase 5b: Voice optimization — add camera/screenshare tracking and +-- per-channel voice configuration for the Pion SFU. +ALTER TABLE voice_states ADD COLUMN camera INTEGER NOT NULL DEFAULT 0; +ALTER TABLE voice_states ADD COLUMN screenshare INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE channels ADD COLUMN voice_max_users INTEGER NOT NULL DEFAULT 0; +ALTER TABLE channels ADD COLUMN voice_quality TEXT; +ALTER TABLE channels ADD COLUMN mixing_threshold INTEGER; +ALTER TABLE channels ADD COLUMN voice_max_video INTEGER NOT NULL DEFAULT 10; diff --git a/Server/migrations/004_fix_member_permissions.sql b/Server/migrations/004_fix_member_permissions.sql new file mode 100644 index 00000000..186cdac5 --- /dev/null +++ b/Server/migrations/004_fix_member_permissions.sql @@ -0,0 +1,6 @@ +-- Migration 004: Fix Member role permissions +-- The Member role was missing READ_MESSAGES (0x2), ATTACH_FILES (0x20), +-- and ADD_REACTIONS (0x40) bits. Also had MUTE_MEMBERS which is mod-level. +-- New value: 0x663 = SEND_MESSAGES | READ_MESSAGES | ATTACH_FILES | +-- ADD_REACTIONS | CONNECT_VOICE | SPEAK_VOICE +UPDATE roles SET permissions = 1635 WHERE id = 4 AND name = 'Member'; diff --git a/Server/migrations/005_channel_overrides_index.sql b/Server/migrations/005_channel_overrides_index.sql new file mode 100644 index 00000000..b924391d --- /dev/null +++ b/Server/migrations/005_channel_overrides_index.sql @@ -0,0 +1,4 @@ +-- Add composite index on channel_overrides for permission lookups. +-- This prevents N+1 query degradation when listing channels with overrides. +CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel_role + ON channel_overrides(channel_id, role_id); diff --git a/Server/migrations/migrations.go b/Server/migrations/migrations.go new file mode 100644 index 00000000..c33dc4b7 --- /dev/null +++ b/Server/migrations/migrations.go @@ -0,0 +1,9 @@ +// Package migrations holds embedded SQL migration files for the OwnCord server. +package migrations + +import "embed" + +// FS holds all migration SQL files embedded at compile time. +// +//go:embed *.sql +var FS embed.FS diff --git a/Server/permissions/permissions.go b/Server/permissions/permissions.go new file mode 100644 index 00000000..bf9b11ca --- /dev/null +++ b/Server/permissions/permissions.go @@ -0,0 +1,70 @@ +// Package permissions provides the canonical permission bit constants and +// role ID constants for the OwnCord server. All other packages must import +// from here instead of defining their own local copies. +package permissions + +// ─── Permission bit constants (from SCHEMA.md) ─────────────────────────────── + +const ( + SendMessages = int64(0x0001) // bit 0 + ReadMessages = int64(0x0002) // bit 1 + AttachFiles = int64(0x0020) // bit 5 + AddReactions = int64(0x0040) // bit 6 + UseSoundboard = int64(0x0100) // bit 8 + ConnectVoice = int64(0x0200) // bit 9 + SpeakVoice = int64(0x0400) // bit 10 + UseVideo = int64(0x0800) // bit 11 + ShareScreen = int64(0x1000) // bit 12 + ManageMessages = int64(0x10000) // bit 16 + ManageChannels = int64(0x20000) // bit 17 + KickMembers = int64(0x40000) // bit 18 + BanMembers = int64(0x80000) // bit 19 + MuteMembers = int64(0x100000) // bit 20 + ManageRoles = int64(0x1000000) // bit 24 + ManageServer = int64(0x2000000) // bit 25 + ManageInvites = int64(0x4000000) // bit 26 + ViewAuditLog = int64(0x8000000) // bit 27 + Administrator = int64(0x40000000) // bit 30 — bypasses all permission checks +) + +// ─── Role ID constants (default roles inserted on first run) ───────────────── + +const ( + OwnerRoleID = int64(1) + AdminRoleID = int64(2) + ModeratorRoleID = int64(3) + MemberRoleID = int64(4) +) + +// OwnerRolePosition is the hierarchy position of the owner role. Roles with a +// position below this value cannot modify the owner role or perform privileged +// operations reserved for the owner. +const OwnerRolePosition = 100 + +// ─── Permission helper functions ───────────────────────────────────────────── + +// HasPerm reports whether rolePerms contains all bits in requiredPerm. +// Returns false when requiredPerm is zero because zero is not a valid bit. +func HasPerm(rolePerms, requiredPerm int64) bool { + if requiredPerm == 0 { + return false + } + return rolePerms&requiredPerm == requiredPerm +} + +// HasAdmin reports whether rolePerms includes the Administrator bit, which +// grants unconditional access to all operations. +func HasAdmin(rolePerms int64) bool { + return rolePerms&Administrator != 0 +} + +// EffectivePerms computes the resolved permission set for a channel override. +// The formula matches Discord's channel override semantics: +// +// effective = (rolePerm & ^deny) | allow +// +// deny is applied first (strips bits), then allow is applied (adds bits), +// so allow takes precedence over deny when both target the same bit. +func EffectivePerms(rolePerm, allow, deny int64) int64 { + return (rolePerm &^ deny) | allow +} diff --git a/Server/permissions/permissions_test.go b/Server/permissions/permissions_test.go new file mode 100644 index 00000000..a6d89b8c --- /dev/null +++ b/Server/permissions/permissions_test.go @@ -0,0 +1,256 @@ +package permissions_test + +import ( + "testing" + + "github.com/owncord/server/permissions" +) + +// ─── Constant value tests ───────────────────────────────────────────────────── + +// TestPermissionBitValues verifies every constant matches the SCHEMA.md bitfield. +func TestPermissionBitValues(t *testing.T) { + cases := []struct { + name string + got int64 + expected int64 + }{ + {"SendMessages", permissions.SendMessages, 0x0001}, + {"ReadMessages", permissions.ReadMessages, 0x0002}, + {"AttachFiles", permissions.AttachFiles, 0x0020}, + {"AddReactions", permissions.AddReactions, 0x0040}, + {"UseSoundboard", permissions.UseSoundboard, 0x0100}, + {"ConnectVoice", permissions.ConnectVoice, 0x0200}, + {"SpeakVoice", permissions.SpeakVoice, 0x0400}, + {"UseVideo", permissions.UseVideo, 0x0800}, + {"ShareScreen", permissions.ShareScreen, 0x1000}, + {"ManageMessages", permissions.ManageMessages, 0x10000}, + {"ManageChannels", permissions.ManageChannels, 0x20000}, + {"KickMembers", permissions.KickMembers, 0x40000}, + {"BanMembers", permissions.BanMembers, 0x80000}, + {"MuteMembers", permissions.MuteMembers, 0x100000}, + {"ManageRoles", permissions.ManageRoles, 0x1000000}, + {"ManageServer", permissions.ManageServer, 0x2000000}, + {"ManageInvites", permissions.ManageInvites, 0x4000000}, + {"ViewAuditLog", permissions.ViewAuditLog, 0x8000000}, + {"Administrator", permissions.Administrator, 0x40000000}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.expected { + t.Errorf("%s: got 0x%X, want 0x%X", tc.name, tc.got, tc.expected) + } + }) + } +} + +// TestRoleIDConstants verifies the predefined role IDs match SCHEMA.md defaults. +func TestRoleIDConstants(t *testing.T) { + cases := []struct { + name string + got int64 + expected int64 + }{ + {"OwnerRoleID", permissions.OwnerRoleID, 1}, + {"AdminRoleID", permissions.AdminRoleID, 2}, + {"ModeratorRoleID", permissions.ModeratorRoleID, 3}, + {"MemberRoleID", permissions.MemberRoleID, 4}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.expected { + t.Errorf("%s: got %d, want %d", tc.name, tc.got, tc.expected) + } + }) + } +} + +// TestOwnerRolePosition verifies the owner position sentinel value. +func TestOwnerRolePosition(t *testing.T) { + if permissions.OwnerRolePosition != 100 { + t.Errorf("OwnerRolePosition: got %d, want 100", permissions.OwnerRolePosition) + } +} + +// ─── HasPerm tests ──────────────────────────────────────────────────────────── + +func TestHasPerm_MatchingBitReturnsTrue(t *testing.T) { + rolePerms := permissions.SendMessages | permissions.ReadMessages | permissions.ConnectVoice + if !permissions.HasPerm(rolePerms, permissions.SendMessages) { + t.Error("expected HasPerm to return true when bit is set") + } +} + +func TestHasPerm_MissingBitReturnsFalse(t *testing.T) { + rolePerms := permissions.ReadMessages | permissions.ConnectVoice + if permissions.HasPerm(rolePerms, permissions.SendMessages) { + t.Error("expected HasPerm to return false when bit is not set") + } +} + +func TestHasPerm_ZeroPermsReturnsFalse(t *testing.T) { + if permissions.HasPerm(0, permissions.SendMessages) { + t.Error("expected HasPerm(0, ...) to return false") + } +} + +func TestHasPerm_ZeroRequiredReturnsFalse(t *testing.T) { + // Requiring perm 0 should never match — 0 is not a valid permission bit. + if permissions.HasPerm(permissions.Administrator, 0) { + t.Error("expected HasPerm(..., 0) to return false for zero required perm") + } +} + +func TestHasPerm_MultipleBitsSetOnlyChecksRequired(t *testing.T) { + // rolePerms has many bits; we ask about one that is present. + rolePerms := permissions.SendMessages | permissions.ManageMessages | permissions.BanMembers + if !permissions.HasPerm(rolePerms, permissions.ManageMessages) { + t.Error("expected HasPerm to find ManageMessages in combined bitfield") + } +} + +func TestHasPerm_AllBitsSet(t *testing.T) { + // 0x7FFFFFFF (Owner default) must satisfy every individual permission. + allPerms := int64(0x7FFFFFFF) + perms := []int64{ + permissions.SendMessages, permissions.ReadMessages, permissions.AttachFiles, + permissions.AddReactions, permissions.UseSoundboard, permissions.ConnectVoice, + permissions.SpeakVoice, permissions.UseVideo, permissions.ShareScreen, + permissions.ManageMessages, permissions.ManageChannels, permissions.KickMembers, + permissions.BanMembers, permissions.MuteMembers, permissions.ManageRoles, + permissions.ManageServer, permissions.ManageInvites, permissions.ViewAuditLog, + permissions.Administrator, + } + for _, p := range perms { + if !permissions.HasPerm(allPerms, p) { + t.Errorf("expected all-bits owner to have perm 0x%X", p) + } + } +} + +// ─── HasAdmin tests ─────────────────────────────────────────────────────────── + +func TestHasAdmin_AdministratorBitSet(t *testing.T) { + if !permissions.HasAdmin(permissions.Administrator) { + t.Error("expected HasAdmin to return true when Administrator bit is set") + } +} + +func TestHasAdmin_AdministratorBitWithOthers(t *testing.T) { + combined := permissions.SendMessages | permissions.Administrator | permissions.BanMembers + if !permissions.HasAdmin(combined) { + t.Error("expected HasAdmin to return true with Administrator bit among others") + } +} + +func TestHasAdmin_NoAdministratorBit(t *testing.T) { + if permissions.HasAdmin(permissions.SendMessages | permissions.BanMembers) { + t.Error("expected HasAdmin to return false without Administrator bit") + } +} + +func TestHasAdmin_ZeroPerms(t *testing.T) { + if permissions.HasAdmin(0) { + t.Error("expected HasAdmin(0) to return false") + } +} + +func TestHasAdmin_AdminRolePermsMissingBit(t *testing.T) { + // Admin role default is 0x3FFFFFFF — bit 30 (Administrator) is NOT set. + adminDefault := int64(0x3FFFFFFF) + if permissions.HasAdmin(adminDefault) { + t.Error("expected HasAdmin to return false for Admin role (0x3FFFFFFF lacks bit 30)") + } +} + +func TestHasAdmin_OwnerRolePermsHasBit(t *testing.T) { + // Owner role default is 0x7FFFFFFF — bit 30 IS set. + ownerDefault := int64(0x7FFFFFFF) + if !permissions.HasAdmin(ownerDefault) { + t.Error("expected HasAdmin to return true for Owner role (0x7FFFFFFF has bit 30)") + } +} + +// ─── EffectivePerms tests ───────────────────────────────────────────────────── + +// EffectivePerms(rolePerm, allow, deny) = (rolePerm & ^deny) | allow + +func TestEffectivePerms_NoOverrides(t *testing.T) { + base := permissions.SendMessages | permissions.ReadMessages + got := permissions.EffectivePerms(base, 0, 0) + if got != base { + t.Errorf("EffectivePerms with no overrides: got 0x%X, want 0x%X", got, base) + } +} + +func TestEffectivePerms_AllowAddsPermission(t *testing.T) { + base := permissions.ReadMessages + allow := permissions.SendMessages + got := permissions.EffectivePerms(base, allow, 0) + want := permissions.ReadMessages | permissions.SendMessages + if got != want { + t.Errorf("EffectivePerms allow: got 0x%X, want 0x%X", got, want) + } +} + +func TestEffectivePerms_DenyRemovesPermission(t *testing.T) { + base := permissions.SendMessages | permissions.ReadMessages | permissions.ConnectVoice + deny := permissions.ConnectVoice + got := permissions.EffectivePerms(base, 0, deny) + want := permissions.SendMessages | permissions.ReadMessages + if got != want { + t.Errorf("EffectivePerms deny: got 0x%X, want 0x%X", got, want) + } +} + +func TestEffectivePerms_AllowAndDenyTogether(t *testing.T) { + // deny removes ConnectVoice; allow grants ManageMessages. + base := permissions.SendMessages | permissions.ReadMessages | permissions.ConnectVoice + allow := permissions.ManageMessages + deny := permissions.ConnectVoice + got := permissions.EffectivePerms(base, allow, deny) + want := permissions.SendMessages | permissions.ReadMessages | permissions.ManageMessages + if got != want { + t.Errorf("EffectivePerms allow+deny: got 0x%X, want 0x%X", got, want) + } +} + +func TestEffectivePerms_AllowOverridesDeny(t *testing.T) { + // When both allow and deny target the same bit, allow wins + // because the formula applies deny first, then allow. + base := permissions.SendMessages + allow := permissions.ConnectVoice + deny := permissions.ConnectVoice + got := permissions.EffectivePerms(base, allow, deny) + // deny strips ConnectVoice, then allow adds it back. + want := permissions.SendMessages | permissions.ConnectVoice + if got != want { + t.Errorf("EffectivePerms allow overrides deny: got 0x%X, want 0x%X", got, want) + } +} + +func TestEffectivePerms_ZeroBase(t *testing.T) { + allow := permissions.SendMessages | permissions.ReadMessages + got := permissions.EffectivePerms(0, allow, 0) + if got != allow { + t.Errorf("EffectivePerms zero base: got 0x%X, want 0x%X", got, allow) + } +} + +func TestEffectivePerms_ZeroAll(t *testing.T) { + got := permissions.EffectivePerms(0, 0, 0) + if got != 0 { + t.Errorf("EffectivePerms all zero: got 0x%X, want 0", got) + } +} + +func TestEffectivePerms_DenyAllGrantNone(t *testing.T) { + base := int64(0x7FFFFFFF) + deny := int64(0x7FFFFFFF) + got := permissions.EffectivePerms(base, 0, deny) + if got != 0 { + t.Errorf("EffectivePerms deny all: got 0x%X, want 0", got) + } +} diff --git a/Server/storage/storage.go b/Server/storage/storage.go new file mode 100644 index 00000000..f71549c1 --- /dev/null +++ b/Server/storage/storage.go @@ -0,0 +1,166 @@ +// Package storage handles file upload validation and storage for the OwnCord server. +package storage + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// blockedMagic maps format names to their magic byte signatures. Files whose +// leading bytes match any entry are rejected by ValidateFileType. +var blockedMagic = []struct { + name string + magic []byte +}{ + {"PE executable", []byte("MZ")}, // Windows .exe / .dll + {"ELF binary", []byte("\x7fELF")}, // Linux binaries + {"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit + {"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit + {"shell script", []byte("#!")}, // Shebang scripts (.sh, .py, etc.) +} + +// ValidateFileType checks the first few bytes of a file against known blocked +// magic bytes. It returns an error if the content matches a blocked file type, +// or nil if the content is allowed. +func ValidateFileType(header []byte) error { + for _, blocked := range blockedMagic { + if len(header) >= len(blocked.magic) && bytes.Equal(header[:len(blocked.magic)], blocked.magic) { + return fmt.Errorf("blocked file type: %s", blocked.name) + } + } + return nil +} + +// Storage manages file uploads on disk. +type Storage struct { + dir string + maxSizeMB int +} + +// New creates a Storage instance that stores files in dir. +// dir is created if it does not exist. +func New(dir string, maxSizeMB int) (*Storage, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("creating storage dir %s: %w", dir, err) + } + return &Storage{dir: dir, maxSizeMB: maxSizeMB}, nil +} + +// sanitizeFilename validates that name is safe to use as a filename inside the +// storage directory. It must be a plain basename with no path separators, must +// not be empty, ".", or "..", and must not start with ".". +func sanitizeFilename(name string) error { + if name == "" { + return fmt.Errorf("invalid filename: empty string") + } + // filepath.Base strips any directory component; if it differs from the + // original input the caller smuggled a path separator. + base := filepath.Base(name) + if base != name { + return fmt.Errorf("invalid filename %q: must not contain path separators", name) + } + // Reject "." and ".." explicitly. + if name == "." || name == ".." { + return fmt.Errorf("invalid filename %q: reserved name", name) + } + // Reject filenames starting with "." (hidden/config files). + if strings.HasPrefix(name, ".") { + return fmt.Errorf("invalid filename %q: must not start with '.'", name) + } + // Explicitly reject embedded separators on both Unix and Windows. + if strings.ContainsAny(name, "/\\") { + return fmt.Errorf("invalid filename %q: must not contain path separators", name) + } + return nil +} + +// resolvedPath builds the absolute target path and verifies it stays within +// the storage directory. +func (s *Storage) resolvedPath(name string) (string, error) { + absDir, err := filepath.Abs(s.dir) + if err != nil { + return "", fmt.Errorf("resolving storage dir: %w", err) + } + target := filepath.Join(absDir, name) + // Ensure the joined path is still under absDir. + if !strings.HasPrefix(target, absDir+string(filepath.Separator)) && + target != absDir { + return "", fmt.Errorf("resolved path %q escapes storage directory", target) + } + return target, nil +} + +// Save writes the content from r to a file named by uuid within the storage dir. +// It reads the first 8 bytes to validate the file type (rejecting executables +// and scripts) before writing the full content to disk. +// The caller is responsible for generating a UUID filename. +func (s *Storage) Save(uuid string, r io.Reader) error { + if err := sanitizeFilename(uuid); err != nil { + return err + } + dst, err := s.resolvedPath(uuid) + if err != nil { + return err + } + + // Read the first 8 bytes to check magic bytes without consuming the stream. + var header [8]byte + n, err := io.ReadFull(r, header[:]) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return fmt.Errorf("reading file header: %w", err) + } + headerSlice := header[:n] + + if err := ValidateFileType(headerSlice); err != nil { + return err + } + + f, err := os.Create(dst) + if err != nil { + return fmt.Errorf("creating file %s: %w", dst, err) + } + defer f.Close() //nolint:errcheck + + // Reconstruct the full stream: header bytes we already read + remainder. + maxBytes := int64(s.maxSizeMB) * 1024 * 1024 + full := io.MultiReader(bytes.NewReader(headerSlice), r) + written, err := io.Copy(f, io.LimitReader(full, maxBytes+1)) + if err != nil { + return fmt.Errorf("writing file: %w", err) + } + if written > maxBytes { + // File exceeds limit — remove the partial write and reject. + _ = f.Close() + _ = os.Remove(dst) + return fmt.Errorf("file exceeds maximum size of %d MB", s.maxSizeMB) + } + return nil +} + +// Delete removes the file named uuid from the storage dir. +func (s *Storage) Delete(uuid string) error { + if err := sanitizeFilename(uuid); err != nil { + return err + } + dst, err := s.resolvedPath(uuid) + if err != nil { + return err + } + return os.Remove(dst) +} + +// Open opens the file named uuid for reading. +func (s *Storage) Open(uuid string) (*os.File, error) { + if err := sanitizeFilename(uuid); err != nil { + return nil, err + } + dst, err := s.resolvedPath(uuid) + if err != nil { + return nil, err + } + return os.Open(dst) +} diff --git a/Server/storage/storage_test.go b/Server/storage/storage_test.go new file mode 100644 index 00000000..1961958f --- /dev/null +++ b/Server/storage/storage_test.go @@ -0,0 +1,476 @@ +package storage_test + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/owncord/server/storage" +) + +// newTestStorage creates a Storage instance backed by a temporary directory +// that is removed when the test ends. +func newTestStorage(t *testing.T) *storage.Storage { + t.Helper() + dir := t.TempDir() + s, err := storage.New(dir, 10) + if err != nil { + t.Fatalf("storage.New: %v", err) + } + return s +} + +// ─── sanitizeFilename / path validation (tested indirectly via Save/Delete/Open) ─ + +// TestSave_ValidUUID verifies that a normal UUID-style filename is accepted. +func TestSave_ValidUUID(t *testing.T) { + s := newTestStorage(t) + err := s.Save("550e8400-e29b-41d4-a716-446655440000", strings.NewReader("hello")) + if err != nil { + t.Errorf("Save valid uuid: unexpected error: %v", err) + } +} + +// TestSave_PathTraversalDotDot rejects filenames containing "..". +func TestSave_PathTraversalDotDot(t *testing.T) { + s := newTestStorage(t) + err := s.Save("../../etc/passwd", strings.NewReader("evil")) + if err == nil { + t.Error("Save('../../etc/passwd') returned nil error, want path traversal error") + } +} + +// TestSave_DotDotFilename rejects the literal string "..". +func TestSave_DotDotFilename(t *testing.T) { + s := newTestStorage(t) + err := s.Save("..", strings.NewReader("evil")) + if err == nil { + t.Error("Save('..') returned nil error, want error") + } +} + +// TestSave_SingleDotFilename rejects the literal string ".". +func TestSave_SingleDotFilename(t *testing.T) { + s := newTestStorage(t) + err := s.Save(".", strings.NewReader("evil")) + if err == nil { + t.Error("Save('.') returned nil error, want error") + } +} + +// TestSave_EmptyFilename rejects an empty string. +func TestSave_EmptyFilename(t *testing.T) { + s := newTestStorage(t) + err := s.Save("", strings.NewReader("data")) + if err == nil { + t.Error("Save('') returned nil error, want error") + } +} + +// TestSave_DotPrefixFilename rejects filenames starting with ".". +func TestSave_DotPrefixFilename(t *testing.T) { + s := newTestStorage(t) + err := s.Save(".hidden", strings.NewReader("data")) + if err == nil { + t.Error("Save('.hidden') returned nil error, want error") + } +} + +// TestSave_ForwardSlashRejected rejects filenames containing a forward slash. +func TestSave_ForwardSlashRejected(t *testing.T) { + s := newTestStorage(t) + err := s.Save("sub/file", strings.NewReader("data")) + if err == nil { + t.Error("Save('sub/file') returned nil error, want path separator error") + } +} + +// TestSave_BackslashRejected rejects filenames containing a backslash. +func TestSave_BackslashRejected(t *testing.T) { + s := newTestStorage(t) + err := s.Save(`sub\file`, strings.NewReader("data")) + if err == nil { + t.Error(`Save('sub\file') returned nil error, want path separator error`) + } +} + +// TestSave_ResolvedPathStaysInDir verifies the stored file is actually inside +// the storage directory (defence-in-depth after sanitisation). +func TestSave_ResolvedPathStaysInDir(t *testing.T) { + dir := t.TempDir() + s, _ := storage.New(dir, 10) + + filename := "valid-file.dat" + if err := s.Save(filename, strings.NewReader("content")); err != nil { + t.Fatalf("Save: %v", err) + } + + expectedPath := filepath.Join(dir, filename) + if _, err := os.Stat(expectedPath); errors.Is(err, os.ErrNotExist) { + t.Errorf("expected file at %s but it was not found", expectedPath) + } +} + +// TestDelete_ValidUUID verifies that a saved file can be deleted by its UUID. +func TestDelete_ValidUUID(t *testing.T) { + s := newTestStorage(t) + if err := s.Save("abc123", strings.NewReader("data")); err != nil { + t.Fatalf("Save: %v", err) + } + if err := s.Delete("abc123"); err != nil { + t.Errorf("Delete valid uuid: unexpected error: %v", err) + } +} + +// TestDelete_PathTraversal rejects path-traversal filenames. +func TestDelete_PathTraversal(t *testing.T) { + s := newTestStorage(t) + err := s.Delete("../../sensitive") + if err == nil { + t.Error("Delete('../../sensitive') returned nil error, want path traversal error") + } +} + +// TestDelete_DotDot rejects "..". +func TestDelete_DotDot(t *testing.T) { + s := newTestStorage(t) + if err := s.Delete(".."); err == nil { + t.Error("Delete('..') returned nil error, want error") + } +} + +// TestDelete_EmptyFilename rejects an empty string. +func TestDelete_EmptyFilename(t *testing.T) { + s := newTestStorage(t) + if err := s.Delete(""); err == nil { + t.Error("Delete('') returned nil error, want error") + } +} + +// TestDelete_DotPrefixFilename rejects filenames starting with ".". +func TestDelete_DotPrefixFilename(t *testing.T) { + s := newTestStorage(t) + if err := s.Delete(".hidden"); err == nil { + t.Error("Delete('.hidden') returned nil error, want error") + } +} + +// TestOpen_ValidUUID verifies that a saved file can be opened and read back. +func TestOpen_ValidUUID(t *testing.T) { + s := newTestStorage(t) + content := "hello storage" + if err := s.Save("myfile", strings.NewReader(content)); err != nil { + t.Fatalf("Save: %v", err) + } + + f, err := s.Open("myfile") + if err != nil { + t.Fatalf("Open: %v", err) + } + defer f.Close() //nolint:errcheck + + got, err := io.ReadAll(f) + if err != nil { + t.Fatalf("reading opened file: %v", err) + } + if string(got) != content { + t.Errorf("content = %q, want %q", got, content) + } +} + +// TestOpen_PathTraversal rejects path-traversal filenames. +func TestOpen_PathTraversal(t *testing.T) { + s := newTestStorage(t) + _, err := s.Open("../../etc/passwd") + if err == nil { + t.Error("Open('../../etc/passwd') returned nil error, want path traversal error") + } +} + +// TestOpen_DotDot rejects "..". +func TestOpen_DotDot(t *testing.T) { + s := newTestStorage(t) + if _, err := s.Open(".."); err == nil { + t.Error("Open('..') returned nil error, want error") + } +} + +// TestOpen_EmptyFilename rejects an empty string. +func TestOpen_EmptyFilename(t *testing.T) { + s := newTestStorage(t) + if _, err := s.Open(""); err == nil { + t.Error("Open('') returned nil error, want error") + } +} + +// TestOpen_DotPrefixFilename rejects filenames starting with ".". +func TestOpen_DotPrefixFilename(t *testing.T) { + s := newTestStorage(t) + if _, err := s.Open(".env"); err == nil { + t.Error("Open('.env') returned nil error, want error") + } +} + +// TestOpen_ForwardSlashRejected rejects filenames containing a forward slash. +func TestOpen_ForwardSlashRejected(t *testing.T) { + s := newTestStorage(t) + if _, err := s.Open("dir/file"); err == nil { + t.Error("Open('dir/file') returned nil error, want path separator error") + } +} + +// TestSave_RoundTrip confirms data integrity through Save then Open. +func TestSave_RoundTrip(t *testing.T) { + s := newTestStorage(t) + payload := bytes.Repeat([]byte("abcdef"), 1000) // 6 KB + if err := s.Save("roundtrip", bytes.NewReader(payload)); err != nil { + t.Fatalf("Save: %v", err) + } + + f, err := s.Open("roundtrip") + if err != nil { + t.Fatalf("Open: %v", err) + } + defer f.Close() //nolint:errcheck + + got, _ := io.ReadAll(f) + if !bytes.Equal(got, payload) { + t.Errorf("round-trip data mismatch: got %d bytes, want %d", len(got), len(payload)) + } +} + +// ─── 4.2: Magic byte validation ─────────────────────────────────────────────── + +// TestValidateFileType_AllowsNormalContent verifies that plain file content passes. +func TestValidateFileType_AllowsNormalContent(t *testing.T) { + cases := []struct { + name string + header []byte + }{ + {"PNG", []byte("\x89PNG\r\n\x1a\n")}, + {"JPEG", []byte("\xff\xd8\xff\xe0")}, + {"GIF87", []byte("GIF87a")}, + {"GIF89", []byte("GIF89a")}, + {"PDF", []byte("%PDF-1.4")}, + {"ZIP", []byte("PK\x03\x04")}, + {"plaintext", []byte("Hello world")}, + {"empty", []byte{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := storage.ValidateFileType(tc.header) + if err != nil { + t.Errorf("ValidateFileType(%q) = %v, want nil", tc.name, err) + } + }) + } +} + +// TestValidateFileType_BlocksPEExecutable verifies Windows .exe files are rejected. +func TestValidateFileType_BlocksPEExecutable(t *testing.T) { + header := []byte("MZP\x00\x02\x00\x00\x00") // PE magic "MZ" + err := storage.ValidateFileType(header) + if err == nil { + t.Error("ValidateFileType(PE header) = nil, want error") + } +} + +// TestValidateFileType_BlocksELFBinary verifies Linux ELF binaries are rejected. +func TestValidateFileType_BlocksELFBinary(t *testing.T) { + header := []byte("\x7fELF\x02\x01\x01\x00") + err := storage.ValidateFileType(header) + if err == nil { + t.Error("ValidateFileType(ELF header) = nil, want error") + } +} + +// TestValidateFileType_BlocksMachO64 verifies macOS 64-bit Mach-O binaries are rejected. +func TestValidateFileType_BlocksMachO64(t *testing.T) { + header := []byte("\xcf\xfa\xed\xfe\x07\x00\x00\x01") + err := storage.ValidateFileType(header) + if err == nil { + t.Error("ValidateFileType(Mach-O 64 header) = nil, want error") + } +} + +// TestValidateFileType_BlocksMachO32 verifies macOS 32-bit Mach-O binaries are rejected. +func TestValidateFileType_BlocksMachO32(t *testing.T) { + header := []byte("\xce\xfa\xed\xfe\x07\x00\x00\x01") + err := storage.ValidateFileType(header) + if err == nil { + t.Error("ValidateFileType(Mach-O 32 header) = nil, want error") + } +} + +// TestValidateFileType_BlocksShellScript verifies shebang scripts are rejected. +func TestValidateFileType_BlocksShellScript(t *testing.T) { + cases := []struct { + name string + header []byte + }{ + {"bash", []byte("#!/bin/bash\necho hi")}, + {"sh", []byte("#!/bin/sh\necho hi")}, + {"python", []byte("#!/usr/bin/env python3\nprint('x')")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := storage.ValidateFileType(tc.header) + if err == nil { + t.Errorf("ValidateFileType(script %q) = nil, want error", tc.name) + } + }) + } +} + +// TestValidateFileType_ErrorMessageContainsFormat verifies the error names the blocked type. +func TestValidateFileType_ErrorMessageContainsFormat(t *testing.T) { + header := []byte("MZ\x90\x00") // PE executable + err := storage.ValidateFileType(header) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "PE executable") { + t.Errorf("error message %q does not mention 'PE executable'", err.Error()) + } +} + +// TestSave_BlocksExecutable verifies Save rejects PE executable content. +func TestSave_BlocksExecutable(t *testing.T) { + s := newTestStorage(t) + // Construct content with PE magic followed by padding. + content := append([]byte("MZ"), bytes.Repeat([]byte{0x00}, 100)...) + err := s.Save("malware.exe", bytes.NewReader(content)) + if err == nil { + t.Error("Save(PE executable) = nil, want error") + } +} + +// TestSave_BlocksELF verifies Save rejects ELF binary content. +func TestSave_BlocksELF(t *testing.T) { + s := newTestStorage(t) + content := append([]byte("\x7fELF"), bytes.Repeat([]byte{0x00}, 100)...) + err := s.Save("linux-binary", bytes.NewReader(content)) + if err == nil { + t.Error("Save(ELF binary) = nil, want error") + } +} + +// TestSave_BlocksShellScript verifies Save rejects script content. +func TestSave_BlocksShellScript(t *testing.T) { + s := newTestStorage(t) + content := []byte("#!/bin/bash\nrm -rf /\n") + err := s.Save("nasty.sh", bytes.NewReader(content)) + if err == nil { + t.Error("Save(shell script) = nil, want error") + } +} + +// TestSave_AllowsPNG verifies Save still accepts legitimate image content after magic check. +func TestSave_AllowsPNG(t *testing.T) { + s := newTestStorage(t) + content := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 100)...) + err := s.Save("image.png", bytes.NewReader(content)) + if err != nil { + t.Errorf("Save(PNG) = %v, want nil", err) + } +} + +// TestSave_EmptyFileAllowed verifies that an empty file (no content) is accepted. +func TestSave_EmptyFileAllowed(t *testing.T) { + s := newTestStorage(t) + err := s.Save("empty-file", bytes.NewReader([]byte{})) + if err != nil { + t.Errorf("Save(empty) = %v, want nil", err) + } +} + +// ─── New edge cases ────────────────────────────────────────────────────────── + +func TestNew_CreatesDirectory(t *testing.T) { + tmpDir := t.TempDir() + newDir := filepath.Join(tmpDir, "nested", "storage") + + s, err := storage.New(newDir, 10) + if err != nil { + t.Fatalf("New: %v", err) + } + if s == nil { + t.Fatal("New returned nil") + } + + // Directory should exist. + info, statErr := os.Stat(newDir) + if statErr != nil { + t.Fatalf("directory not created: %v", statErr) + } + if !info.IsDir() { + t.Error("expected directory, got file") + } +} + +// ─── Save large file ──────────────────────────────────────────────────────── + +func TestSave_ExceedsMaxSize(t *testing.T) { + tmpDir := t.TempDir() + // 1 MB max. + s, err := storage.New(tmpDir, 1) + if err != nil { + t.Fatalf("New: %v", err) + } + + // Create reader with >1MB of data. + bigData := bytes.Repeat([]byte("x"), 1024*1024+100) + err = s.Save("big-file", bytes.NewReader(bigData)) + if err == nil { + t.Error("Save should reject file exceeding max size") + } + + // File should be removed. + if _, statErr := os.Stat(filepath.Join(tmpDir, "big-file")); !os.IsNotExist(statErr) { + t.Error("oversized file should be removed after rejection") + } +} + +func TestSave_ReadError(t *testing.T) { + s := newTestStorage(t) + err := s.Save("read-err", &failReader{}) + if err == nil { + t.Error("Save with failing reader should return error") + } +} + +type failReader struct{} + +func (f *failReader) Read([]byte) (int, error) { + return 0, errors.New("simulated read error") +} + +// ─── resolvedPath edge case (via Save with dot prefix) ────────────────────── + +func TestSave_HiddenFilename(t *testing.T) { + s := newTestStorage(t) + err := s.Save(".hidden", strings.NewReader("data")) + if err == nil { + t.Error("Save should reject hidden filenames starting with '.'") + } +} + +func TestOpen_NotFound(t *testing.T) { + s := newTestStorage(t) + _, err := s.Open("nonexistent-file") + if err == nil { + t.Error("Open should return error for nonexistent file") + } +} + +func TestDelete_NotFound(t *testing.T) { + s := newTestStorage(t) + err := s.Delete("nonexistent-file") + if err == nil { + t.Error("Delete should return error for nonexistent file") + } +} diff --git a/Server/updater/updater.go b/Server/updater/updater.go new file mode 100644 index 00000000..34fd8f81 --- /dev/null +++ b/Server/updater/updater.go @@ -0,0 +1,367 @@ +// Package updater checks GitHub Releases for server updates and manages +// binary downloads with checksum verification. +package updater + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/mod/semver" +) + +const ( + defaultBaseURL = "https://api.github.com" + cacheTTL = 1 * time.Hour + binaryAsset = "chatserver.exe" + checksumAsset = "checksums.sha256" +) + +// UpdateInfo holds the result of a version check. +type UpdateInfo struct { + Current string `json:"current"` + Latest string `json:"latest"` + UpdateAvailable bool `json:"update_available"` + ReleaseURL string `json:"release_url"` + DownloadURL string `json:"download_url"` + ChecksumURL string `json:"checksum_url"` + ReleaseNotes string `json:"release_notes"` + Assets []Asset `json:"assets,omitempty"` +} + +// Asset is a simplified release asset with name and download URL. +type Asset struct { + Name string `json:"name"` + DownloadURL string `json:"download_url"` +} + +// ClientAssets holds the URLs for Tauri client update artifacts. +type ClientAssets struct { + InstallerURL string + SignatureURL string +} + +// releaseResponse mirrors the subset of GitHub's release API we need. +type releaseResponse struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Assets []assetResponse `json:"assets"` +} + +// assetResponse mirrors a single release asset from the GitHub API. +type assetResponse struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// Updater checks GitHub Releases for updates and manages binary downloads. +type Updater struct { + currentVersion string + githubToken string + repoOwner string + repoName string + baseURL string // override for testing; empty uses defaultBaseURL + + cache *UpdateInfo + cacheExpiry time.Time + mu sync.Mutex + httpClient *http.Client +} + +// NewUpdater creates an Updater for the given repository. +func NewUpdater(currentVersion, githubToken, repoOwner, repoName string) *Updater { + return &Updater{ + currentVersion: currentVersion, + githubToken: githubToken, + repoOwner: repoOwner, + repoName: repoName, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// SetBaseURL overrides the GitHub API base URL (for testing). +func (u *Updater) SetBaseURL(url string) { + u.baseURL = url +} + +// ensureVPrefix returns the version string with a "v" prefix for semver +// comparison. If it already has one, it is returned unchanged. +func ensureVPrefix(v string) string { + if strings.HasPrefix(v, "v") { + return v + } + return "v" + v +} + +// apiBaseURL returns the effective base URL for GitHub API requests. +func (u *Updater) apiBaseURL() string { + if u.baseURL != "" { + return u.baseURL + } + return defaultBaseURL +} + +// CheckForUpdate queries GitHub for the latest release and compares it +// against the current version. Results are cached for cacheTTL. +func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateInfo, error) { + u.mu.Lock() + if u.cache != nil && time.Now().Before(u.cacheExpiry) { + cached := *u.cache + u.mu.Unlock() + return cached, nil + } + u.mu.Unlock() + + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", u.apiBaseURL(), u.repoOwner, u.repoName) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return UpdateInfo{}, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + if u.githubToken != "" { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return UpdateInfo{}, fmt.Errorf("fetching latest release: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return UpdateInfo{}, fmt.Errorf("github API returned status %d", resp.StatusCode) + } + + var release releaseResponse + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return UpdateInfo{}, fmt.Errorf("decoding release response: %w", err) + } + + currentV := ensureVPrefix(u.currentVersion) + latestV := ensureVPrefix(release.TagName) + + // semver.Compare returns -1, 0, or +1. Update available when current < latest. + updateAvailable := semver.Compare(currentV, latestV) < 0 + + var downloadURL, checksumURL string + assets := make([]Asset, 0, len(release.Assets)) + for _, asset := range release.Assets { + assets = append(assets, Asset{ + Name: asset.Name, + DownloadURL: asset.BrowserDownloadURL, + }) + switch asset.Name { + case binaryAsset: + downloadURL = asset.BrowserDownloadURL + case checksumAsset: + checksumURL = asset.BrowserDownloadURL + } + } + + info := UpdateInfo{ + Current: currentV, + Latest: latestV, + UpdateAvailable: updateAvailable, + ReleaseURL: release.HTMLURL, + DownloadURL: downloadURL, + ChecksumURL: checksumURL, + ReleaseNotes: release.Body, + Assets: assets, + } + + u.mu.Lock() + u.cache = &info + u.cacheExpiry = time.Now().Add(cacheTTL) + u.mu.Unlock() + + return info, nil +} + +// ValidateDownloadURL ensures the URL points to an expected GitHub release +// asset for this repository. +func (u *Updater) ValidateDownloadURL(url string) error { + prefix := fmt.Sprintf("https://github.com/%s/%s/releases/download/", u.repoOwner, u.repoName) + if !strings.HasPrefix(url, prefix) { + return fmt.Errorf("download URL %q does not match expected prefix %q", url, prefix) + } + return nil +} + +// DownloadAndVerify downloads the binary from downloadURL, fetches the +// checksum file from checksumURL, and verifies the SHA256 hash matches. +// On checksum mismatch the downloaded file is removed. +func (u *Updater) DownloadAndVerify(ctx context.Context, downloadURL, checksumURL, destPath string) error { + if err := u.ValidateDownloadURL(downloadURL); err != nil { + return err + } + if err := u.ValidateDownloadURL(checksumURL); err != nil { + return fmt.Errorf("validating checksum URL: %w", err) + } + + // Fetch checksum file. + checksumData, err := u.fetchBody(ctx, checksumURL) + if err != nil { + return fmt.Errorf("fetching checksums: %w", err) + } + + destFilename := filepath.Base(destPath) + expectedHash, err := u.ParseChecksumFile(checksumData, destFilename) + if err != nil { + return fmt.Errorf("parsing checksum file: %w", err) + } + + // Download the binary. + if err := u.downloadFile(ctx, downloadURL, destPath); err != nil { + return fmt.Errorf("downloading binary: %w", err) + } + + // Verify hash. + if err := u.VerifyChecksum(destPath, expectedHash); err != nil { + // Remove the invalid file. + _ = os.Remove(destPath) + return err + } + + return nil +} + +// VerifyChecksum computes the SHA256 hash of the file at filePath and +// compares it (case-insensitive) against expectedHash. +func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("opening file for checksum: %w", err) + } + defer f.Close() //nolint:errcheck + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("computing checksum: %w", err) + } + + actual := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(actual, expectedHash) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) + } + return nil +} + +// ParseChecksumFile parses a sha256sum-format checksum file (lines of +// " ") and returns the hash for the given filename. +func (u *Updater) ParseChecksumFile(data []byte, filename string) (string, error) { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // sha256sum format: " " (two spaces) + // Also handle single-space separation for robustness. + parts := strings.Fields(line) + if len(parts) >= 2 && parts[len(parts)-1] == filename { + return parts[0], nil + } + } + return "", fmt.Errorf("file %q not found in checksum data", filename) +} + +// fetchBody performs a GET request and returns the response body as bytes. +func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if u.githubToken != "" { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, url) + } + + // Cap reads at 1 MiB — checksum and signature files are tiny text; + // this prevents a malicious or corrupted release asset from exhausting memory. + return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) +} + +// FindClientAssets scans the cached release assets for the Tauri NSIS +// installer zip and its Ed25519 signature file. +func (u *Updater) FindClientAssets() ClientAssets { + u.mu.Lock() + defer u.mu.Unlock() + + if u.cache == nil { + return ClientAssets{} + } + + var ca ClientAssets + for _, a := range u.cache.Assets { + switch { + case strings.HasSuffix(a.Name, "_x64-setup.nsis.zip.sig"): + ca.SignatureURL = a.DownloadURL + case strings.HasSuffix(a.Name, "_x64-setup.nsis.zip"): + ca.InstallerURL = a.DownloadURL + } + } + return ca +} + +// FetchTextAsset downloads a small text asset (e.g. a .sig file) and returns +// its content as a string. +func (u *Updater) FetchTextAsset(ctx context.Context, url string) (string, error) { + data, err := u.fetchBody(ctx, url) + if err != nil { + return "", err + } + return string(data), nil +} + +// downloadFile downloads the content at url and writes it to destPath. +func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + if u.githubToken != "" { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url) + } + + f, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("creating destination file: %w", err) + } + defer f.Close() //nolint:errcheck + + if _, err := io.Copy(f, resp.Body); err != nil { + return fmt.Errorf("writing downloaded file: %w", err) + } + + return nil +} diff --git a/Server/updater/updater_test.go b/Server/updater/updater_test.go new file mode 100644 index 00000000..da51f1d3 --- /dev/null +++ b/Server/updater/updater_test.go @@ -0,0 +1,481 @@ +package updater + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +// ghRelease mirrors the GitHub release API response shape. +type ghRelease struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Assets []ghAsset `json:"assets"` +} + +// ghAsset mirrors a GitHub release asset. +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func newTestRelease(tag, body, htmlURL string, assetDownloadBase string) ghRelease { + return ghRelease{ + TagName: tag, + Body: body, + HTMLURL: htmlURL, + Assets: []ghAsset{ + {Name: "chatserver.exe", BrowserDownloadURL: assetDownloadBase + "/chatserver.exe"}, + {Name: "checksums.sha256", BrowserDownloadURL: assetDownloadBase + "/checksums.sha256"}, + }, + } +} + +func newTestServer(t *testing.T, release ghRelease, statusCode int) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if statusCode == http.StatusOK { + if err := json.NewEncoder(w).Encode(release); err != nil { + t.Fatalf("encoding release: %v", err) + } + } else { + _, _ = fmt.Fprint(w, `{"message":"Internal Server Error"}`) + } + }) + return httptest.NewServer(mux) +} + +func newTestUpdater(baseURL, currentVersion string) *Updater { + u := NewUpdater(currentVersion, "", "J3vb", "OwnCord") + u.baseURL = baseURL + return u +} + +func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { + release := newTestRelease("v1.2.0", "Bug fixes and improvements", "https://github.com/J3vb/OwnCord/releases/tag/v1.2.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.2.0") + srv := newTestServer(t, release, http.StatusOK) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + info, err := u.CheckForUpdate(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.UpdateAvailable { + t.Error("expected UpdateAvailable=true, got false") + } + if info.Latest != "v1.2.0" { + t.Errorf("expected Latest=v1.2.0, got %s", info.Latest) + } + if info.Current != "v1.0.0" { + t.Errorf("expected Current=v1.0.0, got %s", info.Current) + } + if info.DownloadURL == "" { + t.Error("expected non-empty DownloadURL") + } + if info.ChecksumURL == "" { + t.Error("expected non-empty ChecksumURL") + } +} + +func TestCheckForUpdate_UpToDate(t *testing.T) { + release := newTestRelease("v1.0.0", "Current release", "https://github.com/J3vb/OwnCord/releases/tag/v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0") + srv := newTestServer(t, release, http.StatusOK) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + info, err := u.CheckForUpdate(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.UpdateAvailable { + t.Error("expected UpdateAvailable=false, got true") + } +} + +func TestCheckForUpdate_CachesResult(t *testing.T) { + var hitCount atomic.Int32 + release := newTestRelease("v2.0.0", "Major update", "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v2.0.0") + + mux := http.NewServeMux() + mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) { + hitCount.Add(1) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(release); err != nil { + t.Fatalf("encoding release: %v", err) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + ctx := context.Background() + + _, err := u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("first call error: %v", err) + } + _, err = u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("second call error: %v", err) + } + + if got := hitCount.Load(); got != 1 { + t.Errorf("expected 1 API hit (cached), got %d", got) + } +} + +func TestCheckForUpdate_CacheExpires(t *testing.T) { + var hitCount atomic.Int32 + release := newTestRelease("v2.0.0", "Major update", "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v2.0.0") + + mux := http.NewServeMux() + mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) { + hitCount.Add(1) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(release); err != nil { + t.Fatalf("encoding release: %v", err) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + ctx := context.Background() + + // First call populates cache. + _, err := u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("first call error: %v", err) + } + + // Expire the cache manually. + u.mu.Lock() + u.cacheExpiry = time.Now().Add(-1 * time.Minute) + u.mu.Unlock() + + // Second call should hit the API again. + _, err = u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("second call error: %v", err) + } + + if got := hitCount.Load(); got != 2 { + t.Errorf("expected 2 API hits (cache expired), got %d", got) + } +} + +func TestCheckForUpdate_APIError(t *testing.T) { + release := ghRelease{} // unused since status is 500 + srv := newTestServer(t, release, http.StatusInternalServerError) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + _, err := u.CheckForUpdate(context.Background()) + if err == nil { + t.Fatal("expected error for 500 response, got nil") + } +} + +func TestValidateDownloadURL_Valid(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + err := u.ValidateDownloadURL("https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe") + if err != nil { + t.Errorf("expected valid URL to pass, got error: %v", err) + } +} + +func TestValidateDownloadURL_Invalid(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + err := u.ValidateDownloadURL("https://evil.com/chatserver.exe") + if err == nil { + t.Error("expected invalid URL to be rejected, got nil") + } +} + +func TestVerifyChecksum_Correct(t *testing.T) { + content := []byte("hello world binary content") + hash := sha256.Sum256(content) + expectedHash := hex.EncodeToString(hash[:]) + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "chatserver.exe") + if err := os.WriteFile(filePath, content, 0o644); err != nil { + t.Fatalf("writing temp file: %v", err) + } + + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + if err := u.VerifyChecksum(filePath, expectedHash); err != nil { + t.Errorf("expected correct checksum to pass, got error: %v", err) + } +} + +func TestVerifyChecksum_Incorrect(t *testing.T) { + content := []byte("hello world binary content") + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "chatserver.exe") + if err := os.WriteFile(filePath, content, 0o644); err != nil { + t.Fatalf("writing temp file: %v", err) + } + + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + wrongHash := "0000000000000000000000000000000000000000000000000000000000000000" + if err := u.VerifyChecksum(filePath, wrongHash); err == nil { + t.Error("expected incorrect checksum to fail, got nil") + } +} + +func TestParseChecksumFile_FindsFile(t *testing.T) { + data := []byte("abc123 readme.txt\ndef456 chatserver.exe\nghi789 other.dll\n") + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + hash, err := u.ParseChecksumFile(data, "chatserver.exe") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hash != "def456" { + t.Errorf("expected hash=def456, got %s", hash) + } +} + +func TestParseChecksumFile_FileNotFound(t *testing.T) { + data := []byte("abc123 readme.txt\ndef456 chatserver.exe\n") + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + _, err := u.ParseChecksumFile(data, "nonexistent.exe") + if err == nil { + t.Error("expected error for missing file in checksum data, got nil") + } +} + +// ─── SetBaseURL ────────────────────────────────────────────────────────────── + +func TestSetBaseURL(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + u.SetBaseURL("https://custom.api.example.com") + if u.apiBaseURL() != "https://custom.api.example.com" { + t.Errorf("apiBaseURL = %q, want custom URL", u.apiBaseURL()) + } +} + +func TestApiBaseURL_DefaultWhenEmpty(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + got := u.apiBaseURL() + if got != defaultBaseURL { + t.Errorf("apiBaseURL = %q, want default %q", got, defaultBaseURL) + } +} + +// ─── fetchBody ─────────────────────────────────────────────────────────────── + +func TestFetchBody_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello body")) + })) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + body, err := u.fetchBody(context.Background(), srv.URL+"/test") + if err != nil { + t.Fatalf("fetchBody: %v", err) + } + if string(body) != "hello body" { + t.Errorf("body = %q, want 'hello body'", body) + } +} + +func TestFetchBody_NonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + _, err := u.fetchBody(context.Background(), srv.URL+"/test") + if err == nil { + t.Error("fetchBody should error on non-200 status") + } +} + +func TestFetchBody_WithGithubToken(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + u := NewUpdater("1.0.0", "my-token", "J3vb", "OwnCord") + u.baseURL = srv.URL + _, _ = u.fetchBody(context.Background(), srv.URL+"/test") + if gotAuth != "token my-token" { + t.Errorf("Authorization = %q, want 'token my-token'", gotAuth) + } +} + +// ─── downloadFile ──────────────────────────────────────────────────────────── + +func TestDownloadFile_Success(t *testing.T) { + content := []byte("binary content here") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + })) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "downloaded.exe") + + u := newTestUpdater(srv.URL, "1.0.0") + if err := u.downloadFile(context.Background(), srv.URL+"/binary", dest); err != nil { + t.Fatalf("downloadFile: %v", err) + } + + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("reading downloaded file: %v", err) + } + if string(got) != string(content) { + t.Errorf("content = %q, want %q", got, content) + } +} + +func TestDownloadFile_NonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "downloaded.exe") + + u := newTestUpdater(srv.URL, "1.0.0") + err := u.downloadFile(context.Background(), srv.URL+"/binary", dest) + if err == nil { + t.Error("downloadFile should error on non-200 status") + } +} + +// ─── DownloadAndVerify ─────────────────────────────────────────────────────── + +func TestDownloadAndVerify_Success(t *testing.T) { + content := []byte("real binary content for verification") + hash := sha256.Sum256(content) + checksumHex := hex.EncodeToString(hash[:]) + + mux := http.NewServeMux() + mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(content) + }) + mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "chatserver.exe") + + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + u.baseURL = srv.URL + + downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe" + checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256" + + // Override HTTP client to route GitHub URLs to our test server. + u.httpClient = &http.Client{ + Transport: &rewriteTransport{srv.URL}, + } + + err := u.DownloadAndVerify(context.Background(), downloadURL, checksumURL, dest) + if err != nil { + t.Fatalf("DownloadAndVerify: %v", err) + } + + // File should exist and be correct. + got, _ := os.ReadFile(dest) + if string(got) != string(content) { + t.Errorf("downloaded content mismatch") + } +} + +func TestDownloadAndVerify_InvalidDownloadURL(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + err := u.DownloadAndVerify(context.Background(), "https://evil.com/file", "https://evil.com/sum", "/tmp/out") + if err == nil { + t.Error("DownloadAndVerify should reject invalid download URL") + } +} + +func TestDownloadAndVerify_InvalidChecksumURL(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe" + err := u.DownloadAndVerify(context.Background(), downloadURL, "https://evil.com/sum", "/tmp/out") + if err == nil { + t.Error("DownloadAndVerify should reject invalid checksum URL") + } +} + +func TestDownloadAndVerify_ChecksumMismatch(t *testing.T) { + content := []byte("binary content") + wrongChecksum := "0000000000000000000000000000000000000000000000000000000000000000" + + mux := http.NewServeMux() + mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(content) + }) + mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s chatserver.exe\n", wrongChecksum) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "chatserver.exe") + + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}} + + downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe" + checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256" + + err := u.DownloadAndVerify(context.Background(), downloadURL, checksumURL, dest) + if err == nil { + t.Error("DownloadAndVerify should fail on checksum mismatch") + } + + // File should be removed after mismatch. + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Error("file should be removed after checksum mismatch") + } +} + +// rewriteTransport rewrites GitHub release URLs to a local test server. +type rewriteTransport struct { + target string +} + +func (rt *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Rewrite github.com URLs to the test server. + newURL := rt.target + "/download/" + filepath.Base(req.URL.Path) + newReq, _ := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body) + return http.DefaultTransport.RoundTrip(newReq) +} diff --git a/Server/ws/authz_test.go b/Server/ws/authz_test.go new file mode 100644 index 00000000..c628132f --- /dev/null +++ b/Server/ws/authz_test.go @@ -0,0 +1,154 @@ +package ws_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/ws" +) + +// ─── Authorization tests for WS channel_focus ─────────────────────────────── +// These tests verify that the READ_MESSAGES permission check on channel_focus +// correctly blocks or allows access. + +// channelFocusMsg constructs a raw channel_focus WebSocket envelope. +func channelFocusMsg(channelID int64) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{ + "channel_id": channelID, + }, + }) + return raw +} + +// denyReadOnChannel inserts a channel_override that denies READ_MESSAGES for a +// specific role on a specific channel. +func denyReadOnChannel(t *testing.T, database *db.DB, channelID, roleID int64) { + t.Helper() + _, err := database.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, + channelID, roleID, permissions.ReadMessages, + ) + if err != nil { + t.Fatalf("denyReadOnChannel: %v", err) + } +} + +// TestChannelFocus_AllowedByDefault verifies that a member can focus a channel +// when no override denies READ_MESSAGES (members have it by default). +func TestChannelFocus_AllowedByDefault(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedMemberUser(t, database, "focus-allowed") + chID := seedTestChannel(t, database, "focus-pub") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, channelFocusMsg(chID)) + time.Sleep(50 * time.Millisecond) + + // Should NOT receive a FORBIDDEN error. + msgs := drainChan(send) + for _, m := range msgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "error" { + if payload, ok := env["payload"].(map[string]any); ok { + if payload["code"] == "FORBIDDEN" { + t.Error("member was incorrectly denied channel_focus on accessible channel") + } + } + } + } +} + +// TestChannelFocus_DeniedByOverride verifies that channel_focus is rejected +// when READ_MESSAGES is denied via a channel override. +func TestChannelFocus_DeniedByOverride(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedMemberUser(t, database, "focus-denied") + chID := seedTestChannel(t, database, "focus-priv") + + // Deny READ_MESSAGES for Member role on this channel. + denyReadOnChannel(t, database, chID, permissions.MemberRoleID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, channelFocusMsg(chID)) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN error for denied channel_focus, got %q", code) + } +} + +// TestChannelFocus_AdminBypassesDeny verifies that an Owner/Admin can focus +// any channel regardless of deny overrides. +func TestChannelFocus_AdminBypassesDeny(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "focus-owner") + chID := seedTestChannel(t, database, "focus-priv2") + + // Deny READ_MESSAGES for all non-admin roles. + denyReadOnChannel(t, database, chID, permissions.MemberRoleID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, channelFocusMsg(chID)) + time.Sleep(50 * time.Millisecond) + + // Should NOT receive a FORBIDDEN error. + msgs := drainChan(send) + for _, m := range msgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "error" { + if payload, ok := env["payload"].(map[string]any); ok { + if payload["code"] == "FORBIDDEN" { + t.Error("admin was incorrectly denied channel_focus") + } + } + } + } +} + +// TestChatSend_DeniedWithoutSendMessages verifies that chat_send is rejected +// when READ_MESSAGES or SEND_MESSAGES is denied via a channel override. +func TestChatSend_DeniedWithoutSendMessages(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedMemberUser(t, database, "send-denied") + chID := seedTestChannel(t, database, "send-priv") + + // Deny READ_MESSAGES (which also blocks SEND as both are required). + denyReadOnChannel(t, database, chID, permissions.MemberRoleID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, chatSendMsg(chID, "should be rejected")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN error for denied chat_send, got %q", code) + } +} diff --git a/Server/ws/client.go b/Server/ws/client.go new file mode 100644 index 00000000..9e2071ab --- /dev/null +++ b/Server/ws/client.go @@ -0,0 +1,187 @@ +package ws + +import ( + "sync" + + "github.com/pion/webrtc/v4" + + "github.com/owncord/server/db" +) + +const sendBufSize = 256 + +// SessionCheckInterval is the number of messages processed between periodic +// session-expiry checks in readPump. Exported so tests can trigger the check +// without waiting for a real ticker. +const SessionCheckInterval = 10 + +// Client represents a single authenticated WebSocket connection. +// The underlying transport (conn) is set by ServeWS; in tests it remains nil. +type Client struct { + hub *Hub + conn wsConn // interface — nil in unit tests + userID int64 + user *db.User + channelID int64 // currently viewed channel for channel-scoped broadcasts + voiceChID int64 // voice channel the user is in (0 = not in voice); guarded by voiceMu + pc *webrtc.PeerConnection // SFU peer connection; nil when not in voice; guarded by voiceMu + voiceDone chan struct{} // closed by clearVoice to signal RTP goroutines to exit; guarded by voiceMu + roleName string // cached role name for chat_message broadcasts + tokenHash string // SHA-256 hex of the session token; used for periodic revalidation + msgCount int // count of messages processed; resets after session check + sendClosed bool // true after the send channel has been closed + send chan []byte + mu sync.Mutex // guards sendClosed, msgCount, channelID + voiceMu sync.Mutex // guards voiceChID and pc +} + +// wsConn is the subset of nhooyr.io/websocket.Conn used by writePump/readPump. +// Defining it as an interface lets us avoid importing nhooyr.io/websocket here, +// keeping the core hub logic free from that dependency during unit tests. +type wsConn interface { + // intentionally empty — methods used only in serve.go/client_pump.go +} + +// newClient creates a real client wrapping a WebSocket connection (set by serve.go). +func newClient(hub *Hub, conn wsConn, user *db.User, tokenHash string) *Client { + return &Client{ + hub: hub, + conn: conn, + userID: user.ID, + user: user, + tokenHash: tokenHash, + send: make(chan []byte, sendBufSize), + } +} + +// GetTokenHash returns the session token hash stored on this client. +// Exported for tests. +func (c *Client) GetTokenHash() string { + return c.tokenHash +} + +// NewTestClient creates a client with a caller-supplied send channel. +// Intended for unit tests only — conn is nil. +func NewTestClient(hub *Hub, userID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + userID: userID, + send: send, + } +} + +// NewTestClientWithChannel creates a test client subscribed to a specific channel. +func NewTestClientWithChannel(hub *Hub, userID, channelID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + userID: userID, + channelID: channelID, + send: send, + } +} + +// NewTestClientWithUser creates a test client with an authenticated user record set. +// Use this when tests need the client to pass permission checks. +func NewTestClientWithUser(hub *Hub, user *db.User, channelID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + userID: user.ID, + user: user, + channelID: channelID, + send: send, + } +} + +// SetClientVoiceChID sets the voiceChID field on a client. For test use only. +func SetClientVoiceChID(c *Client, channelID int64) { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + c.voiceChID = channelID +} + +// NewTestClientWithTokenHash creates a test client that carries a session token +// hash. Use this when tests need to exercise the periodic session-expiry check. +func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, channelID int64, send chan []byte) *Client { + return &Client{ + hub: hub, + userID: user.ID, + user: user, + tokenHash: tokenHash, + channelID: channelID, + send: send, + } +} + +// getVoiceChID returns the voice channel ID under voiceMu. +func (c *Client) getVoiceChID() int64 { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + return c.voiceChID +} + +// getPC returns the PeerConnection under voiceMu. +func (c *Client) getPC() *webrtc.PeerConnection { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + return c.pc +} + +// setVoice sets the voice channel and PeerConnection atomically. +// It also creates a done channel that RTP goroutines can select on. +func (c *Client) setVoice(chID int64, pc *webrtc.PeerConnection) { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + c.voiceChID = chID + c.pc = pc + c.voiceDone = make(chan struct{}) +} + +// getVoiceDone returns the done channel for the current voice session. +func (c *Client) getVoiceDone() <-chan struct{} { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + return c.voiceDone +} + +// clearVoice clears voice state and returns the old values for cleanup. +// The caller is responsible for closing the returned PeerConnection. +// Closes the voiceDone channel to signal any RTP goroutines to exit. +func (c *Client) clearVoice() (oldChID int64, oldPC *webrtc.PeerConnection) { + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + oldChID = c.voiceChID + oldPC = c.pc + if c.voiceDone != nil { + close(c.voiceDone) + c.voiceDone = nil + } + c.voiceChID = 0 + c.pc = nil + return +} + +// sendMsg queues a message to this client's send buffer without blocking. +// It is a no-op if the send channel has already been closed. +func (c *Client) sendMsg(msg []byte) { + c.mu.Lock() + defer c.mu.Unlock() + if c.sendClosed { + return + } + select { + case c.send <- msg: + default: + // Buffer full — drop rather than block the hub. + } +} + +// closeSend marks the send channel closed and closes it exactly once. +// Safe to call from any goroutine. +func (c *Client) closeSend() { + c.mu.Lock() + defer c.mu.Unlock() + if !c.sendClosed { + c.sendClosed = true + close(c.send) + } +} diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go new file mode 100644 index 00000000..effd61f1 --- /dev/null +++ b/Server/ws/coverage_boost_test.go @@ -0,0 +1,2463 @@ +package ws_test + +// coverage_boost_test.go adds tests for functions with 0% or low coverage +// to push the ws package above 80%. + +import ( + "encoding/json" + "math" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" + "github.com/owncord/server/ws" +) + +// ─── schema with voice_states + audit_log for coverage tests ────────────────── + +var coverageSchema = append(hubTestSchema, []byte(` +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel_cov ON voice_states(channel_id); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT '', + target_id INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + stored_as TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); +`)...) + +func openCoverageDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: coverageSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) { + t.Helper() + database := openCoverageDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + return hub, database +} + +func seedCoverageOwner(t *testing.T, database *db.DB, username string) *db.User { + t.Helper() + _, err := database.CreateUser(username, "hash", 1) + if err != nil { + t.Fatalf("seedCoverageOwner CreateUser: %v", err) + } + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + t.Fatalf("seedCoverageOwner GetUserByUsername: %v", err) + } + return user +} + +// ─── SetClientVoiceChID (client.go:95 — 0% coverage) ───────────────────────── + +func TestSetClientVoiceChID_SetsValue(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + ws.SetClientVoiceChID(c, 42) + + // Verify by creating a voice room and checking the client is considered in voice. + // Since we can't directly read voiceChID from outside, we verify via HandleVoiceLeaveForTest + // which checks getVoiceChID internally. If voice leave runs without the client being in + // a voice channel, it should be a no-op. + // We just verify it doesn't panic and the function executes. +} + +func TestSetClientVoiceChID_ZeroClearsVoice(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + ws.SetClientVoiceChID(c, 100) + ws.SetClientVoiceChID(c, 0) + // Should not panic. +} + +func TestSetClientVoiceChID_ConcurrentAccess(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + done := make(chan struct{}) + go func() { + for i := range 100 { + ws.SetClientVoiceChID(c, int64(i)) + } + close(done) + }() + for i := range 100 { + ws.SetClientVoiceChID(c, int64(i+100)) + } + <-done +} + +// ─── setupICEMonitor — nil PC guard path (voice_handlers.go:30) ─────────────── + +func TestSetupICEMonitor_NilPC_NoPanic(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + // Client has no PeerConnection (pc == nil). + // setupICEMonitor should return early without panic. + hub.SetupICEMonitorForTest(c, 42) +} + +// ─── setupICECallback — nil PC guard path (voice_handlers.go:67) ────────────── + +func TestSetupICECallback_NilPC_NoPanic(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + // Client has no PeerConnection (pc == nil). + // setupICECallback should return early without panic. + hub.SetupICECallbackForTest(c, 42) +} + +// ─── renegotiateParticipant — nil PC guard path (voice_handlers.go:83) ──────── + +func TestRenegotiateParticipant_NilPC_NoPanic(t *testing.T) { + hub, _ := newCoverageHub(t) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, 1, send) + + // Client has no PeerConnection (pc == nil). + // renegotiateParticipant should return early without panic. + hub.RenegotiateParticipantForTest(c) +} + +// ─── SFU.Close (sfu.go:97 — 0% coverage) ───────────────────────────────────── + +func TestSFU_Close_DoubleClose_NoPanic(t *testing.T) { + cfg := &config.VoiceConfig{ + Quality: "medium", + MediaPortMin: 50000, + MediaPortMax: 50100, + } + sfu, err := ws.NewSFU(cfg) + if err != nil { + t.Fatalf("NewSFU: %v", err) + } + sfu.Close() + // Double close must not panic. + sfu.Close() +} + +// ─── NewSFU with STUN port (sfu.go:73 — 66.7% coverage) ───────────────────── + +func TestNewPeerConnection_WithSTUNPort(t *testing.T) { + cfg := &config.VoiceConfig{ + Quality: "medium", + MediaPortMin: 50000, + MediaPortMax: 50100, + STUNPort: 3478, + } + sfu, err := ws.NewSFU(cfg) + if err != nil { + t.Fatalf("NewSFU: %v", err) + } + defer sfu.Close() + + pc, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection: %v", err) + } + if pc == nil { + t.Fatal("NewPeerConnection returned nil") + } + _ = pc.Close() +} + +func TestNewPeerConnection_WithTURN(t *testing.T) { + cfg := &config.VoiceConfig{ + Quality: "high", + MediaPortMin: 50000, + MediaPortMax: 50100, + STUNPort: 3478, + TURNEnabled: true, + TURNPort: 3479, + TURNSecret: "test-secret", + } + sfu, err := ws.NewSFU(cfg) + if err != nil { + t.Fatalf("NewSFU: %v", err) + } + defer sfu.Close() + + pc, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection: %v", err) + } + if pc == nil { + t.Fatal("NewPeerConnection returned nil") + } + _ = pc.Close() +} + +func TestNewPeerConnection_WithTURNDisabled(t *testing.T) { + cfg := &config.VoiceConfig{ + Quality: "low", + MediaPortMin: 50000, + MediaPortMax: 50100, + TURNEnabled: false, + TURNPort: 3479, + TURNSecret: "test-secret", + } + sfu, err := ws.NewSFU(cfg) + if err != nil { + t.Fatalf("NewSFU: %v", err) + } + defer sfu.Close() + + pc, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection: %v", err) + } + _ = pc.Close() +} + +func TestNewPeerConnection_NoSTUNPort(t *testing.T) { + cfg := &config.VoiceConfig{ + Quality: "medium", + MediaPortMin: 50000, + MediaPortMax: 50100, + STUNPort: 0, + } + sfu, err := ws.NewSFU(cfg) + if err != nil { + t.Fatalf("NewSFU: %v", err) + } + defer sfu.Close() + + pc, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection: %v", err) + } + _ = pc.Close() +} + +// ─── buildJSON error fallback (messages.go:18 — 75% coverage) ──────────────── + +func TestBuildJSON_UnmarshalableValue_ReturnsFallback(t *testing.T) { + // math.Inf is not valid JSON — forces the error path in buildJSON. + out := ws.BuildJSONForTest(math.Inf(1)) + if !json.Valid(out) { + t.Fatalf("fallback output is not valid JSON: %s", out) + } + var m map[string]string + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal fallback: %v", err) + } + if m["type"] != "error" { + t.Errorf("fallback type = %q, want error", m["type"]) + } + if m["message"] != "internal marshal error" { + t.Errorf("fallback message = %q, want 'internal marshal error'", m["message"]) + } +} + +func TestBuildJSON_ChannelValue_ReturnsFallback(t *testing.T) { + // Channels are not JSON-marshalable. + out := ws.BuildJSONForTest(make(chan int)) + if !json.Valid(out) { + t.Fatalf("fallback output is not valid JSON: %s", out) + } +} + +// ─── GracefulStop with clients having voice state (hub.go:188 — 75%) ───────── + +func TestGracefulStop_WithClientsHavingVoiceState(t *testing.T) { + hub, database := newCoverageHub(t) + + user := seedCoverageOwner(t, database, "graceful-voice-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Set voice channel ID on the client to simulate voice state. + ws.SetClientVoiceChID(c, 42) + + // Create a voice room so GracefulStop has rooms to clean up. + hub.GetOrCreateVoiceRoom(42, ws.VoiceRoomConfig{ChannelID: 42, MaxUsers: 10, Quality: "medium"}) + + hub.GracefulStop() + time.Sleep(20 * time.Millisecond) + + // Voice rooms should be cleaned up. + if hub.GetVoiceRoom(42) != nil { + t.Error("expected voice room to be nil after GracefulStop") + } +} + +func TestGracefulStop_MultipleClients(t *testing.T) { + hub, database := newCoverageHub(t) + + for i := range 5 { + user := seedCoverageOwner(t, database, strings.ReplaceAll("graceful-multi-"+string(rune('a'+i)), "", "")) + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + } + time.Sleep(30 * time.Millisecond) + + hub.GracefulStop() +} + +// ─── handleChatSend additional branches (handlers.go:127 — 76.2%) ──────────── + +func TestHandleChatSend_EmptyContent(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "empty-content-user") + chID := seedTestChannel(t, database, "empty-content-chan") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for empty content", code) + } +} + +func TestHandleChatSend_ContentTooLong(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "long-content-user") + chID := seedTestChannel(t, database, "long-content-chan") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Content over 4000 characters. + longContent := strings.Repeat("x", 4001) + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": longContent, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for content too long", code) + } +} + +func TestHandleChatSend_InvalidChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "bad-chid-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": "not-a-number", + "content": "hello", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid channel_id", code) + } +} + +func TestHandleChatSend_ChannelNotFound(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "notfound-chan-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": 99999, + "content": "hello", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "NOT_FOUND" { + t.Errorf("error code = %q, want NOT_FOUND for nonexistent channel", code) + } +} + +func TestHandleChatSend_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "bad-payload-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid payload", code) + } +} + +func TestHandleChatSend_NegativeChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "neg-chid-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": -1, + "content": "hello", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for negative channel_id", code) + } +} + +// ─── handleChatSend with reply_to (handlers.go:127 — covers reply_to path) ── + +func TestHandleChatSend_WithReplyTo(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "reply-user") + chID := seedTestChannel(t, database, "reply-chan") + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send first message to get an ID. + raw1, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "req-1", + "payload": map[string]any{ + "channel_id": chID, + "content": "original message", + }, + }) + hub.HandleMessageForTest(c, raw1) + time.Sleep(50 * time.Millisecond) + + // Drain to find the message ID from chat_send_ok. + var msgID float64 + timeout := time.After(500 * time.Millisecond) +drainFirst: + for { + select { + case msg := <-send: + var env map[string]any + if err := json.Unmarshal(msg, &env); err == nil { + if env["type"] == "chat_send_ok" { + if p, ok := env["payload"].(map[string]any); ok { + msgID = p["message_id"].(float64) + } + break drainFirst + } + } + case <-timeout: + t.Fatal("did not receive chat_send_ok for first message") + } + } + + // Drain remaining messages. + drainChanBuf(send) + + // Send reply. + replyTo := int64(msgID) + raw2, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "req-2", + "payload": map[string]any{ + "channel_id": chID, + "content": "reply message", + "reply_to": replyTo, + }, + }) + hub.HandleMessageForTest(c, raw2) + time.Sleep(50 * time.Millisecond) + + // Should get chat_send_ok for the reply. + found := false + timeout2 := time.After(500 * time.Millisecond) +drainReply: + for { + select { + case msg := <-send: + var env map[string]any + if err := json.Unmarshal(msg, &env); err == nil { + if env["type"] == "chat_send_ok" && env["id"] == "req-2" { + found = true + break drainReply + } + } + case <-timeout2: + break drainReply + } + } + if !found { + t.Error("expected chat_send_ok for reply message") + } +} + +// ─── Ping message type (handlers.go — pong response) ───────────────────────── + +func TestHandleMessage_Ping_ReturnsPong(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "ping-user") + send := make(chan []byte, 4) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{"type": "ping"}) + hub.HandleMessageForTest(c, raw) + time.Sleep(20 * time.Millisecond) + + select { + case msg := <-send: + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env["type"] != "pong" { + t.Errorf("type = %q, want pong", env["type"]) + } + case <-time.After(500 * time.Millisecond): + t.Error("expected pong response") + } +} + +// ─── buildReady with voice channel having participants ──────────────────────── + +func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "ready-voice-user") + + // Create a voice channel. + vcID, err := database.CreateChannel("voice-room", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel voice: %v", err) + } + + // Create another user and join them to voice. + other := seedCoverageOwner(t, database, "ready-voice-other") + if err := database.JoinVoiceChannel(other.ID, vcID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + msg, err := hub.BuildReadyForTest(database, user.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + + var env struct { + Payload struct { + VoiceStates []struct { + ChannelID float64 `json:"channel_id"` + UserID float64 `json:"user_id"` + } `json:"voice_states"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Payload.VoiceStates) != 1 { + t.Errorf("voice_states count = %d, want 1", len(env.Payload.VoiceStates)) + } +} + +func TestBuildReady_MultipleChannelTypes(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "ready-multi-user") + + // Create text and voice channels. + _, err := database.CreateChannel("text-chan", "text", "General", "", 0) + if err != nil { + t.Fatalf("CreateChannel text: %v", err) + } + _, err = database.CreateChannel("voice-chan", "voice", "General", "", 1) + if err != nil { + t.Fatalf("CreateChannel voice: %v", err) + } + + msg, err := hub.BuildReadyForTest(database, user.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + + var env struct { + Payload struct { + Channels []map[string]any `json:"channels"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Payload.Channels) != 2 { + t.Errorf("channels count = %d, want 2", len(env.Payload.Channels)) + } + + // Text channels should have unread_count; voice channels should not. + for _, ch := range env.Payload.Channels { + if ch["type"] == "text" { + if _, ok := ch["unread_count"]; !ok { + t.Error("text channel missing unread_count") + } + } + } +} + +// ─── voice handler edge cases ──────────────────────────────────────────────── + +func TestHandleVoiceJoin_InvalidChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-bad-chid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": "not-a-number", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleVoiceJoin_NegativeChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-neg-chid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": -1, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleVoiceMute_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vm-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_mute", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid voice_mute payload", code) + } +} + +func TestHandleVoiceDeafen_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vd-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_deafen", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid voice_deafen payload", code) + } +} + +func TestHandleVoiceOffer_NoPC(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vo-no-pc") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_offer", + "payload": map[string]any{ + "channel_id": 1, + "sdp": "v=0\r\n", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "VOICE_ERROR" { + t.Errorf("error code = %q, want VOICE_ERROR for no PC", code) + } +} + +func TestHandleVoiceAnswer_NoPC(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "va-no-pc") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_answer", + "payload": map[string]any{ + "channel_id": 1, + "sdp": "v=0\r\n", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "VOICE_ERROR" { + t.Errorf("error code = %q, want VOICE_ERROR for no PC", code) + } +} + +func TestHandleVoiceICE_NoPC(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vi-no-pc") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_ice", + "payload": map[string]any{ + "channel_id": 1, + "candidate": map[string]any{"candidate": ""}, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "VOICE_ERROR" { + t.Errorf("error code = %q, want VOICE_ERROR for no PC", code) + } +} + +func TestHandleVoiceOffer_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vo-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Client needs a PC for the payload to be parsed. + // Without a PC, we get VOICE_ERROR before parsing. + // Test the payload parse path requires a PC, so test that path + // via the no-PC early return above. + raw, _ := json.Marshal(map[string]any{ + "type": "voice_offer", + "payload": "bad", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code == "" { + t.Error("expected an error for invalid voice_offer payload") + } +} + +func TestHandleVoiceOffer_EmptySDP(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vo-empty-sdp") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Without PC, gets VOICE_ERROR before SDP check. That's fine — it covers + // the rate limiter and early-return path. + raw, _ := json.Marshal(map[string]any{ + "type": "voice_offer", + "payload": map[string]any{ + "channel_id": 1, + "sdp": "", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code == "" { + t.Error("expected an error for empty SDP") + } +} + +func TestHandleVoiceAnswer_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "va-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_answer", + "payload": "bad", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code == "" { + t.Error("expected error for invalid voice_answer payload") + } +} + +func TestHandleVoiceICE_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vi-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_ice", + "payload": "bad", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code == "" { + t.Error("expected error for invalid voice_ice payload") + } +} + +// ─── voice camera and screenshare error paths ──────────────────────────────── + +func TestHandleVoiceCamera_NotInVoice(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vc-not-in-voice") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_camera", + "payload": map[string]any{ + "enabled": true, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "VOICE_ERROR" { + t.Errorf("error code = %q, want VOICE_ERROR", code) + } +} + +func TestHandleVoiceCamera_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vc-bad-payload") + vcID, err := database.CreateChannel("cam-vc", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Set voice channel so the not-in-voice check passes. + ws.SetClientVoiceChID(c, vcID) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_camera", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleVoiceScreenshare_NotInVoice(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vs-not-in-voice") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_screenshare", + "payload": map[string]any{ + "enabled": true, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "VOICE_ERROR" { + t.Errorf("error code = %q, want VOICE_ERROR", code) + } +} + +func TestHandleVoiceScreenshare_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vs-bad-payload") + vcID, err := database.CreateChannel("screen-vc", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + ws.SetClientVoiceChID(c, vcID) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_screenshare", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +// ─── soundboard handler error paths ────────────────────────────────────────── + +func TestHandleSoundboard_MissingSoundID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "sb-missing-id") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "soundboard_play", + "payload": map[string]any{}, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for missing sound_id", code) + } +} + +func TestHandleSoundboard_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "sb-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "soundboard_play", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid soundboard payload", code) + } +} + +// ─── channel_focus handler ─────────────────────────────────────────────────── + +func TestHandleChannelFocus_InvalidChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "cf-bad-chid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{ + "channel_id": "not-a-number", + }, + }) + hub.HandleMessageForTest(c, raw) + // Invalid channel_id in channel_focus is silently ignored (slog.Debug). + // No error sent to client. Just verify no panic. + time.Sleep(20 * time.Millisecond) +} + +func TestHandleChannelFocus_ValidChannel(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "cf-valid") + chID := seedTestChannel(t, database, "cf-valid-chan") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{ + "channel_id": chID, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(20 * time.Millisecond) + // Should not error — just update internal state. +} + +// ─── presence handler error paths ──────────────────────────────────────────── + +func TestHandlePresence_InvalidStatus(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "pres-bad-status") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "presence_update", + "payload": map[string]any{ + "status": "invisible", // not allowed per CLAUDE.md + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid status", code) + } +} + +func TestHandlePresence_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "pres-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "presence_update", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid presence payload", code) + } +} + +// ─── typing handler error path ─────────────────────────────────────────────── + +func TestHandleTyping_InvalidChannelID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "typing-bad-chid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "typing_start", + "payload": map[string]any{ + "channel_id": -1, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for invalid typing channel_id", code) + } +} + +// ─── message builder coverage ──────────────────────────────────────────────── + +func TestBuildPresenceMsg_ValidJSON(t *testing.T) { + msg := ws.BuildJSONForTest(map[string]any{ + "type": "presence", + "payload": map[string]any{ + "user_id": 1, + "status": "online", + }, + }) + if !json.Valid(msg) { + t.Error("buildPresenceMsg output is not valid JSON") + } +} + +func TestBuildChatSendOK_ValidJSON(t *testing.T) { + msg := ws.BuildJSONForTest(map[string]any{ + "type": "chat_send_ok", + "id": "req-1", + "payload": map[string]any{ + "message_id": 1, + "timestamp": "2024-01-01T00:00:00Z", + }, + }) + if !json.Valid(msg) { + t.Error("buildChatSendOK output is not valid JSON") + } +} + +// ─── SendToUser full buffer path (hub.go:308 — 87.5%) ─────────────────────── + +func TestSendToUser_FullBuffer_ReturnsFalse(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "send-full-user") + // Create a send channel with buffer size 1. + send := make(chan []byte, 1) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Fill the buffer. + send <- []byte(`{"type":"filler"}`) + + // Next send should return false (buffer full). + ok := hub.SendToUser(user.ID, []byte(`{"type":"overflow"}`)) + if ok { + t.Error("SendToUser should return false when send buffer is full") + } +} + +// ─── handleChatSend with attachments (handlers.go:127 — 76.2%) ────────────── + +func TestHandleChatSend_WithAttachments_NoPermission(t *testing.T) { + hub, database := newCoverageHub(t) + // Use a member user. + _, err := database.CreateUser("attach-noperm-user", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + user, err := database.GetUserByUsername("attach-noperm-user") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + + chID := seedTestChannel(t, database, "attach-noperm-chan") + + // Deny ATTACH_FILES (0x0020) on this channel for Member role (id=4). + _, err = database.Exec("INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 32)", chID) + if err != nil { + t.Fatalf("INSERT channel_overrides: %v", err) + } + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "msg with attachment", + "attachments": []string{"att-id-1"}, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("error code = %q, want FORBIDDEN for denied ATTACH_FILES permission", code) + } +} + +func TestHandleChatSend_WithAttachments_Success(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "attach-ok-user") + chID := seedTestChannel(t, database, "attach-ok-chan") + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "attach-req", + "payload": map[string]any{ + "channel_id": chID, + "content": "msg with attachment", + "attachments": []string{"nonexistent-att-id"}, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(100 * time.Millisecond) + + // Should still succeed (attachments that don't exist are silently skipped). + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "chat_send_ok" { + found = true + break + } + } + if !found { + t.Error("expected chat_send_ok even with nonexistent attachment IDs") + } +} + +// ─── handleChatSend slow mode for non-mod user (handlers.go:164) ──────────── + +func TestHandleChatSend_SlowMode_EnforcedForMember(t *testing.T) { + hub, database := newCoverageHub(t) + _, err := database.CreateUser("slow-member-user", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + user, err := database.GetUserByUsername("slow-member-user") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + + // Create channel with slow mode. + chID, err := database.CreateChannel("slow-chan", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.SetChannelSlowMode(chID, 60); err != nil { + t.Fatalf("SetChannelSlowMode: %v", err) + } + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "first message", + }, + }) + + // First message should succeed. + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + drainChanBuf(send) + + // Second message should be rate limited by slow mode. + raw2, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "second message", + }, + }) + hub.HandleMessageForTest(c, raw2) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "SLOW_MODE" { + t.Errorf("error code = %q, want SLOW_MODE", code) + } +} + +// ─── handleChatEdit more paths (handlers.go:249 — 89.7%) ──────────────────── + +func TestHandleChatEdit_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "edit-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_edit", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleChatEdit_InvalidMessageID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "edit-bad-msgid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_edit", + "payload": map[string]any{ + "message_id": -1, + "content": "updated", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleChatEdit_EmptyContent(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "edit-empty") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_edit", + "payload": map[string]any{ + "message_id": 1, + "content": "", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +// ─── handleChatDelete more paths (handlers.go:298) ─────────────────────────── + +func TestHandleChatDelete_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "delete-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_delete", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleChatDelete_InvalidMessageID(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "delete-bad-msgid") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_delete", + "payload": map[string]any{ + "message_id": -1, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleChatDelete_MessageNotFound(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "delete-notfound") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_delete", + "payload": map[string]any{ + "message_id": 99999, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "NOT_FOUND" { + t.Errorf("error code = %q, want NOT_FOUND", code) + } +} + +// ─── handleReaction more paths (handlers.go:337) ───────────────────────────── + +func TestHandleReaction_InvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "react-bad-payload") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "reaction_add", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleReaction_EmptyEmoji(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "react-empty-emoji") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "reaction_add", + "payload": map[string]any{ + "message_id": 1, + "emoji": "", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleReaction_EmojiTooLong(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "react-long-emoji") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "reaction_add", + "payload": map[string]any{ + "message_id": 1, + "emoji": strings.Repeat("x", 33), + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST", code) + } +} + +func TestHandleReaction_ControlCharInEmoji(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "react-ctrl-emoji") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "reaction_add", + "payload": map[string]any{ + "message_id": 1, + "emoji": "\x00bad", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for control char emoji", code) + } +} + +// ─── handleChannelFocus with message marking (handlers.go:507) ─────────────── + +func TestHandleChannelFocus_UpdatesReadState(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "cf-readstate-user") + chID := seedTestChannel(t, database, "cf-readstate-chan") + + // Insert a message so there's a latest_message_id. + _, err := database.CreateMessage(chID, user.ID, "test message", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{ + "channel_id": chID, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + // No error expected — just verify no panic. +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// drainForErrorCode reads from ch until an error message is found or deadline passes. +func drainForErrorCode(ch <-chan []byte, deadline time.Duration) string { + timer := time.NewTimer(deadline) + defer timer.Stop() + for { + select { + case msg := <-ch: + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + if env["type"] == "error" { + if payload, ok := env["payload"].(map[string]any); ok { + code, _ := payload["code"].(string) + return code + } + } + case <-timer.C: + return "" + } + } +} + +// drainChanBuf drains all buffered messages from a channel. +func drainChanBuf(ch <-chan []byte) { + for { + select { + case <-ch: + default: + return + } + } +} + +// drainChanTimeout reads messages until timeout, returning all collected. +func drainChanTimeout(ch <-chan []byte, d time.Duration) [][]byte { + var msgs [][]byte + timer := time.NewTimer(d) + defer timer.Stop() + for { + select { + case msg := <-ch: + msgs = append(msgs, msg) + case <-timer.C: + return msgs + } + } +} + +// ─── voice join/leave full flow (voice_handlers.go coverage) ───────────────── + +func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 { + t.Helper() + id, err := database.CreateChannel(name, "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel voice: %v", err) + } + return id +} + +func TestHandleVoiceJoin_FullFlow(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-flow-user") + vcID := seedVoiceChannel(t, database, "vj-flow-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 500*time.Millisecond) + foundState := false + foundConfig := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil { + switch env["type"] { + case "voice_state": + foundState = true + case "voice_config": + foundConfig = true + } + } + } + if !foundState { + t.Error("expected voice_state broadcast after voice_join") + } + if !foundConfig { + t.Error("expected voice_config after voice_join") + } +} + +func TestHandleVoiceJoin_AlreadyInSameChannel(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-same-user") + vcID := seedVoiceChannel(t, database, "vj-same-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(100 * time.Millisecond) + drainChanBuf(send) + + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "ALREADY_JOINED" { + t.Errorf("error code = %q, want ALREADY_JOINED", code) + } +} + +func TestHandleVoiceJoin_SwitchChannels(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-switch-user") + vc1 := seedVoiceChannel(t, database, "vj-switch-vc1") + vc2 := seedVoiceChannel(t, database, "vj-switch-vc2") + send := make(chan []byte, 128) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw1, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vc1, + }, + }) + hub.HandleMessageForTest(c, raw1) + time.Sleep(100 * time.Millisecond) + drainChanBuf(send) + + raw2, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vc2, + }, + }) + hub.HandleMessageForTest(c, raw2) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + foundLeave := false + foundConfig := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil { + switch env["type"] { + case "voice_leave": + foundLeave = true + case "voice_config": + foundConfig = true + } + } + } + if !foundLeave { + t.Error("expected voice_leave broadcast when switching channels") + } + if !foundConfig { + t.Error("expected voice_config for new channel") + } +} + +func TestHandleVoiceJoin_ChannelFull(t *testing.T) { + hub, database := newCoverageHub(t) + vcID, err := database.CreateChannel("full-vc", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + _, err = database.Exec("UPDATE channels SET voice_max_users = 1 WHERE id = ?", vcID) + if err != nil { + t.Fatalf("UPDATE channels: %v", err) + } + + user1 := seedCoverageOwner(t, database, "vj-full-u1") + send1 := make(chan []byte, 64) + c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) + hub.Register(c1) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c1, raw) + time.Sleep(100 * time.Millisecond) + drainChanBuf(send1) + + user2 := seedCoverageOwner(t, database, "vj-full-u2") + send2 := make(chan []byte, 64) + c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c2, raw) + time.Sleep(100 * time.Millisecond) + + code := drainForErrorCode(send2, 300*time.Millisecond) + if code != "CHANNEL_FULL" { + t.Errorf("error code = %q, want CHANNEL_FULL", code) + } +} + +func TestHandleVoiceLeave_ExplicitLeave(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vl-explicit-user") + vcID := seedVoiceChannel(t, database, "vl-explicit-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, joinRaw) + time.Sleep(100 * time.Millisecond) + drainChanBuf(send) + + leaveRaw, _ := json.Marshal(map[string]any{"type": "voice_leave"}) + hub.HandleMessageForTest(c, leaveRaw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + foundLeave := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" { + foundLeave = true + break + } + } + if !foundLeave { + t.Error("expected voice_leave broadcast after explicit leave") + } + + if hub.GetVoiceRoom(vcID) != nil { + t.Error("expected voice room to be removed after last participant leaves") + } +} + +func TestHandleVoiceLeave_NotInVoice(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vl-not-in-voice") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleVoiceLeaveForTest(c) + time.Sleep(20 * time.Millisecond) +} + +func TestHandleVoiceMute_FullFlow(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vm-flow-user") + vcID := seedVoiceChannel(t, database, "vm-flow-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, joinRaw) + time.Sleep(100 * time.Millisecond) + drainChanBuf(send) + + muteRaw, _ := json.Marshal(map[string]any{ + "type": "voice_mute", + "payload": map[string]any{ + "muted": true, + }, + }) + hub.HandleMessageForTest(c, muteRaw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + found = true + break + } + } + if !found { + t.Error("expected voice_state broadcast after mute") + } +} + +func TestHandleVoiceDeafen_FullFlow(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vd-flow-user") + vcID := seedVoiceChannel(t, database, "vd-flow-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, joinRaw) + time.Sleep(100 * time.Millisecond) + drainChanBuf(send) + + deafenRaw, _ := json.Marshal(map[string]any{ + "type": "voice_deafen", + "payload": map[string]any{ + "deafened": true, + }, + }) + hub.HandleMessageForTest(c, deafenRaw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + found = true + break + } + } + if !found { + t.Error("expected voice_state broadcast after deafen") + } +} + +func TestHandleVoiceJoin_ChannelNotFound(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-notfound-user") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": 99999, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "NOT_FOUND" { + t.Errorf("error code = %q, want NOT_FOUND", code) + } +} + +func TestHandleVoiceJoin_WithQualityOverride(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-quality-user") + + vcID, err := database.CreateChannel("quality-vc", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + _, err = database.Exec("UPDATE channels SET voice_quality = 'high' WHERE id = ?", vcID) + if err != nil { + t.Fatalf("UPDATE: %v", err) + } + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_config" { + p := env["payload"].(map[string]any) + if p["quality"] != "high" { + t.Errorf("voice_config quality = %v, want high", p["quality"]) + } + return + } + } + t.Error("expected voice_config with quality override") +} + +func TestHandleVoiceJoin_WithMixingThresholdOverride(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vj-thresh-user") + + vcID, err := database.CreateChannel("thresh-vc", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + _, err = database.Exec("UPDATE channels SET mixing_threshold = 5 WHERE id = ?", vcID) + if err != nil { + t.Fatalf("UPDATE: %v", err) + } + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_config" { + p := env["payload"].(map[string]any) + if p["mixing_threshold"] != float64(5) { + t.Errorf("voice_config mixing_threshold = %v, want 5", p["mixing_threshold"]) + } + return + } + } + t.Error("expected voice_config with mixing_threshold override") +} + +func TestHandleVoiceJoin_MultipleParticipants(t *testing.T) { + hub, database := newCoverageHub(t) + vcID := seedVoiceChannel(t, database, "vj-multi-vc") + + user1 := seedCoverageOwner(t, database, "vj-multi-u1") + send1 := make(chan []byte, 64) + c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) + hub.Register(c1) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c1, raw) + time.Sleep(100 * time.Millisecond) + drainChanBuf(send1) + + user2 := seedCoverageOwner(t, database, "vj-multi-u2") + send2 := make(chan []byte, 64) + c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c2, raw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send2, 300*time.Millisecond) + voiceStateCount := 0 + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + voiceStateCount++ + } + } + if voiceStateCount < 2 { + t.Errorf("voice_state count = %d, want at least 2", voiceStateCount) + } +} + +func TestHandleVoiceLeave_BroadcastsToOtherParticipants(t *testing.T) { + hub, database := newCoverageHub(t) + vcID := seedVoiceChannel(t, database, "vl-bcast-vc") + + user1 := seedCoverageOwner(t, database, "vl-bcast-u1") + user2 := seedCoverageOwner(t, database, "vl-bcast-u2") + send1 := make(chan []byte, 64) + send2 := make(chan []byte, 64) + c1 := ws.NewTestClientWithUser(hub, user1, 0, send1) + c2 := ws.NewTestClientWithUser(hub, user2, 0, send2) + hub.Register(c1) + hub.Register(c2) + time.Sleep(30 * time.Millisecond) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c1, joinRaw) + time.Sleep(100 * time.Millisecond) + hub.HandleMessageForTest(c2, joinRaw) + time.Sleep(100 * time.Millisecond) + drainChanBuf(send1) + drainChanBuf(send2) + + leaveRaw, _ := json.Marshal(map[string]any{"type": "voice_leave"}) + hub.HandleMessageForTest(c1, leaveRaw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send2, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_leave" { + found = true + break + } + } + if !found { + t.Error("user2 should receive voice_leave when user1 leaves") + } +} + +func TestHandleVoiceCamera_FullFlow(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vc-flow-user") + vcID := seedVoiceChannel(t, database, "vc-flow-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, joinRaw) + time.Sleep(100 * time.Millisecond) + drainChanBuf(send) + + camRaw, _ := json.Marshal(map[string]any{ + "type": "voice_camera", + "payload": map[string]any{ + "enabled": true, + }, + }) + hub.HandleMessageForTest(c, camRaw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + found = true + break + } + } + if !found { + t.Error("expected voice_state after camera toggle") + } +} + +func TestHandleVoiceScreenshare_FullFlow(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "vs-flow-user") + vcID := seedVoiceChannel(t, database, "vs-flow-vc") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + joinRaw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{ + "channel_id": vcID, + }, + }) + hub.HandleMessageForTest(c, joinRaw) + time.Sleep(100 * time.Millisecond) + drainChanBuf(send) + + ssRaw, _ := json.Marshal(map[string]any{ + "type": "voice_screenshare", + "payload": map[string]any{ + "enabled": true, + }, + }) + hub.HandleMessageForTest(c, ssRaw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_state" { + found = true + break + } + } + if !found { + t.Error("expected voice_state after screenshare toggle") + } +} + +func TestHandleChatSend_WithNilAvatar(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "nil-avatar-user") + chID := seedTestChannel(t, database, "nil-avatar-chan") + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "avatar-req", + "payload": map[string]any{ + "channel_id": chID, + "content": "hello from nil avatar user", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + found := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil && env["type"] == "chat_send_ok" { + found = true + break + } + } + if !found { + t.Error("expected chat_send_ok for nil-avatar user") + } +} + +// ─── hasChannelPerm with nil user (handlers.go:454) ────────────────────────── + +func TestHasChannelPerm_NilUser_DeniesPermission(t *testing.T) { + hub, database := newCoverageHub(t) + chID := seedTestChannel(t, database, "perm-nil-user-chan") + send := make(chan []byte, 16) + // Create a test client WITHOUT a user (user == nil). + c := ws.NewTestClient(hub, 1, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Try to send a chat message — should get FORBIDDEN due to nil user. + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "should fail", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := drainForErrorCode(send, 200*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("error code = %q, want FORBIDDEN for nil user", code) + } +} + +// ─── deliverBroadcast with full send buffer (hub.go:344) ───────────────────── + +func TestDeliverBroadcast_FullBuffer_DropsMessage(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "bcast-full-user") + // Create a tiny send buffer. + send := make(chan []byte, 1) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Fill the buffer. + send <- []byte(`{"type":"filler"}`) + + // Broadcasting should not block — message dropped. + hub.BroadcastToAll([]byte(`{"type":"should_be_dropped"}`)) + time.Sleep(50 * time.Millisecond) + // No assertion needed — just verify no deadlock. +} + +func TestBuildAuthOK_NonNilAvatar(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "authok-avatar-user") + // Set a non-nil avatar. + _, err := database.Exec("UPDATE users SET avatar = 'https://example.com/pic.png' WHERE id = ?", user.ID) + if err != nil { + t.Fatalf("UPDATE avatar: %v", err) + } + user, err = database.GetUserByUsername("authok-avatar-user") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + + msg := hub.BuildAuthOKForTest(user, "owner") + var env struct { + Payload struct { + User struct { + Avatar string `json:"avatar"` + } `json:"user"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.User.Avatar != "https://example.com/pic.png" { + t.Errorf("avatar = %q, want https://example.com/pic.png", env.Payload.User.Avatar) + } +} + +func TestHandleChatSend_WithNonNilAvatar(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "avatar-user") + // Set a non-nil avatar on the user. + _, err := database.Exec("UPDATE users SET avatar = 'https://example.com/avatar.png' WHERE id = ?", user.ID) + if err != nil { + t.Fatalf("UPDATE avatar: %v", err) + } + // Reload user to get updated avatar. + user, err = database.GetUserByUsername("avatar-user") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + + chID := seedTestChannel(t, database, "avatar-chan") + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": "avatar-req2", + "payload": map[string]any{ + "channel_id": chID, + "content": "hello from avatar user", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(100 * time.Millisecond) + + msgs := drainChanTimeout(send, 300*time.Millisecond) + foundOK := false + foundBroadcast := false + for _, msg := range msgs { + var env map[string]any + if json.Unmarshal(msg, &env) == nil { + if env["type"] == "chat_send_ok" { + foundOK = true + } + if env["type"] == "chat_message" { + // Verify avatar is present in broadcast. + if p, ok := env["payload"].(map[string]any); ok { + if u, ok := p["user"].(map[string]any); ok { + if u["avatar"] == "https://example.com/avatar.png" { + foundBroadcast = true + } + } + } + } + } + } + if !foundOK { + t.Error("expected chat_send_ok for avatar user") + } + if !foundBroadcast { + t.Error("expected chat_message with non-nil avatar") + } +} diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go new file mode 100644 index 00000000..934e7e89 --- /dev/null +++ b/Server/ws/export_test.go @@ -0,0 +1,63 @@ +// export_test.go exposes unexported functions and methods for use in external +// test packages (package ws_test). This file is compiled only during "go test". +package ws + +import ( + "encoding/json" + "time" + + "github.com/owncord/server/db" +) + +// BuildAuthOKForTest exposes Hub.buildAuthOK for external tests. +func (h *Hub) BuildAuthOKForTest(user *db.User, roleName string) []byte { + return h.buildAuthOK(user, roleName) +} + +// BuildReadyForTest exposes Hub.buildReady for external tests. +func (h *Hub) BuildReadyForTest(database *db.DB, userID int64) ([]byte, error) { + return h.buildReady(database, userID) +} + +// GetCachedSettingsForTest exposes Hub.getCachedSettings for external tests. +func (h *Hub) GetCachedSettingsForTest() (string, string) { + return h.getCachedSettings() +} + +// ExpireSettingsCacheForTest forces the settings cache to appear stale so that +// the next call to getCachedSettings triggers a DB refresh. +func (h *Hub) ExpireSettingsCacheForTest() { + h.settingsMu.Lock() + defer h.settingsMu.Unlock() + h.settingsLastUpdate = time.Time{} // zero time — always older than any TTL +} + +// ParseChannelIDForTest exposes parseChannelID for external tests. +func ParseChannelIDForTest(payload json.RawMessage) (int64, error) { + return parseChannelID(payload) +} + +// BuildJSONForTest exposes buildJSON for external tests. +func BuildJSONForTest(v any) []byte { + return buildJSON(v) +} + +// BuildVoiceOfferForTest exposes buildVoiceOffer for external tests. +func BuildVoiceOfferForTest(channelID int64, sdp string) []byte { + return buildVoiceOffer(channelID, sdp) +} + +// BuildVoiceICEForTest exposes buildVoiceICE for external tests. +func BuildVoiceICEForTest(channelID int64, candidate any) []byte { + return buildVoiceICE(channelID, candidate) +} + +// SetupICECallbackForTest exposes setupICECallback for external tests. +func (h *Hub) SetupICECallbackForTest(c *Client, channelID int64) { + h.setupICECallback(c, channelID) +} + +// RenegotiateParticipantForTest exposes renegotiateParticipant for external tests. +func (h *Hub) RenegotiateParticipantForTest(c *Client) { + h.renegotiateParticipant(c) +} diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go new file mode 100644 index 00000000..4029dd74 --- /dev/null +++ b/Server/ws/handlers.go @@ -0,0 +1,533 @@ +package ws + +import ( + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/microcosm-cc/bluemonday" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// Rate limit windows. +const ( + chatRateLimit = 10 + chatWindow = time.Second + typingRateLimit = 1 + typingWindow = 3 * time.Second + presenceRateLimit = 1 + presenceWindow = 10 * time.Second + reactionRateLimit = 5 + reactionWindow = time.Second +) + +var sanitizer = bluemonday.StrictPolicy() + +// HandleMessageForTest dispatches a raw WebSocket message from client c. +// Exported so ws_test package can invoke it directly without a real connection. +func (h *Hub) HandleMessageForTest(c *Client, raw []byte) { + h.handleMessage(c, raw) +} + +// HandleVoiceLeaveForTest calls handleVoiceLeave directly, simulating a +// disconnect-triggered cleanup without an explicit voice_leave message. +// Exported for ws_test package use only. +func (h *Hub) HandleVoiceLeaveForTest(c *Client) { + h.handleVoiceLeave(c) +} + + +// handleMessage parses the envelope and dispatches to the appropriate handler. +func (h *Hub) handleMessage(c *Client, raw []byte) { + // Periodic session expiry check: every SessionCheckInterval messages, + // re-validate the session token. This catches sessions that are revoked or + // expire while the WebSocket connection is still open. + c.mu.Lock() + c.msgCount++ + shouldCheck := c.msgCount >= SessionCheckInterval + if shouldCheck { + c.msgCount = 0 + } + c.mu.Unlock() + + if shouldCheck && c.tokenHash != "" { + result, dbErr := h.db.GetSessionWithBanStatus(c.tokenHash) + if dbErr != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) { + slog.Info("ws session expired, closing connection", "user_id", c.userID) + h.kickClient(c) + return + } + tempUser := &db.User{Banned: result.Banned, BanExpires: result.BanExpires} + if auth.IsEffectivelyBanned(tempUser) { + slog.Info("ws user banned, closing connection", "user_id", c.userID) + c.sendMsg(buildErrorMsg("BANNED", "you are banned")) + h.kickClient(c) + return + } + } + + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + slog.Warn("ws handleMessage invalid JSON", "user_id", c.userID, "err", err) + c.sendMsg(buildErrorMsg("INVALID_JSON", "message must be valid JSON")) + return + } + + slog.Debug("ws ← client message", "type", env.Type, "user_id", c.userID, "id", env.ID) + + switch env.Type { + case "chat_send": + h.handleChatSend(c, env.ID, env.Payload) + case "chat_edit": + h.handleChatEdit(c, env.ID, env.Payload) + case "chat_delete": + h.handleChatDelete(c, env.ID, env.Payload) + case "reaction_add": + h.handleReaction(c, true, env.Payload) + case "reaction_remove": + h.handleReaction(c, false, env.Payload) + case "typing_start": + h.handleTyping(c, env.Payload) + case "presence_update": + h.handlePresence(c, env.Payload) + case "channel_focus": + h.handleChannelFocus(c, env.Payload) + case "voice_join": + h.handleVoiceJoin(c, env.Payload) + case "voice_leave": + h.handleVoiceLeave(c) + case "voice_mute": + h.handleVoiceMute(c, env.Payload) + case "voice_deafen": + h.handleVoiceDeafen(c, env.Payload) + case "voice_camera": + h.handleVoiceCamera(c, env.Payload) + case "voice_screenshare": + h.handleVoiceScreenshare(c, env.Payload) + case "voice_offer": + h.handleVoiceOffer(c, env.Payload) + case "voice_answer": + h.handleVoiceAnswer(c, env.Payload) + case "voice_ice": + h.handleVoiceICE(c, env.Payload) + case "soundboard_play": + h.handleSoundboard(c, env.Payload) + case "ping": + c.sendMsg(buildJSON(map[string]any{"type": "pong"})) + default: + slog.Warn("ws handleMessage unknown type", "type", env.Type, "user_id", c.userID) + c.sendMsg(buildErrorMsg("UNKNOWN_TYPE", fmt.Sprintf("unknown message type: %s", env.Type))) + } +} + +// handleChatSend processes a chat_send message. +func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { + // Rate limit. + ratKey := fmt.Sprintf("chat:%d", c.userID) + if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) { + c.sendMsg(buildRateLimitError("too many messages", chatWindow.Seconds())) + return + } + + var p struct { + ChannelID json.Number `json:"channel_id"` + Content string `json:"content"` + ReplyTo *int64 `json:"reply_to"` + Attachments []string `json:"attachments"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid chat_send payload")) + return + } + channelID, err := p.ChannelID.Int64() + if err != nil || channelID <= 0 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer")) + return + } + + // Check channel exists. + ch, err := h.db.GetChannel(channelID) + if err != nil || ch == nil { + c.sendMsg(buildErrorMsg("NOT_FOUND", "channel not found")) + return + } + + // Permission check. + if !h.requireChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES") { + return + } + + // Slow mode enforcement: moderators with MANAGE_MESSAGES bypass it. + if ch.SlowMode > 0 && !h.hasChannelPerm(c, channelID, permissions.ManageMessages) { + slowKey := fmt.Sprintf("slow:%d:%d", c.userID, channelID) + if !h.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) { + c.sendMsg(buildErrorMsg("SLOW_MODE", fmt.Sprintf("channel has %ds slow mode", ch.SlowMode))) + return + } + } + + // Sanitize and validate content length. + content := sanitizer.Sanitize(p.Content) + if content == "" && len(p.Attachments) == 0 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "message content cannot be empty")) + return + } + if len([]rune(content)) > 4000 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "message content exceeds maximum length of 4000 characters")) + return + } + + // Check attachment permission before persisting anything. + if len(p.Attachments) > 0 { + if !h.requireChannelPerm(c, channelID, permissions.AttachFiles, "ATTACH_FILES") { + return + } + } + + // Persist message. + msgID, err := h.db.CreateMessage(channelID, c.userID, content, p.ReplyTo) + if err != nil { + slog.Error("ws handleChatSend CreateMessage", "err", err) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to save message")) + return + } + + // Link attachments if provided. + var attachments []map[string]any + if len(p.Attachments) > 0 { + linked, linkErr := h.db.LinkAttachmentsToMessage(msgID, p.Attachments) + if linkErr != nil { + slog.Error("ws handleChatSend LinkAttachments", "err", linkErr) + } + if linked > 0 { + attMap, attErr := h.db.GetAttachmentsByMessageIDs([]int64{msgID}) + if attErr != nil { + slog.Error("ws handleChatSend GetAttachments", "err", attErr) + } else { + for _, ai := range attMap[msgID] { + attachments = append(attachments, map[string]any{ + "id": ai.ID, + "filename": ai.Filename, + "size": ai.Size, + "mime": ai.Mime, + "url": ai.URL, + }) + } + } + } + } + + // Retrieve to get timestamp. + msg, err := h.db.GetMessage(msgID) + if err != nil || msg == nil { + slog.Error("ws handleChatSend GetMessage after create", "err", err) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to retrieve message")) + return + } + + var username string + var avatar *string + if c.user != nil { + username = c.user.Username + avatar = c.user.Avatar + } + + slog.Info("message sent", "user", username, "channel_id", channelID, "msg_id", msgID) + + // Ack sender. + c.sendMsg(buildChatSendOK(reqID, msgID, msg.Timestamp)) + + // Broadcast to channel. + broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, c.roleName, content, msg.Timestamp, p.ReplyTo, attachments) + h.BroadcastToChannel(channelID, broadcast) +} + +// handleChatEdit processes a chat_edit message. +func (h *Hub) handleChatEdit(c *Client, _ string, payload json.RawMessage) { + ratKey := fmt.Sprintf("chat_edit:%d", c.userID) + if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) { + c.sendMsg(buildRateLimitError("too many edits", chatWindow.Seconds())) + return + } + + var p struct { + MessageID json.Number `json:"message_id"` + Content string `json:"content"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid chat_edit payload")) + return + } + msgID, err := p.MessageID.Int64() + if err != nil || msgID <= 0 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "message_id must be positive integer")) + return + } + + content := sanitizer.Sanitize(p.Content) + if content == "" { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "content cannot be empty")) + return + } + + // EditMessage checks ownership internally. + if err := h.db.EditMessage(msgID, c.userID, content); err != nil { + c.sendMsg(buildErrorMsg("FORBIDDEN", "cannot edit this message")) + return + } + + msg, err := h.db.GetMessage(msgID) + if err != nil || msg == nil { + slog.Error("ws handleChatEdit GetMessage after edit", "err", err, "msg_id", msgID) + c.sendMsg(buildErrorMsg("INTERNAL", "edit saved but broadcast failed")) + return + } + + editedAt := "" + if msg.EditedAt != nil { + editedAt = *msg.EditedAt + } + slog.Info("message edited", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID) + h.BroadcastToChannel(msg.ChannelID, buildChatEdited(msgID, msg.ChannelID, content, editedAt)) +} + +// handleChatDelete processes a chat_delete message. +func (h *Hub) handleChatDelete(c *Client, _ string, payload json.RawMessage) { + ratKey := fmt.Sprintf("chat_delete:%d", c.userID) + if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) { + c.sendMsg(buildRateLimitError("too many deletes", chatWindow.Seconds())) + return + } + + var p struct { + MessageID json.Number `json:"message_id"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid chat_delete payload")) + return + } + msgID, err := p.MessageID.Int64() + if err != nil || msgID <= 0 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "message_id must be positive integer")) + return + } + + msg, err := h.db.GetMessage(msgID) + if err != nil || msg == nil { + c.sendMsg(buildErrorMsg("NOT_FOUND", "message not found")) + return + } + + isMod := h.hasChannelPerm(c, msg.ChannelID, permissions.ManageMessages) + if err := h.db.DeleteMessage(msgID, c.userID, isMod); err != nil { + c.sendMsg(buildErrorMsg("FORBIDDEN", "cannot delete this message")) + return + } + + slog.Info("message deleted", "user_id", c.userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod) + _ = h.db.LogAudit(c.userID, "message_delete", "message", msgID, + fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod)) + h.BroadcastToChannel(msg.ChannelID, buildChatDeleted(msgID, msg.ChannelID)) +} + +// handleReaction processes reaction_add and reaction_remove messages. +func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) { + ratKey := fmt.Sprintf("reaction:%d", c.userID) + if !h.limiter.Allow(ratKey, reactionRateLimit, reactionWindow) { + c.sendMsg(buildRateLimitError("too many reactions", reactionWindow.Seconds())) + return + } + + var p struct { + MessageID json.Number `json:"message_id"` + Emoji string `json:"emoji"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid reaction payload")) + return + } + msgID, err := p.MessageID.Int64() + if err != nil || msgID <= 0 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "message_id must be positive integer")) + return + } + if p.Emoji == "" { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji cannot be empty")) + return + } + if len(p.Emoji) > 32 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji too long")) + return + } + // Reject control characters (U+0000–U+001F, U+007F) to prevent injection. + for _, r := range p.Emoji { + if r < 0x20 || r == 0x7F { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji contains invalid characters")) + return + } + } + + msg, err := h.db.GetMessage(msgID) + if err != nil || msg == nil { + // Normalize: return same error whether message doesn't exist or is in + // a channel the user can't see (prevents IDOR information leak). + c.sendMsg(buildErrorMsg("BAD_REQUEST", "reaction failed")) + return + } + + if !h.requireChannelPerm(c, msg.ChannelID, permissions.AddReactions, "ADD_REACTIONS") { + return + } + + action := "add" + if add { + err = h.db.AddReaction(msgID, c.userID, p.Emoji) + } else { + action = "remove" + err = h.db.RemoveReaction(msgID, c.userID, p.Emoji) + } + if err != nil { + // Sanitize: never leak raw DB constraint errors to client. + slog.Warn("reaction failed", "action", action, "msg_id", msgID, "user_id", c.userID, "err", err) + c.sendMsg(buildErrorMsg("CONFLICT", "reaction failed")) + return + } + + h.BroadcastToChannel(msg.ChannelID, buildReactionUpdate(msgID, msg.ChannelID, c.userID, p.Emoji, action)) +} + +// handleTyping processes a typing_start message. +func (h *Hub) handleTyping(c *Client, payload json.RawMessage) { + channelID, err := parseChannelID(payload) + if err != nil || channelID <= 0 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be positive integer")) + return + } + + ratKey := fmt.Sprintf("typing:%d:%d", c.userID, channelID) + if !h.limiter.Allow(ratKey, typingRateLimit, typingWindow) { + return // silently drop; no error for typing throttle + } + + var username string + if c.user != nil { + username = c.user.Username + } + + // Broadcast to channel, excluding sender. + h.broadcastExclude(channelID, c.userID, buildTypingMsg(channelID, c.userID, username)) +} + +// handlePresence processes a presence_update message. +func (h *Hub) handlePresence(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("presence:%d", c.userID) + if !h.limiter.Allow(ratKey, presenceRateLimit, presenceWindow) { + c.sendMsg(buildRateLimitError("too many presence updates", presenceWindow.Seconds())) + return + } + + var p struct { + Status string `json:"status"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid presence_update payload")) + return + } + validStatuses := map[string]bool{"online": true, "idle": true, "dnd": true, "offline": true} + if !validStatuses[p.Status] { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "status must be online|idle|dnd|offline")) + return + } + + if err := h.db.UpdateUserStatus(c.userID, p.Status); err != nil { + slog.Error("ws handlePresence UpdateUserStatus", "err", err) + } + + h.BroadcastToAll(buildPresenceMsg(c.userID, p.Status)) +} + +// hasChannelPerm reports whether the client's role has all the given permission bits. +// The ADMINISTRATOR bit bypasses all checks. +func (h *Hub) hasChannelPerm(c *Client, channelID int64, perm int64) bool { + if c.user == nil { + return false + } + role, err := h.db.GetRoleByID(c.user.RoleID) + if err != nil || role == nil { + return false + } + if role.Permissions&permissions.Administrator != 0 { + return true + } + // Check channel overrides. + allow, deny, err := h.db.GetChannelPermissions(channelID, role.ID) + if err != nil { + return false + } + effective := permissions.EffectivePerms(role.Permissions, allow, deny) + return effective&perm == perm +} + +// requireChannelPerm checks whether the client has the given permission on the +// channel. If not, it sends a FORBIDDEN error to the client and returns false. +// The permLabel should be the human-readable permission name (e.g. "SEND_MESSAGES"). +func (h *Hub) requireChannelPerm(c *Client, channelID int64, perm int64, permLabel string) bool { + if h.hasChannelPerm(c, channelID, perm) { + return true + } + slog.Warn("ws permission denied", "user_id", c.userID, "channel_id", channelID, "perm", permLabel) + c.sendMsg(buildErrorMsg("FORBIDDEN", "missing "+permLabel+" permission")) + return false +} + +// broadcastExclude sends msg to all channel members except excludeUserID. +func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + for uid, c := range h.clients { + if uid == excludeUserID { + continue + } + if channelID != 0 && c.channelID != channelID { + continue + } + select { + case c.send <- msg: + default: + } + } +} + +// handleChannelFocus sets which channel the client is currently viewing, +// so channel-scoped broadcasts (chat messages, typing) reach them. +// Also updates read_states so unread counts decrease when the user views a channel. +func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) { + chID, err := parseChannelID(payload) + if err != nil || chID <= 0 { + slog.Debug("handleChannelFocus: invalid channel_id", "user_id", c.userID, "err", err) + return + } + + // Permission check: user must have READ_MESSAGES on the target channel. + if !h.requireChannelPerm(c, chID, permissions.ReadMessages, "READ_MESSAGES") { + return + } + + c.mu.Lock() + prevCh := c.channelID + c.channelID = chID + c.mu.Unlock() + + slog.Info("channel_focus", "user_id", c.userID, "channel_id", chID, "prev_channel_id", prevCh) + + // Mark channel as read by updating read_states to the latest message. + latestID, latestErr := h.db.GetLatestMessageID(chID) + if latestErr == nil && latestID > 0 { + if rsErr := h.db.UpdateReadState(c.userID, chID, latestID); rsErr != nil { + slog.Warn("handleChannelFocus UpdateReadState", "err", rsErr, "user_id", c.userID, "channel_id", chID) + } + } +} diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go new file mode 100644 index 00000000..17ac5e8a --- /dev/null +++ b/Server/ws/handlers_test.go @@ -0,0 +1,1926 @@ +package ws_test + +import ( + "encoding/json" + "fmt" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/ws" +) + +// ─── schema used by handler tests ───────────────────────────────────────────── + +// handlerTestSchema extends hubTestSchema with the audit_log table required by +// some handler paths, and includes voice_states for completeness. +var handlerTestSchema = append(hubTestSchema, []byte(` +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT '', + target_id INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + stored_as TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); +`)...) + +func openHandlerDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: handlerTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +func newHandlerHub(t *testing.T) (*ws.Hub, *db.DB) { + t.Helper() + database := openHandlerDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + return hub, database +} + +// seedModUser inserts a Moderator-role user (roleID=3, permissions=1048575 which +// includes MANAGE_MESSAGES bit 0x10000). +func seedModUser(t *testing.T, database *db.DB, username string) *db.User { + t.Helper() + _, err := database.CreateUser(username, "hash", 3) // roleID=3 → Moderator + if err != nil { + t.Fatalf("seedModUser CreateUser: %v", err) + } + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + t.Fatalf("seedModUser GetUserByUsername: %v", err) + } + return user +} + +// seedMemberUser inserts a Member-role user (roleID=4, permissions=1635) that +// does NOT have MANAGE_MESSAGES (0x10000=65536). +func seedMemberUser(t *testing.T, database *db.DB, username string) *db.User { + t.Helper() + _, err := database.CreateUser(username, "hash", 4) // roleID=4 → Member + if err != nil { + t.Fatalf("seedMemberUser CreateUser: %v", err) + } + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + t.Fatalf("seedMemberUser GetUserByUsername: %v", err) + } + return user +} + +// seedChannelWithSlowMode creates a text channel and sets its slow_mode to the +// given seconds value, then returns the channel ID. +func seedChannelWithSlowMode(t *testing.T, database *db.DB, name string, slowModeSecs int) int64 { + t.Helper() + chID, err := database.CreateChannel(name, "text", "", "", 0) + if err != nil { + t.Fatalf("seedChannelWithSlowMode CreateChannel: %v", err) + } + if slowModeSecs > 0 { + if err := database.SetChannelSlowMode(chID, slowModeSecs); err != nil { + t.Fatalf("seedChannelWithSlowMode SetChannelSlowMode: %v", err) + } + } + return chID +} + +// chatSendMsg constructs a raw chat_send WebSocket envelope. +func chatSendMsg(channelID int64, content string) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": channelID, + "content": content, + }, + }) + return raw +} + +// receiveErrorCode drains up to n messages from ch and returns the first error +// code field found, or "" if none. +func receiveErrorCode(ch <-chan []byte, deadline time.Duration) string { + timer := time.NewTimer(deadline) + defer timer.Stop() + for { + select { + case msg := <-ch: + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + if env["type"] == "error" { + if payload, ok := env["payload"].(map[string]any); ok { + code, _ := payload["code"].(string) + return code + } + } + case <-timer.C: + return "" + } + } +} + +// ─── 2.2: Session expiry check in readPump ──────────────────────────────────── + +// TestSessionExpiry_TokenHashStoredOnClient verifies that a Client created via +// NewTestClientWithTokenHash carries the tokenHash field for periodic revalidation. +func TestSessionExpiry_TokenHashStoredOnClient(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "expiry-user1") + send := make(chan []byte, 16) + + hash := "deadbeefdeadbeef" + c := ws.NewTestClientWithTokenHash(hub, user, hash, 0, send) + + if got := c.GetTokenHash(); got != hash { + t.Errorf("GetTokenHash() = %q, want %q", got, hash) + } +} + +// TestSessionExpiry_ValidSessionAllowsMessages verifies that when a client has a +// valid (non-expired) session stored in the DB, the periodic expiry check does +// NOT close the connection. +func TestSessionExpiry_ValidSessionAllowsMessages(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "expiry-user2") + chID := seedTestChannel(t, database, "expiry-chan2") + + // Create a real session with a far-future expiry. + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + hash := auth.HashToken(token) + if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + send := make(chan []byte, 64) + c := ws.NewTestClientWithTokenHash(hub, user, hash, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Trigger the expiry check by sending enough messages to cross the check threshold. + for i := range ws.SessionCheckInterval + 1 { + hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i))) + } + time.Sleep(100 * time.Millisecond) + + // Client should still be registered. + if hub.ClientCount() == 0 { + t.Error("client was removed despite having a valid session") + } +} + +// TestSessionExpiry_ExpiredSessionClosesConnection verifies that after +// SessionCheckInterval messages, a client whose session has been deleted from +// the DB gets kicked. +func TestSessionExpiry_ExpiredSessionClosesConnection(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "expiry-user3") + + // Create a session then immediately delete it to simulate expiry. + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + hash := auth.HashToken(token) + if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + // Delete the session to simulate it being expired/revoked. + if err := database.DeleteSession(hash); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + + send := make(chan []byte, 64) + c := ws.NewTestClientWithTokenHash(hub, user, hash, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Trigger the expiry check. + for range ws.SessionCheckInterval + 1 { + // Use a harmless but parseable message to accumulate message count. + hub.HandleMessageForTest(c, []byte(`{"type":"presence_update","payload":{"status":"online"}}`)) + } + time.Sleep(100 * time.Millisecond) + + // The client's send channel should be closed (connection severed). + // We verify this by checking that the send channel has been closed, + // which manifests as a zero-value receive without blocking. + select { + case _, open := <-send: + _ = open + // closed channel or a message — either way connection was acted on. + default: + // Send channel still open and empty — check hub registration instead. + } + + // The most reliable assertion: hub should have unregistered the client. + time.Sleep(50 * time.Millisecond) + if hub.ClientCount() != 0 { + t.Error("expired-session client was not removed from the hub") + } +} + +// TestSessionExpiry_MissingTokenHashSkipsCheck verifies that a client created +// without a token hash (legacy / test-only path) does not crash during the +// periodic check. +func TestSessionExpiry_MissingTokenHashSkipsCheck(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "expiry-user4") + chID := seedTestChannel(t, database, "expiry-chan4") + + send := make(chan []byte, 64) + // No token hash — simulates old-style test clients. + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send past the threshold; should not panic or remove the client. + for i := range ws.SessionCheckInterval + 1 { + hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i))) + } + time.Sleep(100 * time.Millisecond) + + if hub.ClientCount() == 0 { + t.Error("client without token hash was incorrectly removed") + } +} + +// ─── 2.8: Slow mode enforcement ─────────────────────────────────────────────── + +// TestSlowMode_ZeroSlowMode_AllowsRapidMessages verifies that when slow_mode=0, +// messages are not throttled by slow mode (only the normal rate limiter applies). +func TestSlowMode_ZeroSlowMode_AllowsRapidMessages(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "slowmode-user1") + chID := seedTestChannel(t, database, "no-slowmode-chan") // slow_mode defaults to 0 + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send 3 messages in quick succession. + for i := range 3 { + hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("rapid %d", i))) + } + time.Sleep(50 * time.Millisecond) + + // Drain all messages. + msgs := drainChan(send) + for _, m := range msgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "error" { + if payload, ok := env["payload"].(map[string]any); ok { + if payload["code"] == "SLOW_MODE" { + t.Error("got unexpected SLOW_MODE error when slow_mode=0") + } + } + } + } +} + +// TestSlowMode_EnforcedAfterFirstMessage verifies that when slow_mode > 0, the +// second message from the same user within the slow_mode window is rejected. +func TestSlowMode_EnforcedAfterFirstMessage(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedMemberUser(t, database, "slowmode-user2") + chID := seedChannelWithSlowMode(t, database, "slow-chan", 30) // 30s slow mode + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // First message should succeed. + hub.HandleMessageForTest(c, chatSendMsg(chID, "first message")) + time.Sleep(30 * time.Millisecond) + drainChan(send) // clear the ack + + // Second message within slow_mode window should be rejected. + hub.HandleMessageForTest(c, chatSendMsg(chID, "second message too soon")) + time.Sleep(30 * time.Millisecond) + + code := receiveErrorCode(send, 200*time.Millisecond) + if code != "SLOW_MODE" { + t.Errorf("expected SLOW_MODE error on second message, got %q", code) + } +} + +// TestSlowMode_DifferentUsersNotBlocked verifies that the slow mode key is +// per-user-per-channel: user B sending after user A is not blocked. +func TestSlowMode_DifferentUsersNotBlocked(t *testing.T) { + hub, database := newHandlerHub(t) + chID := seedChannelWithSlowMode(t, database, "slow-multi-chan", 30) + + userA := seedMemberUser(t, database, "slowmode-userA") + userB := seedMemberUser(t, database, "slowmode-userB") + + sendA := make(chan []byte, 32) + sendB := make(chan []byte, 32) + cA := ws.NewTestClientWithUser(hub, userA, chID, sendA) + cB := ws.NewTestClientWithUser(hub, userB, chID, sendB) + hub.Register(cA) + hub.Register(cB) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(cA, chatSendMsg(chID, "from A")) + time.Sleep(20 * time.Millisecond) + + // B sends after A — B's slow mode window is independent. + hub.HandleMessageForTest(cB, chatSendMsg(chID, "from B")) + time.Sleep(50 * time.Millisecond) + + // B should NOT receive a SLOW_MODE error. + msgs := drainChan(sendB) + for _, m := range msgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "error" { + if payload, ok := env["payload"].(map[string]any); ok { + if payload["code"] == "SLOW_MODE" { + t.Error("user B was incorrectly slow-mode throttled by user A's window") + } + } + } + } +} + +// TestSlowMode_ModeratorBypassesSlowMode verifies that a user with MANAGE_MESSAGES +// permission can send multiple messages without hitting slow mode. +func TestSlowMode_ModeratorBypassesSlowMode(t *testing.T) { + hub, database := newHandlerHub(t) + chID := seedChannelWithSlowMode(t, database, "slow-mod-chan", 30) + + mod := seedModUser(t, database, "slowmode-mod") + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, mod, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send two messages in rapid succession — mod should not be blocked. + hub.HandleMessageForTest(c, chatSendMsg(chID, "mod msg 1")) + time.Sleep(20 * time.Millisecond) + drainChan(send) + + hub.HandleMessageForTest(c, chatSendMsg(chID, "mod msg 2")) + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "error" { + if payload, ok := env["payload"].(map[string]any); ok { + if payload["code"] == "SLOW_MODE" { + t.Error("moderator was incorrectly blocked by slow mode") + } + } + } + } +} + +// TestSlowMode_DifferentChannels_IndependentWindows verifies that slow mode is +// scoped per-channel: a user hitting slow mode in channel A is not affected in +// channel B. +func TestSlowMode_DifferentChannels_IndependentWindows(t *testing.T) { + hub, database := newHandlerHub(t) + + chA := seedChannelWithSlowMode(t, database, "slow-chan-A", 30) + chB := seedChannelWithSlowMode(t, database, "slow-chan-B", 30) + + user := seedMemberUser(t, database, "slowmode-multichan") + + sendA := make(chan []byte, 32) + sendB := make(chan []byte, 32) + + // Use two separate clients in each channel to simulate the user being in both. + cA := ws.NewTestClientWithUser(hub, user, chA, sendA) + // For channel B we need a separate client — re-use same userID is fine for + // this test since we are calling HandleMessageForTest directly. + cB := ws.NewTestClientWithUser(hub, user, chB, sendB) + + hub.Register(cA) + time.Sleep(10 * time.Millisecond) + + // cA sends in channel A — triggers slow mode for A. + hub.HandleMessageForTest(cA, chatSendMsg(chA, "msg in A")) + time.Sleep(20 * time.Millisecond) + drainChan(sendA) + + // Now send in channel B via cB — should NOT be affected. + hub.Register(cB) + time.Sleep(10 * time.Millisecond) + + hub.HandleMessageForTest(cB, chatSendMsg(chB, "msg in B")) + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(sendB) + for _, m := range msgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "error" { + if payload, ok := env["payload"].(map[string]any); ok { + if payload["code"] == "SLOW_MODE" { + t.Error("slow mode in channel A incorrectly blocked channel B") + } + } + } + } +} + +// ─── Attachment permission ordering ─────────────────────────────────────────── + +// chatSendMsgWithAttachments constructs a raw chat_send envelope with attachment IDs. +func chatSendMsgWithAttachments(channelID int64, content string, attachmentIDs []string) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": channelID, + "content": content, + "attachments": attachmentIDs, + }, + }) + return raw +} + +// denyAttachOnChannel inserts a channel_override that denies ATTACH_FILES. +func denyAttachOnChannel(t *testing.T, database *db.DB, channelID, roleID int64) { + t.Helper() + _, err := database.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, + channelID, roleID, permissions.AttachFiles, + ) + if err != nil { + t.Fatalf("denyAttachOnChannel: %v", err) + } +} + +// TestChatSend_AttachmentsDeniedNoMessageCreated verifies that when ATTACH_FILES +// is denied, the message is NOT persisted (permission check before CreateMessage). +func TestChatSend_AttachmentsDeniedNoMessageCreated(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedMemberUser(t, database, "attach-denied") + chID := seedTestChannel(t, database, "attach-chan") + + // Deny ATTACH_FILES for Member role on this channel. + denyAttachOnChannel(t, database, chID, permissions.MemberRoleID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send a message with attachments — should be rejected before persisting. + hub.HandleMessageForTest(c, chatSendMsgWithAttachments(chID, "has attachment", []string{"fake-attach-id"})) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN for denied ATTACH_FILES, got %q", code) + } + + // Verify no message was persisted in the database. + var count int + err := database.QueryRow("SELECT COUNT(*) FROM messages WHERE channel_id = ?", chID).Scan(&count) + if err != nil { + t.Fatalf("count query: %v", err) + } + if count != 0 { + t.Errorf("expected 0 messages in DB (permission denied before persist), got %d", count) + } +} + +// TestSlowMode_ErrorMessageContainsSlowModeDuration verifies the error payload +// describes the slow mode duration. +func TestSlowMode_ErrorMessageContainsSlowModeDuration(t *testing.T) { + hub, database := newHandlerHub(t) + const slowSecs = 15 + chID := seedChannelWithSlowMode(t, database, "slow-msg-chan", slowSecs) + + user := seedMemberUser(t, database, "slowmode-errmsg") + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // First message to prime the window. + hub.HandleMessageForTest(c, chatSendMsg(chID, "first")) + time.Sleep(20 * time.Millisecond) + drainChan(send) + + // Second message — should receive SLOW_MODE error with duration in message. + hub.HandleMessageForTest(c, chatSendMsg(chID, "too soon")) + time.Sleep(50 * time.Millisecond) + + timer := time.NewTimer(300 * time.Millisecond) + defer timer.Stop() + for { + select { + case msg := <-send: + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + if env["type"] != "error" { + continue + } + payload, ok := env["payload"].(map[string]any) + if !ok { + continue + } + if payload["code"] != "SLOW_MODE" { + continue + } + detail, _ := payload["message"].(string) + expected := fmt.Sprintf("%ds slow mode", slowSecs) + if detail == "" { + t.Error("SLOW_MODE error had empty message") + } else if len(detail) > 0 { + // Verify the duration is mentioned somewhere in the message. + found := false + for i := 0; i <= len(detail)-len(expected); i++ { + if detail[i:i+len(expected)] == expected { + found = true + break + } + } + if !found { + t.Errorf("SLOW_MODE message %q does not contain %q", detail, expected) + } + } + return + case <-timer.C: + t.Error("did not receive SLOW_MODE error within timeout") + return + } + } +} + +// ─── handleChatSend additional coverage ────────────────────────────────────── + +// TestChatSend_InvalidPayload_ReturnsBadRequest verifies that a non-object +// payload to chat_send returns BAD_REQUEST. +func TestChatSend_InvalidPayload_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "send-inv1") + chID := seedTestChannel(t, database, "send-inv-chan1") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for invalid payload, got %q", code) + } +} + +// TestChatSend_InvalidChannelID_ReturnsBadRequest verifies that channel_id=0 +// returns BAD_REQUEST. +func TestChatSend_InvalidChannelID_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "send-inv2") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": 0, + "content": "hello", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for channel_id=0, got %q", code) + } +} + +// TestChatSend_ChannelNotFound_ReturnsNotFound verifies that sending to a +// non-existent channel returns NOT_FOUND. +func TestChatSend_ChannelNotFound_ReturnsNotFound(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "send-inv3") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 99999, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, chatSendMsg(99999, "hello")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "NOT_FOUND" { + t.Errorf("expected NOT_FOUND for non-existent channel, got %q", code) + } +} + +// TestChatSend_EmptyContent_ReturnsBadRequest verifies that content that +// sanitizes to empty is rejected. +func TestChatSend_EmptyContent_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "send-empty1") + chID := seedTestChannel(t, database, "send-empty-chan") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send message with empty content. + hub.HandleMessageForTest(c, chatSendMsg(chID, "")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for empty content, got %q", code) + } +} + +// TestChatSend_TooLongContent_ReturnsBadRequest verifies that content exceeding +// 4000 Unicode code points is rejected. +func TestChatSend_TooLongContent_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "send-long1") + chID := seedTestChannel(t, database, "send-long-chan") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Build a 4001-rune string to exceed the limit. + longContent := make([]rune, 4001) + for i := range longContent { + longContent[i] = 'a' + } + hub.HandleMessageForTest(c, chatSendMsg(chID, string(longContent))) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for too-long content, got %q", code) + } +} + +// TestChatSend_SuccessWithReplyTo verifies that a message with reply_to is +// accepted and the broadcast includes it. +func TestChatSend_SuccessWithReplyTo(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "send-reply1") + chID := seedTestChannel(t, database, "send-reply-chan") + parentMsgID, err := database.CreateMessage(chID, user.ID, "parent message", nil) + if err != nil { + t.Fatalf("CreateMessage parent: %v", err) + } + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "reply message", + "reply_to": parentMsgID, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + // Should get a chat_send_ok ack. + timer := time.NewTimer(300 * time.Millisecond) + defer timer.Stop() + for { + select { + case msg := <-send: + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + if env["type"] == "chat_send_ok" { + return // success + } + case <-timer.C: + t.Error("expected chat_send_ok for reply message, got none") + return + } + } +} + +// TestChatSend_NilUserClientSendsMessage verifies that a client without a user +// object attached still sends a message (uses empty username/nil avatar). +func TestChatSend_NilUserClientSendsMessage(t *testing.T) { + hub, database := newHandlerHub(t) + // Create a client with just an owner userID but no user object, + // so c.user == nil. The permission check will fail if no user is set. + // Use an owner-level user so permissions pass. + owner := seedOwnerUser(t, database, "send-niluser1") + chID := seedTestChannel(t, database, "send-niluser-chan") + + send := make(chan []byte, 32) + // Use NewTestClientWithUser so permissions work (user record is attached). + c := ws.NewTestClientWithUser(hub, owner, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, chatSendMsg(chID, "hello")) + time.Sleep(50 * time.Millisecond) + + // Expect a chat_send_ok. + timer := time.NewTimer(300 * time.Millisecond) + defer timer.Stop() + for { + select { + case msg := <-send: + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + if env["type"] == "chat_send_ok" { + return + } + case <-timer.C: + t.Error("expected chat_send_ok for normal message, got none") + return + } + } +} + +// TestPresence_RateLimit_ReturnsError verifies that sending more than +// presenceRateLimit updates within presenceWindow triggers a rate-limit error. +func TestPresence_RateLimit_ReturnsError(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "presence-rl1") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // First presence update — should succeed. + hub.HandleMessageForTest(c, presenceUpdateMsg("online")) + time.Sleep(20 * time.Millisecond) + drainChan(send) + + // Second presence update immediately — should be rate-limited. + hub.HandleMessageForTest(c, presenceUpdateMsg("idle")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "RATE_LIMITED" { + t.Errorf("expected RATE_LIMITED for excess presence updates, got %q", code) + } +} + +// ─── helpers for the new handler tests ──────────────────────────────────────── + +// seedMessage inserts a message into the given channel for the given user +// and returns its ID. +func seedMessage(t *testing.T, database *db.DB, channelID, userID int64, content string) int64 { + t.Helper() + id, err := database.CreateMessage(channelID, userID, content, nil) + if err != nil { + t.Fatalf("seedMessage CreateMessage: %v", err) + } + return id +} + +// chatEditMsg constructs a raw chat_edit WebSocket envelope. +func chatEditMsg(messageID int64, content string) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "chat_edit", + "payload": map[string]any{ + "message_id": messageID, + "content": content, + }, + }) + return raw +} + +// chatDeleteMsg constructs a raw chat_delete WebSocket envelope. +func chatDeleteMsg(messageID int64) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "chat_delete", + "payload": map[string]any{ + "message_id": messageID, + }, + }) + return raw +} + +// reactionMsg constructs a raw reaction_add or reaction_remove envelope. +func reactionMsg(msgType string, messageID int64, emoji string) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": msgType, + "payload": map[string]any{ + "message_id": messageID, + "emoji": emoji, + }, + }) + return raw +} + +// typingMsg constructs a raw typing_start envelope. +func typingStartMsg(channelID int64) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "typing_start", + "payload": map[string]any{ + "channel_id": channelID, + }, + }) + return raw +} + +// presenceMsg constructs a raw presence_update envelope. +func presenceUpdateMsg(status string) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "presence_update", + "payload": map[string]any{ + "status": status, + }, + }) + return raw +} + +// receiveMsgOfType drains ch until a message with the given type field is found, +// or the deadline elapses. Returns the parsed payload or nil on timeout. +func receiveMsgOfType(ch <-chan []byte, msgType string, deadline time.Duration) map[string]any { + timer := time.NewTimer(deadline) + defer timer.Stop() + for { + select { + case msg := <-ch: + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + if env["type"] == msgType { + payload, _ := env["payload"].(map[string]any) + return payload + } + case <-timer.C: + return nil + } + } +} + +// ─── handleChatEdit ─────────────────────────────────────────────────────────── + +// TestChatEdit_ValidEdit_BroadcastsChatEdited verifies that editing an owned +// message succeeds and broadcasts a chat_edited event to channel members. +func TestChatEdit_ValidEdit_BroadcastsChatEdited(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "edit-owner1") + chID := seedTestChannel(t, database, "edit-chan1") + msgID := seedMessage(t, database, chID, user.ID, "original content") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, chatEditMsg(msgID, "edited content")) + time.Sleep(50 * time.Millisecond) + + payload := receiveMsgOfType(send, "chat_edited", 300*time.Millisecond) + if payload == nil { + t.Fatal("expected chat_edited broadcast, got none") + } + // Verify the message ID is included. + gotID, _ := payload["message_id"].(float64) + if int64(gotID) != msgID { + t.Errorf("chat_edited message_id = %v, want %d", gotID, msgID) + } +} + +// TestChatEdit_InvalidPayload_ReturnsBadRequest verifies that malformed JSON +// in the payload returns a BAD_REQUEST error. +func TestChatEdit_InvalidPayload_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "edit-owner2") + chID := seedTestChannel(t, database, "edit-chan2") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send a chat_edit envelope with an unparseable payload. + raw, _ := json.Marshal(map[string]any{ + "type": "chat_edit", + "payload": "not-an-object", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for invalid payload, got %q", code) + } +} + +// TestChatEdit_EmptyContent_ReturnsBadRequest verifies that an empty (or +// HTML-stripped-to-empty) content field is rejected. +func TestChatEdit_EmptyContent_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "edit-owner3") + chID := seedTestChannel(t, database, "edit-chan3") + msgID := seedMessage(t, database, chID, user.ID, "original") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, chatEditMsg(msgID, "")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for empty content, got %q", code) + } +} + +// TestChatEdit_NotOwner_ReturnsForbidden verifies that editing another user's +// message is rejected with a FORBIDDEN error. +func TestChatEdit_NotOwner_ReturnsForbidden(t *testing.T) { + hub, database := newHandlerHub(t) + author := seedOwnerUser(t, database, "edit-author4") + editor := seedMemberUser(t, database, "edit-editor4") + chID := seedTestChannel(t, database, "edit-chan4") + msgID := seedMessage(t, database, chID, author.ID, "author's message") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, editor, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, chatEditMsg(msgID, "stolen edit")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN for editing another's message, got %q", code) + } +} + +// TestChatEdit_InvalidMessageID_ReturnsBadRequest verifies that a non-positive +// message_id is rejected immediately. +func TestChatEdit_InvalidMessageID_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "edit-owner5") + chID := seedTestChannel(t, database, "edit-chan5") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_edit", + "payload": map[string]any{ + "message_id": 0, + "content": "hello", + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for message_id=0, got %q", code) + } +} + +// ─── handleChatDelete ───────────────────────────────────────────────────────── + +// TestChatDelete_OwnerDeletesOwn_BroadcastsChatDeleted verifies that a user +// can delete their own message and a chat_deleted broadcast is sent. +func TestChatDelete_OwnerDeletesOwn_BroadcastsChatDeleted(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "del-owner1") + chID := seedTestChannel(t, database, "del-chan1") + msgID := seedMessage(t, database, chID, user.ID, "to be deleted") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, chatDeleteMsg(msgID)) + time.Sleep(50 * time.Millisecond) + + payload := receiveMsgOfType(send, "chat_deleted", 300*time.Millisecond) + if payload == nil { + t.Fatal("expected chat_deleted broadcast, got none") + } + gotID, _ := payload["message_id"].(float64) + if int64(gotID) != msgID { + t.Errorf("chat_deleted message_id = %v, want %d", gotID, msgID) + } +} + +// TestChatDelete_ModeratorDeletesOthers_BroadcastsChatDeleted verifies that a +// moderator (who has MANAGE_MESSAGES) can delete any message. +func TestChatDelete_ModeratorDeletesOthers_BroadcastsChatDeleted(t *testing.T) { + hub, database := newHandlerHub(t) + author := seedMemberUser(t, database, "del-author2") + mod := seedModUser(t, database, "del-mod2") + chID := seedTestChannel(t, database, "del-chan2") + msgID := seedMessage(t, database, chID, author.ID, "member's message") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, mod, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, chatDeleteMsg(msgID)) + time.Sleep(50 * time.Millisecond) + + payload := receiveMsgOfType(send, "chat_deleted", 300*time.Millisecond) + if payload == nil { + t.Fatal("expected chat_deleted broadcast after mod delete, got none") + } +} + +// TestChatDelete_NonOwnerWithoutManageMessages_ReturnsForbidden verifies that a +// regular member cannot delete another user's message. +func TestChatDelete_NonOwnerWithoutManageMessages_ReturnsForbidden(t *testing.T) { + hub, database := newHandlerHub(t) + author := seedOwnerUser(t, database, "del-author3") + other := seedMemberUser(t, database, "del-other3") + chID := seedTestChannel(t, database, "del-chan3") + msgID := seedMessage(t, database, chID, author.ID, "owner's message") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, other, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, chatDeleteMsg(msgID)) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN for non-owner delete, got %q", code) + } +} + +// TestChatDelete_InvalidPayload_ReturnsBadRequest verifies that a malformed +// payload returns BAD_REQUEST. +func TestChatDelete_InvalidPayload_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "del-owner4") + chID := seedTestChannel(t, database, "del-chan4") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_delete", + "payload": "bad", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for invalid payload, got %q", code) + } +} + +// TestChatDelete_NonExistentMessage_ReturnsNotFound verifies that attempting +// to delete a message that does not exist returns NOT_FOUND. +func TestChatDelete_NonExistentMessage_ReturnsNotFound(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "del-owner5") + chID := seedTestChannel(t, database, "del-chan5") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, chatDeleteMsg(99999)) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "NOT_FOUND" { + t.Errorf("expected NOT_FOUND for non-existent message, got %q", code) + } +} + +// TestChatDelete_InvalidMessageID_ReturnsBadRequest verifies that message_id=0 +// is rejected before any DB lookup. +func TestChatDelete_InvalidMessageID_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "del-owner6") + chID := seedTestChannel(t, database, "del-chan6") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_delete", + "payload": map[string]any{ + "message_id": 0, + }, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for message_id=0, got %q", code) + } +} + +// TestChatEdit_RateLimit_ReturnsError verifies that exceeding the chat edit +// rate limit returns a RATE_LIMITED error. +func TestChatEdit_RateLimit_ReturnsError(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "edit-rl1") + chID := seedTestChannel(t, database, "edit-rl-chan1") + msgID := seedMessage(t, database, chID, user.ID, "original") + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Exhaust the rate limit (chatRateLimit = 10 per second). + for i := 0; i < 11; i++ { + hub.HandleMessageForTest(c, chatEditMsg(msgID, fmt.Sprintf("edit-%d", i))) + } + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "RATE_LIMITED" { + t.Errorf("expected RATE_LIMITED for excess chat edits, got %q", code) + } +} + +// TestChatDelete_RateLimit_ReturnsError verifies that exceeding the chat delete +// rate limit returns a RATE_LIMITED error. +func TestChatDelete_RateLimit_ReturnsError(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "del-rl1") + chID := seedTestChannel(t, database, "del-rl-chan1") + + // Seed enough messages to attempt deleting. + msgIDs := make([]int64, 11) + for i := range msgIDs { + msgIDs[i] = seedMessage(t, database, chID, user.ID, fmt.Sprintf("msg-%d", i)) + } + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Exhaust the rate limit (chatRateLimit = 10 per second). + for _, id := range msgIDs { + hub.HandleMessageForTest(c, chatDeleteMsg(id)) + } + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "RATE_LIMITED" { + t.Errorf("expected RATE_LIMITED for excess chat deletes, got %q", code) + } +} + +// ─── handleReaction ─────────────────────────────────────────────────────────── + +// TestReaction_AddReaction_BroadcastsReactionUpdate verifies that adding a +// valid reaction broadcasts a reaction_update event. +func TestReaction_AddReaction_BroadcastsReactionUpdate(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "react-owner1") + chID := seedTestChannel(t, database, "react-chan1") + msgID := seedMessage(t, database, chID, user.ID, "react to me") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "👍")) + time.Sleep(50 * time.Millisecond) + + payload := receiveMsgOfType(send, "reaction_update", 300*time.Millisecond) + if payload == nil { + t.Fatal("expected reaction_update broadcast, got none") + } + if payload["action"] != "add" { + t.Errorf("expected action=add, got %v", payload["action"]) + } +} + +// TestReaction_RemoveReaction_BroadcastsReactionUpdate verifies that removing +// a reaction broadcasts a reaction_update event with action=remove. +func TestReaction_RemoveReaction_BroadcastsReactionUpdate(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "react-owner2") + chID := seedTestChannel(t, database, "react-chan2") + msgID := seedMessage(t, database, chID, user.ID, "react to me 2") + + // Pre-seed the reaction so removal has something to remove. + if err := database.AddReaction(msgID, user.ID, "❤️"); err != nil { + t.Fatalf("seedReaction: %v", err) + } + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, reactionMsg("reaction_remove", msgID, "❤️")) + time.Sleep(50 * time.Millisecond) + + payload := receiveMsgOfType(send, "reaction_update", 300*time.Millisecond) + if payload == nil { + t.Fatal("expected reaction_update broadcast for remove, got none") + } + if payload["action"] != "remove" { + t.Errorf("expected action=remove, got %v", payload["action"]) + } +} + +// TestReaction_InvalidPayload_ReturnsBadRequest verifies that a malformed +// reaction payload returns BAD_REQUEST. +func TestReaction_InvalidPayload_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "react-owner3") + chID := seedTestChannel(t, database, "react-chan3") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "reaction_add", + "payload": "bad", + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for invalid payload, got %q", code) + } +} + +// TestReaction_EmptyEmoji_ReturnsBadRequest verifies that an empty emoji string +// is rejected. +func TestReaction_EmptyEmoji_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "react-owner4") + chID := seedTestChannel(t, database, "react-chan4") + msgID := seedMessage(t, database, chID, user.ID, "msg4") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for empty emoji, got %q", code) + } +} + +// TestReaction_TooLongEmoji_ReturnsBadRequest verifies that an emoji string +// exceeding 32 bytes is rejected. +func TestReaction_TooLongEmoji_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "react-owner5") + chID := seedTestChannel(t, database, "react-chan5") + msgID := seedMessage(t, database, chID, user.ID, "msg5") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // 33-character emoji string — exceeds the 32-byte limit. + longEmoji := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 33 chars + hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, longEmoji)) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for too-long emoji, got %q", code) + } +} + +// TestReaction_ControlCharInEmoji_ReturnsBadRequest verifies that an emoji +// containing a control character (U+0000–U+001F) is rejected. +func TestReaction_ControlCharInEmoji_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "react-owner6") + chID := seedTestChannel(t, database, "react-chan6") + msgID := seedMessage(t, database, chID, user.ID, "msg6") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "a\x01b")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for control char in emoji, got %q", code) + } +} + +// TestReaction_NonExistentMessage_ReturnsBadRequest verifies that reacting to +// a non-existent message returns a sanitized BAD_REQUEST (prevents IDOR). +func TestReaction_NonExistentMessage_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "react-owner7") + chID := seedTestChannel(t, database, "react-chan7") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, reactionMsg("reaction_add", 99999, "👍")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for non-existent message (IDOR sanitize), got %q", code) + } +} + +// TestReaction_DuplicateAdd_ReturnsCONFLICT verifies that adding the same +// emoji twice returns a CONFLICT error (DB unique constraint). +func TestReaction_DuplicateAdd_ReturnsConflict(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "react-owner8") + chID := seedTestChannel(t, database, "react-chan8") + msgID := seedMessage(t, database, chID, user.ID, "msg8") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // First add — should succeed. + hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "🔥")) + time.Sleep(30 * time.Millisecond) + drainChan(send) // clear the first broadcast + + // Second add of the same emoji — should fail with CONFLICT. + hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "🔥")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "CONFLICT" { + t.Errorf("expected CONFLICT for duplicate reaction, got %q", code) + } +} + +// TestReaction_InvalidMessageID_ReturnsBadRequest verifies that message_id=0 +// is rejected before any DB call. +func TestReaction_InvalidMessageID_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "react-owner9") + chID := seedTestChannel(t, database, "react-chan9") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, reactionMsg("reaction_add", 0, "👍")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for message_id=0, got %q", code) + } +} + +// ─── handleTyping ───────────────────────────────────────────────────────────── + +// waitForClients blocks until the hub has at least n clients registered, or +// the deadline expires. Returns true if the count was reached. +func waitForClients(hub *ws.Hub, n int, deadline time.Duration) bool { + deadlineT := time.Now().Add(deadline) + for time.Now().Before(deadlineT) { + if hub.ClientCount() >= n { + return true + } + time.Sleep(5 * time.Millisecond) + } + return hub.ClientCount() >= n +} + +// TestTyping_ValidTyping_BroadcastsToOthers verifies that a typing_start event +// is delivered to other channel members but NOT to the sender. +func TestTyping_ValidTyping_BroadcastsToOthers(t *testing.T) { + hub, database := newHandlerHub(t) + chID := seedTestChannel(t, database, "typing-chan1") + + sender := seedOwnerUser(t, database, "typing-sender1") + watcher := seedMemberUser(t, database, "typing-watcher1") + + sendSender := make(chan []byte, 16) + sendWatcher := make(chan []byte, 16) + + cSender := ws.NewTestClientWithUser(hub, sender, chID, sendSender) + cWatcher := ws.NewTestClientWithUser(hub, watcher, chID, sendWatcher) + + hub.Register(cSender) + hub.Register(cWatcher) + // Wait until both clients are actually in the hub's client map. + if !waitForClients(hub, 2, 500*time.Millisecond) { + t.Fatalf("hub did not register both clients within timeout (count=%d)", hub.ClientCount()) + } + hub.HandleMessageForTest(cSender, typingStartMsg(chID)) + time.Sleep(50 * time.Millisecond) + + // Watcher should receive a "typing" broadcast (the outbound event type from + // buildTypingMsg is "typing", distinct from the inbound "typing_start"). + watcherMsgs := drainChan(sendWatcher) + foundTyping := false + for _, m := range watcherMsgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "typing" { + foundTyping = true + break + } + } + if !foundTyping { + t.Error("watcher did not receive typing broadcast") + } + + // Sender should NOT receive their own typing event. + senderMsgs := drainChan(sendSender) + for _, m := range senderMsgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "typing" { + t.Error("sender incorrectly received their own typing event") + } + } +} + +// TestTyping_InvalidChannelID_ReturnsBadRequest verifies that a typing_start +// with channel_id=0 returns a BAD_REQUEST error. +func TestTyping_InvalidChannelID_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "typing-owner2") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, typingStartMsg(0)) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for channel_id=0, got %q", code) + } +} + +// TestTyping_RateLimited_SilentlyDropped verifies that a second typing_start +// within the rate-limit window is silently dropped (no error sent to client). +func TestTyping_RateLimited_SilentlyDropped(t *testing.T) { + hub, database := newHandlerHub(t) + chID := seedTestChannel(t, database, "typing-chan3") + + sender := seedOwnerUser(t, database, "typing-sender3") + watcher := seedMemberUser(t, database, "typing-watcher3") + + sendSender := make(chan []byte, 16) + sendWatcher := make(chan []byte, 32) + + cSender := ws.NewTestClientWithUser(hub, sender, chID, sendSender) + cWatcher := ws.NewTestClientWithUser(hub, watcher, chID, sendWatcher) + + hub.Register(cSender) + hub.Register(cWatcher) + if !waitForClients(hub, 2, 500*time.Millisecond) { + t.Fatalf("hub did not register both clients within timeout") + } + + // First typing event — should go through. + hub.HandleMessageForTest(cSender, typingStartMsg(chID)) + time.Sleep(30 * time.Millisecond) + drainChan(sendWatcher) + + // Second typing event immediately — should be silently dropped. + hub.HandleMessageForTest(cSender, typingStartMsg(chID)) + time.Sleep(50 * time.Millisecond) + + // Sender should NOT receive an error (silently dropped). + senderMsgs := drainChan(sendSender) + for _, m := range senderMsgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "error" { + t.Errorf("expected silent drop for rate-limited typing, but got error: %s", m) + } + } + + // Watcher should NOT receive a second typing event (broadcast type is "typing"). + watcherMsgs := drainChan(sendWatcher) + typingCount := 0 + for _, m := range watcherMsgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "typing" { + typingCount++ + } + } + if typingCount > 0 { + t.Errorf("rate-limited typing event was not dropped; watcher received %d extra typing", typingCount) + } +} + +// ─── broadcastExclude ───────────────────────────────────────────────────────── + +// TestBroadcastExclude_SendsToOthersNotSelf verifies that broadcastExclude +// delivers to all channel members except the excluded user. +// This is exercised indirectly via typing_start (which calls broadcastExclude). +func TestBroadcastExclude_SendsToOthersNotSelf(t *testing.T) { + hub, database := newHandlerHub(t) + chID := seedTestChannel(t, database, "excl-chan1") + + u1 := seedOwnerUser(t, database, "excl-user1") + u2 := seedMemberUser(t, database, "excl-user2") + u3 := seedMemberUser(t, database, "excl-user3") + + send1 := make(chan []byte, 16) + send2 := make(chan []byte, 16) + send3 := make(chan []byte, 16) + + c1 := ws.NewTestClientWithUser(hub, u1, chID, send1) + c2 := ws.NewTestClientWithUser(hub, u2, chID, send2) + c3 := ws.NewTestClientWithUser(hub, u3, chID, send3) + + hub.Register(c1) + hub.Register(c2) + hub.Register(c3) + // Wait until all three are registered in the hub's client map. + if !waitForClients(hub, 3, 500*time.Millisecond) { + t.Fatalf("hub did not register all 3 clients within timeout (count=%d)", hub.ClientCount()) + } + + // u1 sends a typing event — should reach u2 and u3 but NOT u1. + hub.HandleMessageForTest(c1, typingStartMsg(chID)) + time.Sleep(50 * time.Millisecond) + + // u2 and u3 must receive the "typing" broadcast. + for i, sendCh := range []<-chan []byte{send2, send3} { + msgs := drainChan(sendCh) + found := false + for _, m := range msgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "typing" { + found = true + break + } + } + if !found { + t.Errorf("user%d (non-sender) did not receive typing broadcast", i+2) + } + } + + // u1 (sender) must NOT receive it. + msgs1 := drainChan(send1) + for _, m := range msgs1 { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "typing" { + t.Error("sender (excluded user) incorrectly received their own typing event") + } + } +} + +// TestBroadcastExclude_DifferentChannelNotReceived verifies that broadcastExclude +// does NOT deliver to clients in a different channel. +func TestBroadcastExclude_DifferentChannelNotReceived(t *testing.T) { + hub, database := newHandlerHub(t) + chA := seedTestChannel(t, database, "excl-chanA") + chB := seedTestChannel(t, database, "excl-chanB") + + uA := seedOwnerUser(t, database, "excl-userA") + uB := seedMemberUser(t, database, "excl-userB") + + sendA := make(chan []byte, 16) + sendB := make(chan []byte, 16) + + cA := ws.NewTestClientWithUser(hub, uA, chA, sendA) + cB := ws.NewTestClientWithUser(hub, uB, chB, sendB) + + hub.Register(cA) + hub.Register(cB) + if !waitForClients(hub, 2, 500*time.Millisecond) { + t.Fatalf("hub did not register both clients within timeout") + } + + // uA types in channel A — uB in channel B must NOT receive it. + hub.HandleMessageForTest(cA, typingStartMsg(chA)) + time.Sleep(50 * time.Millisecond) + + msgsB := drainChan(sendB) + for _, m := range msgsB { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "typing" { + t.Error("user in different channel incorrectly received typing broadcast via broadcastExclude") + } + } +} + +// ─── handlePresence (invalid status path) ───────────────────────────────────── + +// TestPresence_InvalidStatus_ReturnsBadRequest verifies that a status value +// not in the allowed set (online|idle|dnd|offline) is rejected. +func TestPresence_InvalidStatus_ReturnsBadRequest(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "presence-bad1") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, presenceUpdateMsg("invisible")) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for invalid status, got %q", code) + } +} + +// TestPresence_ValidStatus_Broadcasts verifies that valid statuses are accepted +// and broadcast to all connected clients. +func TestPresence_ValidStatus_Broadcasts(t *testing.T) { + validStatuses := []string{"online", "idle", "dnd", "offline"} + for _, status := range validStatuses { + status := status + t.Run(status, func(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "presence-valid-"+status) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, presenceUpdateMsg(status)) + time.Sleep(50 * time.Millisecond) + + // Must NOT receive a BAD_REQUEST error. + msgs := drainChan(send) + for _, m := range msgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "error" { + if payload, ok := env["payload"].(map[string]any); ok { + if payload["code"] == "BAD_REQUEST" { + t.Errorf("valid status %q was incorrectly rejected", status) + } + } + } + } + }) + } +} + +// ─── handleChannelFocus (additional edge cases) ─────────────────────────────── + +// TestChannelFocus_ValidFocus_UpdatesChannelID verifies that a successful +// channel_focus updates the client's tracked channel so subsequent broadcasts +// to that channel reach the client. +func TestChannelFocus_ValidFocus_UpdatesChannelID(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "focus-update1") + chID := seedTestChannel(t, database, "focus-update-chan") + + send := make(chan []byte, 32) + // Start client on channel 0. + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Focus on chID. + raw, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{"channel_id": chID}, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + // No error expected. + msgs := drainChan(send) + for _, m := range msgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "error" { + t.Errorf("unexpected error on valid channel_focus: %s", m) + } + } + + // Now broadcast to chID — client should receive it because channel was focused. + hub.BroadcastToChannel(chID, []byte(`{"type":"ping","payload":{}}`)) + time.Sleep(30 * time.Millisecond) + + found := false + for _, m := range drainChan(send) { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "ping" { + found = true + break + } + } + if !found { + t.Error("client did not receive broadcast after channel_focus updated its channelID") + } +} + +// TestChannelFocus_InvalidChannelID_NoResponse verifies that a channel_focus +// with channel_id=0 is silently ignored (no crash, no error message). +func TestChannelFocus_InvalidChannelID_NoResponse(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "focus-invalid1") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{"channel_id": 0}, + }) + hub.HandleMessageForTest(c, raw) + time.Sleep(50 * time.Millisecond) + + // No error or other message should be sent for invalid channel_id. + msgs := drainChan(send) + for _, m := range msgs { + var env map[string]any + if err := json.Unmarshal(m, &env); err != nil { + continue + } + if env["type"] == "error" { + t.Errorf("expected silent ignore for channel_id=0, but got error: %s", m) + } + } +} + +// ─── handleMessage ban check (T-044) ───────────────────────────────────────── + +// TestHandleMessage_BannedUser_GetKickedAfterSessionCheck verifies that a +// user who has been banned is kicked after the session-expiry check fires. +// The ban is detected via the user record (banned=1) during the session check. +func TestHandleMessage_BannedUser_GetKickedAfterSessionCheck(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedOwnerUser(t, database, "banned-user1") + chID := seedTestChannel(t, database, "banned-chan1") + + // Create a valid session so the session check reaches the user lookup. + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + hash := auth.HashToken(token) + if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + // Ban the user in the database (permanent ban, no expiry). + if _, err := database.Exec( + `UPDATE users SET banned=1, ban_reason='test ban', ban_expires=NULL WHERE id=?`, + user.ID, + ); err != nil { + t.Fatalf("ban user: %v", err) + } + + send := make(chan []byte, 64) + c := ws.NewTestClientWithTokenHash(hub, user, hash, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send enough messages to cross the session-check threshold. + for i := range ws.SessionCheckInterval + 1 { + hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i))) + } + time.Sleep(100 * time.Millisecond) + + // The hub should have kicked the banned client. + time.Sleep(50 * time.Millisecond) + if hub.ClientCount() != 0 { + t.Error("banned user was not kicked after session check") + } +} diff --git a/Server/ws/hub.go b/Server/ws/hub.go new file mode 100644 index 00000000..bb2480cb --- /dev/null +++ b/Server/ws/hub.go @@ -0,0 +1,368 @@ +// Package ws provides the WebSocket hub and client management for OwnCord. +package ws + +import ( + "log/slog" + "sync" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// broadcastMsg is an internal message queued for delivery. +type broadcastMsg struct { + channelID int64 // 0 = send to all connected clients + msg []byte +} + +// Hub manages all active WebSocket clients and routes messages between them. +// All exported methods are safe to call from multiple goroutines. +type Hub struct { + clients map[int64]*Client + mu sync.RWMutex + db *db.DB + limiter *auth.RateLimiter + broadcast chan broadcastMsg + register chan *Client + unregister chan *Client + stop chan struct{} + stopOnce sync.Once + sfu *SFU + voiceRooms map[int64]*VoiceRoom + voiceRoomsMu sync.RWMutex + + // Settings cache — avoids per-connection DB queries for server_name/motd. + settingsMu sync.RWMutex + settingsName string + settingsMotd string + settingsLastUpdate time.Time +} + +// NewHub creates a Hub ready to be started with Run. +// It also initializes the settings cache from the database. +func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub { + h := &Hub{ + clients: make(map[int64]*Client), + db: database, + limiter: limiter, + broadcast: make(chan broadcastMsg, 256), + register: make(chan *Client, 32), + unregister: make(chan *Client, 32), + stop: make(chan struct{}), + voiceRooms: make(map[int64]*VoiceRoom), + settingsName: "OwnCord Server", + settingsMotd: "Welcome!", + } + h.refreshSettingsLocked() + return h +} + +// getCachedSettings returns server_name and motd, refreshing the cache if stale. +func (h *Hub) getCachedSettings() (string, string) { + h.settingsMu.RLock() + if time.Since(h.settingsLastUpdate) < settingsCacheTTL { + name, motd := h.settingsName, h.settingsMotd + h.settingsMu.RUnlock() + return name, motd + } + h.settingsMu.RUnlock() + + h.settingsMu.Lock() + defer h.settingsMu.Unlock() + // Double-check after acquiring write lock. + if time.Since(h.settingsLastUpdate) < settingsCacheTTL { + return h.settingsName, h.settingsMotd + } + h.refreshSettingsLocked() + return h.settingsName, h.settingsMotd +} + +// refreshSettingsLocked reloads server_name and motd from the DB. +// Caller must hold settingsMu (write lock) or call during init. +func (h *Hub) refreshSettingsLocked() { + if h.db == nil { + return + } + var name, motd string + if err := h.db.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&name); err == nil { + h.settingsName = name + } + if err := h.db.QueryRow("SELECT value FROM settings WHERE key='motd'").Scan(&motd); err == nil { + h.settingsMotd = motd + } + h.settingsLastUpdate = time.Now() +} + +// SetSFU sets the SFU engine on the hub. Must be called before Run. +func (h *Hub) SetSFU(sfu *SFU) { + h.sfu = sfu +} + +// GetOrCreateVoiceRoom returns the existing room for channelID or creates one. +// cfg provides the room config (from channel settings and server defaults). +func (h *Hub) GetOrCreateVoiceRoom(channelID int64, cfg VoiceRoomConfig) *VoiceRoom { + h.voiceRoomsMu.Lock() + defer h.voiceRoomsMu.Unlock() + + if room, ok := h.voiceRooms[channelID]; ok { + return room + } + room := NewVoiceRoom(cfg) + h.voiceRooms[channelID] = room + return room +} + +// GetVoiceRoom returns the room for channelID, or nil if none exists. +func (h *Hub) GetVoiceRoom(channelID int64) *VoiceRoom { + h.voiceRoomsMu.RLock() + defer h.voiceRoomsMu.RUnlock() + return h.voiceRooms[channelID] +} + +// RemoveVoiceRoom removes and closes the room for channelID. No-op if absent. +func (h *Hub) RemoveVoiceRoom(channelID int64) { + h.voiceRoomsMu.Lock() + room, ok := h.voiceRooms[channelID] + if ok { + delete(h.voiceRooms, channelID) + } + h.voiceRoomsMu.Unlock() + + if ok { + room.Close() + } +} + +// CloseAllVoiceRooms closes all voice rooms. Called during shutdown. +func (h *Hub) CloseAllVoiceRooms() { + h.voiceRoomsMu.Lock() + rooms := make([]*VoiceRoom, 0, len(h.voiceRooms)) + for _, room := range h.voiceRooms { + rooms = append(rooms, room) + } + h.voiceRooms = make(map[int64]*VoiceRoom) + h.voiceRoomsMu.Unlock() + + for _, room := range rooms { + room.Close() + } +} + +// Run starts the hub's dispatch loop. It blocks until Stop is called. +// Must be called in its own goroutine. +func (h *Hub) Run() { + go h.runSpeakerBroadcast(h.stop) + + for { + select { + case <-h.stop: + return + + case c := <-h.register: + h.mu.Lock() + h.clients[c.userID] = c + slog.Info("hub: client registered", "user_id", c.userID, "total_clients", len(h.clients)) + h.mu.Unlock() + + case c := <-h.unregister: + h.mu.Lock() + if current, ok := h.clients[c.userID]; ok && current == c { + delete(h.clients, c.userID) + slog.Info("hub: client unregistered", "user_id", c.userID, "total_clients", len(h.clients)) + } + h.mu.Unlock() + + case bm := <-h.broadcast: + h.deliverBroadcast(bm) + } + } +} + +// Stop signals Run to exit. Safe to call multiple times. +func (h *Hub) Stop() { + h.stopOnce.Do(func() { close(h.stop) }) +} + +// GracefulStop closes all PeerConnections, voice rooms, and then stops the hub. +func (h *Hub) GracefulStop() { + // Close all client PeerConnections first (CRIT-2 fix). + h.mu.RLock() + for _, c := range h.clients { + if _, oldPC := c.clearVoice(); oldPC != nil { + _ = oldPC.Close() + } + } + h.mu.RUnlock() + + h.CloseAllVoiceRooms() + h.stopOnce.Do(func() { close(h.stop) }) +} + +// CleanupVoiceForChannel removes the voice room for the given channel and +// closes PeerConnections for all participants. Called when a channel is deleted. +func (h *Hub) CleanupVoiceForChannel(channelID int64) { + room := h.GetVoiceRoom(channelID) + if room == nil { + return + } + + // Get participant IDs before removing the room. + participantIDs := room.ParticipantIDs() + + // Remove the room (this also calls room.Close() which clears participants). + h.RemoveVoiceRoom(channelID) + + // Close PeerConnections and clean up DB state for all participants. + // Use RLock for client map read; voice fields are guarded by voiceMu (HIGH-3 fix). + h.mu.RLock() + for _, userID := range participantIDs { + if client, ok := h.clients[userID]; ok { + if _, oldPC := client.clearVoice(); oldPC != nil { + _ = oldPC.Close() + } + } + // Clean up DB voice state (best-effort; ignore error). + _ = h.db.LeaveVoiceChannel(userID) + } + h.mu.RUnlock() + + // Broadcast voice_leave for each participant. + for _, userID := range participantIDs { + h.BroadcastToAll(buildVoiceLeave(channelID, userID)) + } +} + +// IsUserConnected returns true if a client with the given userID is already +// registered in the hub. Safe to call from any goroutine. +func (h *Hub) IsUserConnected(userID int64) bool { + h.mu.RLock() + _, ok := h.clients[userID] + h.mu.RUnlock() + return ok +} + +// GetClient returns the client for userID, or nil if not connected. +// Safe to call from any goroutine. +func (h *Hub) GetClient(userID int64) *Client { + h.mu.RLock() + defer h.mu.RUnlock() + return h.clients[userID] +} + +// Register queues a client for registration with the hub. +func (h *Hub) Register(c *Client) { + h.register <- c +} + +// Unregister queues a client for removal from the hub. +func (h *Hub) Unregister(c *Client) { + h.unregister <- c +} + +// BroadcastToChannel enqueues msg for delivery to all clients subscribed to +// channelID. When channelID is 0 the message is sent to every connected client. +func (h *Hub) BroadcastToChannel(channelID int64, msg []byte) { + h.broadcast <- broadcastMsg{channelID: channelID, msg: msg} +} + +// BroadcastToAll enqueues msg for delivery to every connected client. +func (h *Hub) BroadcastToAll(msg []byte) { + h.broadcast <- broadcastMsg{channelID: 0, msg: msg} +} + +// BroadcastServerRestart sends a server_restart message to all connected clients. +// reason describes why the server is restarting (e.g., "update"). +// delaySeconds tells clients how long until the server actually shuts down. +func (h *Hub) BroadcastServerRestart(reason string, delaySeconds int) { + h.BroadcastToAll(buildServerRestartMsg(reason, delaySeconds)) +} + +// BroadcastChannelCreate sends a channel_create message to all connected clients. +func (h *Hub) BroadcastChannelCreate(ch *db.Channel) { + h.BroadcastToAll(buildChannelCreate(ch)) +} + +// BroadcastChannelUpdate sends a channel_update message to all connected clients. +func (h *Hub) BroadcastChannelUpdate(ch *db.Channel) { + h.BroadcastToAll(buildChannelUpdate(ch)) +} + +// BroadcastChannelDelete sends a channel_delete message to all connected clients. +func (h *Hub) BroadcastChannelDelete(channelID int64) { + h.BroadcastToAll(buildChannelDelete(channelID)) +} + +// BroadcastMemberBan sends a member_ban message to all connected clients. +func (h *Hub) BroadcastMemberBan(userID int64) { + h.BroadcastToAll(buildMemberBan(userID)) +} + +// BroadcastMemberUpdate sends a member_update message to all connected clients. +func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) { + h.BroadcastToAll(buildMemberUpdate(userID, roleName)) +} + +// SendToUser delivers msg directly to the client identified by userID. +// Returns true if the client was found and the message was queued. +func (h *Hub) SendToUser(userID int64, msg []byte) bool { + h.mu.RLock() + c, ok := h.clients[userID] + h.mu.RUnlock() + if !ok { + return false + } + select { + case c.send <- msg: + return true + default: + // send buffer full — drop rather than block. + return false + } +} + +// ClientCount returns the number of currently registered clients (test helper). +func (h *Hub) ClientCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +// kickClient forcibly removes a client from the hub and closes its send channel, +// which causes writePump to exit and the WebSocket connection to close. +// It is safe to call from any goroutine. +func (h *Hub) kickClient(c *Client) { + h.mu.Lock() + if current, ok := h.clients[c.userID]; ok && current == c { + delete(h.clients, c.userID) + } + h.mu.Unlock() + c.closeSend() +} + +// deliverBroadcast sends bm.msg to the appropriate clients. +func (h *Hub) deliverBroadcast(bm broadcastMsg) { + h.mu.RLock() + defer h.mu.RUnlock() + + delivered := 0 + skipped := 0 + for _, c := range h.clients { + // channelID == 0 → broadcast to everyone. + if bm.channelID != 0 && c.channelID != bm.channelID && c.getVoiceChID() != bm.channelID { + skipped++ + continue + } + select { + case c.send <- bm.msg: + delivered++ + default: + slog.Warn("broadcast dropped: client send buffer full", + "user_id", c.userID, "channel_id", bm.channelID) + } + } + if bm.channelID != 0 { + slog.Debug("hub: channel broadcast", + "channel_id", bm.channelID, "delivered", delivered, "skipped", skipped) + } +} diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go new file mode 100644 index 00000000..c78ceee4 --- /dev/null +++ b/Server/ws/hub_test.go @@ -0,0 +1,805 @@ +package ws_test + +import ( + "encoding/json" + "fmt" + "sync" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/ws" +) + +// ─── test helpers ───────────────────────────────────────────────────────────── + +func openTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: hubTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +func newTestHub(t *testing.T) (*ws.Hub, *db.DB) { + t.Helper() + database := openTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + return hub, database +} + +// seedTestUser inserts a Member-role user and returns its ID. +func seedTestUser(t *testing.T, database *db.DB, username string) int64 { + t.Helper() + id, err := database.CreateUser(username, "hash", 4) + if err != nil { + t.Fatalf("seedUser: %v", err) + } + return id +} + +// seedOwnerUser inserts an Owner-role user and returns the full *db.User. +// Owner role (id=1) has all permissions (0x7FFFFFFF), so it passes all checks. +func seedOwnerUser(t *testing.T, database *db.DB, username string) *db.User { + t.Helper() + _, err := database.CreateUser(username, "hash", 1) // roleID=1 → Owner + if err != nil { + t.Fatalf("seedOwnerUser: %v", err) + } + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + t.Fatalf("seedOwnerUser GetUserByUsername: %v", err) + } + return user +} + +// seedTestChannel inserts a channel and returns its ID. +func seedTestChannel(t *testing.T, database *db.DB, name string) int64 { + t.Helper() + id, err := database.CreateChannel(name, "text", "", "", 0) + if err != nil { + t.Fatalf("seedChannel: %v", err) + } + return id +} + +// ─── Hub lifecycle ──────────────────────────────────────────────────────────── + +func TestNewHub_NotNil(t *testing.T) { + hub, _ := newTestHub(t) + if hub == nil { + t.Fatal("NewHub returned nil") + } +} + +func TestHub_RunStops(t *testing.T) { + hub, _ := newTestHub(t) + done := make(chan struct{}) + go func() { + hub.Run() + close(done) + }() + // Give the goroutine a moment to start, then stop the hub. + time.Sleep(10 * time.Millisecond) + hub.Stop() + select { + case <-done: + // ok + case <-time.After(2 * time.Second): + t.Error("hub.Run() did not stop after hub.Stop()") + } +} + +// ─── Register / Unregister ──────────────────────────────────────────────────── + +func TestHub_RegisterIncrementsCount(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + userID := seedTestUser(t, database, "alice") + send := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, userID, send)) + + time.Sleep(20 * time.Millisecond) + if hub.ClientCount() != 1 { + t.Errorf("ClientCount = %d, want 1", hub.ClientCount()) + } +} + +func TestHub_UnregisterDecrementsCount(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + userID := seedTestUser(t, database, "bob") + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, userID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.Unregister(c) + time.Sleep(20 * time.Millisecond) + if hub.ClientCount() != 0 { + t.Errorf("ClientCount = %d, want 0", hub.ClientCount()) + } +} + +func TestHub_RegisterSameUserTwice(t *testing.T) { + // Second registration for same userID should replace the first. + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + userID := seedTestUser(t, database, "carol") + send1 := make(chan []byte, 4) + send2 := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, userID, send1)) + hub.Register(ws.NewTestClient(hub, userID, send2)) + time.Sleep(30 * time.Millisecond) + + if hub.ClientCount() != 1 { + t.Errorf("ClientCount = %d after double register, want 1", hub.ClientCount()) + } +} + +// ─── BroadcastToAll ─────────────────────────────────────────────────────────── + +func TestHub_BroadcastToAll_DeliversToAllClients(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + u1 := seedTestUser(t, database, "dave") + u2 := seedTestUser(t, database, "eve") + s1 := make(chan []byte, 4) + s2 := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, u1, s1)) + hub.Register(ws.NewTestClient(hub, u2, s2)) + time.Sleep(20 * time.Millisecond) + + msg := []byte(`{"type":"presence","payload":{}}`) + hub.BroadcastToAll(msg) + time.Sleep(20 * time.Millisecond) + + assertReceived(t, s1, msg, "client 1") + assertReceived(t, s2, msg, "client 2") +} + +func TestHub_BroadcastToAll_NoClients(t *testing.T) { + hub, _ := newTestHub(t) + go hub.Run() + defer hub.Stop() + + // Should not panic. + hub.BroadcastToAll([]byte(`{}`)) +} + +// ─── BroadcastToChannel ─────────────────────────────────────────────────────── + +func TestHub_BroadcastToChannel_OnlySendsToChannelMembers(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + chID := seedTestChannel(t, database, "general") + u1 := seedTestUser(t, database, "frank") + u2 := seedTestUser(t, database, "grace") + + s1 := make(chan []byte, 4) + s2 := make(chan []byte, 4) + c1 := ws.NewTestClientWithChannel(hub, u1, chID, s1) + c2 := ws.NewTestClientWithChannel(hub, u2, 999, s2) // different channel + + hub.Register(c1) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + + msg := []byte(`{"type":"chat_message","payload":{}}`) + hub.BroadcastToChannel(chID, msg) + time.Sleep(20 * time.Millisecond) + + assertReceived(t, s1, msg, "channel member") + assertNotReceived(t, s2, "non-member") +} + +func TestHub_BroadcastToChannel_ZeroChannelSendsToAll(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + u1 := seedTestUser(t, database, "henry") + s1 := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, u1, s1)) + time.Sleep(20 * time.Millisecond) + + msg := []byte(`{"type":"presence","payload":{}}`) + hub.BroadcastToChannel(0, msg) + time.Sleep(20 * time.Millisecond) + + assertReceived(t, s1, msg, "client") +} + +// ─── SendToUser ─────────────────────────────────────────────────────────────── + +func TestHub_SendToUser_ExistingClient(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + userID := seedTestUser(t, database, "ivan") + send := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, userID, send)) + time.Sleep(20 * time.Millisecond) + + msg := []byte(`{"type":"chat_send_ok","payload":{}}`) + ok := hub.SendToUser(userID, msg) + if !ok { + t.Error("SendToUser returned false for existing client") + } + time.Sleep(20 * time.Millisecond) + assertReceived(t, send, msg, "target user") +} + +func TestHub_SendToUser_MissingClient(t *testing.T) { + hub, _ := newTestHub(t) + go hub.Run() + defer hub.Stop() + + ok := hub.SendToUser(9999, []byte(`{}`)) + if ok { + t.Error("SendToUser should return false for absent client") + } +} + +// ─── Message dispatch ───────────────────────────────────────────────────────── + +func TestHub_HandleMessage_UnknownType_SendsError(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + userID := seedTestUser(t, database, "julia") + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, userID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + raw := []byte(`{"type":"totally_unknown","payload":{}}`) + hub.HandleMessageForTest(c, raw) + time.Sleep(20 * time.Millisecond) + + select { + case got := <-send: + var resp map[string]any + if err := json.Unmarshal(got, &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp["type"] != "error" { + t.Errorf("type = %q, want 'error'", resp["type"]) + } + case <-time.After(500 * time.Millisecond): + t.Error("expected error response for unknown message type") + } +} + +func TestHub_HandleMessage_InvalidJSON(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + userID := seedTestUser(t, database, "kim") + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, userID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, []byte(`NOT JSON`)) + time.Sleep(20 * time.Millisecond) + + select { + case got := <-send: + var resp map[string]any + if err := json.Unmarshal(got, &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp["type"] != "error" { + t.Errorf("type = %q, want 'error'", resp["type"]) + } + case <-time.After(500 * time.Millisecond): + t.Error("expected error response for invalid JSON") + } +} + +// ─── Rate limiting ──────────────────────────────────────────────────────────── + +func TestHub_ChatSend_RateLimit(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + user := seedOwnerUser(t, database, "larry") + chID := seedTestChannel(t, database, "rl-test") + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + payload := map[string]any{ + "channel_id": chID, + "content": "hi", + } + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": payload, + }) + + // Send 12 messages rapidly — 11th and beyond should be rate-limited. + for range 12 { + hub.HandleMessageForTest(c, raw) + } + time.Sleep(100 * time.Millisecond) + + // Drain all messages, count errors. + errCount := 0 + drainLoop: + for { + select { + case got := <-send: + var resp map[string]any + if err := json.Unmarshal(got, &resp); err == nil { + if resp["type"] == "error" { + errCount++ + } + } + default: + break drainLoop + } + } + if errCount == 0 { + t.Error("expected at least one rate-limit error response") + } +} + +// ─── Concurrency ───────────────────────────────────────────────────────────── + +func TestHub_ConcurrentRegisterUnregister(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + var wg sync.WaitGroup + for i := range 20 { + wg.Add(1) + go func(i int) { + defer wg.Done() + username := fmt.Sprintf("user%d", i) + userID := seedTestUser(t, database, username) + send := make(chan []byte, 4) + c := ws.NewTestClient(hub, userID, send) + hub.Register(c) + time.Sleep(5 * time.Millisecond) + hub.Unregister(c) + }(i) + } + wg.Wait() + time.Sleep(50 * time.Millisecond) + if hub.ClientCount() != 0 { + t.Errorf("expected 0 clients after concurrent churn, got %d", hub.ClientCount()) + } +} + +// ─── GetClient ─────────────────────────────────────────────────────────────── + +func TestHub_GetClient(t *testing.T) { + hub, _ := newTestHub(t) + send := make(chan []byte, 256) + client := ws.NewTestClient(hub, 42, send) + hub.Register(client) + go hub.Run() + defer hub.Stop() + time.Sleep(10 * time.Millisecond) + + got := hub.GetClient(42) + if got == nil { + t.Fatal("GetClient(42) returned nil") + } + + got2 := hub.GetClient(999) + if got2 != nil { + t.Fatal("GetClient(999) should return nil") + } +} + +// ─── assertion helpers ──────────────────────────────────────────────────────── + +func assertReceived(t *testing.T, ch <-chan []byte, want []byte, label string) { + t.Helper() + select { + case got := <-ch: + if string(got) != string(want) { + t.Errorf("%s: got %q, want %q", label, got, want) + } + case <-time.After(500 * time.Millisecond): + t.Errorf("%s: did not receive expected message within timeout", label) + } +} + +func assertNotReceived(t *testing.T, ch <-chan []byte, label string) { + t.Helper() + select { + case got := <-ch: + t.Errorf("%s: received unexpected message: %q", label, got) + case <-time.After(100 * time.Millisecond): + // ok — nothing received + } +} + +// ─── Voice room lifecycle ───────────────────────────────────────────────────── + +func TestHub_SetSFU_NilSafe(t *testing.T) { + hub, _ := newTestHub(t) + // Setting a nil SFU must not panic. + hub.SetSFU(nil) +} + +func TestHub_GetOrCreateVoiceRoom_CreatesNew(t *testing.T) { + hub, _ := newTestHub(t) + cfg := ws.VoiceRoomConfig{ChannelID: 42, MaxUsers: 10, Quality: "medium"} + + room := hub.GetOrCreateVoiceRoom(42, cfg) + if room == nil { + t.Fatal("GetOrCreateVoiceRoom returned nil") + } +} + +func TestHub_GetOrCreateVoiceRoom_ReturnsSameRoom(t *testing.T) { + hub, _ := newTestHub(t) + cfg := ws.VoiceRoomConfig{ChannelID: 99, MaxUsers: 5, Quality: "low"} + + r1 := hub.GetOrCreateVoiceRoom(99, cfg) + r2 := hub.GetOrCreateVoiceRoom(99, cfg) + if r1 != r2 { + t.Error("GetOrCreateVoiceRoom should return the same room on subsequent calls") + } +} + +func TestHub_GetOrCreateVoiceRoom_DifferentChannels(t *testing.T) { + hub, _ := newTestHub(t) + cfg1 := ws.VoiceRoomConfig{ChannelID: 1, Quality: "low"} + cfg2 := ws.VoiceRoomConfig{ChannelID: 2, Quality: "high"} + + r1 := hub.GetOrCreateVoiceRoom(1, cfg1) + r2 := hub.GetOrCreateVoiceRoom(2, cfg2) + if r1 == r2 { + t.Error("different channel IDs must produce distinct rooms") + } +} + +func TestHub_GetVoiceRoom_ReturnsNilWhenAbsent(t *testing.T) { + hub, _ := newTestHub(t) + room := hub.GetVoiceRoom(404) + if room != nil { + t.Errorf("GetVoiceRoom: want nil for absent channel, got %v", room) + } +} + +func TestHub_GetVoiceRoom_ReturnsRoomAfterCreate(t *testing.T) { + hub, _ := newTestHub(t) + cfg := ws.VoiceRoomConfig{ChannelID: 7, Quality: "medium"} + hub.GetOrCreateVoiceRoom(7, cfg) + + room := hub.GetVoiceRoom(7) + if room == nil { + t.Fatal("GetVoiceRoom: want non-nil after GetOrCreateVoiceRoom, got nil") + } +} + +func TestHub_RemoveVoiceRoom_NoopWhenAbsent(t *testing.T) { + hub, _ := newTestHub(t) + // Must not panic on removal of non-existent room. + hub.RemoveVoiceRoom(999) +} + +func TestHub_RemoveVoiceRoom_RemovesRoom(t *testing.T) { + hub, _ := newTestHub(t) + cfg := ws.VoiceRoomConfig{ChannelID: 55, Quality: "low"} + hub.GetOrCreateVoiceRoom(55, cfg) + + hub.RemoveVoiceRoom(55) + if hub.GetVoiceRoom(55) != nil { + t.Error("GetVoiceRoom: want nil after RemoveVoiceRoom") + } +} + +func TestHub_CloseAllVoiceRooms_ClearsAll(t *testing.T) { + hub, _ := newTestHub(t) + for _, id := range []int64{10, 20, 30} { + hub.GetOrCreateVoiceRoom(id, ws.VoiceRoomConfig{ChannelID: id, Quality: "medium"}) + } + + hub.CloseAllVoiceRooms() + + for _, id := range []int64{10, 20, 30} { + if hub.GetVoiceRoom(id) != nil { + t.Errorf("GetVoiceRoom(%d): want nil after CloseAllVoiceRooms", id) + } + } +} + +func TestHub_CloseAllVoiceRooms_EmptyIsNoop(t *testing.T) { + hub, _ := newTestHub(t) + // Must not panic when no rooms exist. + hub.CloseAllVoiceRooms() +} + +func TestHub_VoiceRooms_ConcurrentAccess(t *testing.T) { + hub, _ := newTestHub(t) + var wg sync.WaitGroup + + // Concurrent creates and reads must not race. + for i := range int64(20) { + wg.Add(1) + go func(id int64) { + defer wg.Done() + cfg := ws.VoiceRoomConfig{ChannelID: id, Quality: "medium"} + hub.GetOrCreateVoiceRoom(id, cfg) + hub.GetVoiceRoom(id) + hub.RemoveVoiceRoom(id) + }(i) + } + wg.Wait() +} + +// ─── GracefulStop ───────────────────────────────────────────────────────────── + +func TestHub_GracefulStop_StopsHub(t *testing.T) { + hub, _ := newTestHub(t) + done := make(chan struct{}) + go func() { + hub.Run() + close(done) + }() + time.Sleep(10 * time.Millisecond) + + hub.GracefulStop() + + select { + case <-done: + // ok — hub stopped + case <-time.After(2 * time.Second): + t.Error("hub.Run() did not stop after GracefulStop()") + } +} + +func TestHub_GracefulStop_ClosesAllVoiceRooms(t *testing.T) { + hub, _ := newTestHub(t) + for _, id := range []int64{100, 200, 300} { + hub.GetOrCreateVoiceRoom(id, ws.VoiceRoomConfig{ChannelID: id, Quality: "low"}) + } + go hub.Run() + + hub.GracefulStop() + time.Sleep(20 * time.Millisecond) + + for _, id := range []int64{100, 200, 300} { + if hub.GetVoiceRoom(id) != nil { + t.Errorf("GetVoiceRoom(%d): expected nil after GracefulStop", id) + } + } +} + +func TestHub_GracefulStop_NoRooms_NoPanic(t *testing.T) { + hub, _ := newTestHub(t) + go hub.Run() + // Must not panic with zero voice rooms. + hub.GracefulStop() +} + +// ─── CleanupVoiceForChannel ─────────────────────────────────────────────────── + +func TestHub_CleanupVoiceForChannel_RemovesRoom(t *testing.T) { + hub, _ := newTestHub(t) + chID := int64(55) + hub.GetOrCreateVoiceRoom(chID, ws.VoiceRoomConfig{ChannelID: chID, Quality: "medium"}) + + hub.CleanupVoiceForChannel(chID) + + if hub.GetVoiceRoom(chID) != nil { + t.Error("expected room to be nil after CleanupVoiceForChannel") + } +} + +func TestHub_CleanupVoiceForChannel_NoRoom_NoPanic(t *testing.T) { + hub, _ := newTestHub(t) + // Must not panic when channel has no voice room. + hub.CleanupVoiceForChannel(9999) +} + +func TestHub_CleanupVoiceForChannel_BroadcastsVoiceLeave(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + chID := seedTestChannel(t, database, "cleanup-vc") + u1 := seedTestUser(t, database, "cleanup-user1") + u2 := seedTestUser(t, database, "cleanup-user2") + + send1 := make(chan []byte, 16) + send2 := make(chan []byte, 16) + c1 := ws.NewTestClientWithChannel(hub, u1, chID, send1) + c2 := ws.NewTestClientWithChannel(hub, u2, chID, send2) + hub.Register(c1) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + + room := hub.GetOrCreateVoiceRoom(chID, ws.VoiceRoomConfig{ChannelID: chID, Quality: "medium"}) + if err := room.AddParticipant(u1); err != nil { + t.Fatalf("AddParticipant u1: %v", err) + } + if err := room.AddParticipant(u2); err != nil { + t.Fatalf("AddParticipant u2: %v", err) + } + + hub.CleanupVoiceForChannel(chID) + time.Sleep(50 * time.Millisecond) + + // At least one of the clients must receive a voice_leave. + allMsgs := append(drainChan(send1), drainChan(send2)...) + found := false + for _, msg := range allMsgs { + var env map[string]any + if err := json.Unmarshal(msg, &env); err == nil { + if env["type"] == "voice_leave" { + found = true + break + } + } + } + if !found { + t.Error("expected voice_leave broadcast after CleanupVoiceForChannel") + } +} + +// TestHub_Register_CleansUpOldVoiceState was removed because duplicate +// logins are now rejected at the WebSocket handshake level (commit 00bbb46) +// before hub.Register is called. The hub's register case simply overwrites +// the client map entry; voice cleanup for disconnects is handled by +// handleVoiceLeave called from readPump/ICE monitor. + +// hubTestSchema is the minimal schema needed for hub tests. +var hubTestSchema = []byte(` +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT, + permissions INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES + (1, 'Owner', '#E74C3C', 2147483647, 100, 0), + (2, 'Admin', '#F39C12', 1073741823, 80, 0), + (3, 'Moderator', '#3498DB', 1048575, 60, 0), + (4, 'Member', NULL, 1635, 40, 1); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + avatar TEXT, + role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, + status TEXT NOT NULL DEFAULT 'offline', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS channel_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + UNIQUE(channel_id, role_id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, + edited_at TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + content, + content='messages', + content_rowid='id' +); + +CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; + +CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); +END; + +CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; + +CREATE TABLE IF NOT EXISTS reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + UNIQUE(message_id, user_id, emoji) +); + +CREATE TABLE IF NOT EXISTS read_states ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + last_message_id INTEGER NOT NULL DEFAULT 0, + mention_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, channel_id) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT OR IGNORE INTO settings (key, value) VALUES + ('server_name', 'OwnCord Server'), + ('motd', 'Welcome!'); +`) diff --git a/Server/ws/messages.go b/Server/ws/messages.go new file mode 100644 index 00000000..1f0941d8 --- /dev/null +++ b/Server/ws/messages.go @@ -0,0 +1,368 @@ +package ws + +import ( + "encoding/json" + "fmt" + + "github.com/owncord/server/db" +) + +// envelope is the common wrapper for all WebSocket messages. +type envelope struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// buildJSON marshals v into a JSON byte slice, logging on failure. +func buildJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + // Fallback: send a generic error rather than panicking. + b, _ = json.Marshal(map[string]string{"type": "error", "message": "internal marshal error"}) + } + return b +} + +// buildErrorMsg produces an error envelope with the given code and message. +func buildErrorMsg(code, message string) []byte { + return buildJSON(map[string]any{ + "type": "error", + "payload": map[string]string{ + "code": code, + "message": message, + }, + }) +} + +// buildRateLimitError produces a RATE_LIMITED error with retry_after per PROTOCOL.md. +func buildRateLimitError(message string, retryAfterSeconds float64) []byte { + return buildJSON(map[string]any{ + "type": "error", + "payload": map[string]any{ + "code": "RATE_LIMITED", + "message": message, + "retry_after": retryAfterSeconds, + }, + }) +} + +// buildAuthError produces an auth_error envelope per PROTOCOL.md. +// The client treats this type as non-recoverable and stops reconnecting. +func buildAuthError(message string) []byte { + return buildJSON(map[string]any{ + "type": "auth_error", + "payload": map[string]string{ + "message": message, + }, + }) +} + +// buildPresenceMsg constructs a presence broadcast payload. +func buildPresenceMsg(userID int64, status string) []byte { + return buildJSON(map[string]any{ + "type": "presence", + "payload": map[string]any{ + "user_id": userID, + "status": status, + }, + }) +} + +// buildMemberJoin constructs a member_join broadcast for when a user comes online. +func buildMemberJoin(user *db.User, roleName string) []byte { + var avatarVal any + if user.Avatar != nil { + avatarVal = *user.Avatar + } + return buildJSON(map[string]any{ + "type": "member_join", + "payload": map[string]any{ + "user": map[string]any{ + "id": user.ID, + "username": user.Username, + "avatar": avatarVal, + "role": roleName, + }, + }, + }) +} + +// buildChatMessage constructs a chat_message broadcast envelope. +// Includes role in user object and empty reactions array for consistency with REST API. +func buildChatMessage(msgID, channelID, userID int64, username string, avatar *string, roleName string, content string, timestamp string, replyTo *int64, attachments []map[string]any) []byte { + var avatarVal any + if avatar != nil { + avatarVal = *avatar + } + if attachments == nil { + attachments = []map[string]any{} + } + return buildJSON(map[string]any{ + "type": "chat_message", + "payload": map[string]any{ + "id": msgID, + "channel_id": channelID, + "user": map[string]any{ + "id": userID, + "username": username, + "avatar": avatarVal, + "role": roleName, + }, + "content": content, + "reply_to": replyTo, + "timestamp": timestamp, + "attachments": attachments, + "reactions": []any{}, + }, + }) +} + +// buildMemberUpdate constructs a member_update broadcast per PROTOCOL.md. +func buildMemberUpdate(userID int64, roleName string) []byte { + return buildJSON(map[string]any{ + "type": "member_update", + "payload": map[string]any{ + "user_id": userID, + "role": roleName, + }, + }) +} + +// buildMemberBan constructs a member_ban broadcast per PROTOCOL.md. +func buildMemberBan(userID int64) []byte { + return buildJSON(map[string]any{ + "type": "member_ban", + "payload": map[string]any{ + "user_id": userID, + }, + }) +} + +// buildChatSendOK constructs a chat_send_ok ack. +func buildChatSendOK(requestID string, msgID int64, timestamp string) []byte { + return buildJSON(map[string]any{ + "type": "chat_send_ok", + "id": requestID, + "payload": map[string]any{ + "message_id": msgID, + "timestamp": timestamp, + }, + }) +} + +// buildChatEdited constructs a chat_edited broadcast. +func buildChatEdited(msgID, channelID int64, content, editedAt string) []byte { + return buildJSON(map[string]any{ + "type": "chat_edited", + "payload": map[string]any{ + "message_id": msgID, + "channel_id": channelID, + "content": content, + "edited_at": editedAt, + }, + }) +} + +// buildChatDeleted constructs a chat_deleted broadcast. +func buildChatDeleted(msgID, channelID int64) []byte { + return buildJSON(map[string]any{ + "type": "chat_deleted", + "payload": map[string]any{ + "message_id": msgID, + "channel_id": channelID, + }, + }) +} + +// buildReactionUpdate constructs a reaction_update broadcast. +func buildReactionUpdate(msgID, channelID, userID int64, emoji, action string) []byte { + return buildJSON(map[string]any{ + "type": "reaction_update", + "payload": map[string]any{ + "message_id": msgID, + "channel_id": channelID, + "emoji": emoji, + "user_id": userID, + "action": action, + }, + }) +} + +// buildTypingMsg constructs a typing broadcast. +func buildTypingMsg(channelID, userID int64, username string) []byte { + return buildJSON(map[string]any{ + "type": "typing", + "payload": map[string]any{ + "channel_id": channelID, + "user_id": userID, + "username": username, + }, + }) +} + +// buildVoiceState constructs a voice_state server->client broadcast. +func buildVoiceState(state db.VoiceState) []byte { + return buildJSON(map[string]any{ + "type": "voice_state", + "payload": map[string]any{ + "channel_id": state.ChannelID, + "user_id": state.UserID, + "username": state.Username, + "muted": state.Muted, + "deafened": state.Deafened, + "speaking": state.Speaking, + "camera": state.Camera, + "screenshare": state.Screenshare, + }, + }) +} + +// buildVoiceConfig constructs a voice_config message sent after voice_join acceptance. +func buildVoiceConfig(channelID int64, quality string, bitrate int, mode string, threshold, topSpeakers, maxUsers int) []byte { + return buildJSON(map[string]any{ + "type": "voice_config", + "payload": map[string]any{ + "channel_id": channelID, + "quality": quality, + "bitrate": bitrate, + "threshold_mode": mode, + "mixing_threshold": threshold, + "top_speakers": topSpeakers, + "max_users": maxUsers, + }, + }) +} + +// buildVoiceSpeakers constructs a voice_speakers broadcast. +func buildVoiceSpeakers(channelID int64, speakers []int64, mode string) []byte { + return buildJSON(map[string]any{ + "type": "voice_speakers", + "payload": map[string]any{ + "channel_id": channelID, + "speakers": speakers, + "threshold_mode": mode, + }, + }) +} + +// buildVoiceLeave constructs a voice_leave server->client broadcast. +func buildVoiceLeave(channelID, userID int64) []byte { + return buildJSON(map[string]any{ + "type": "voice_leave", + "payload": map[string]any{ + "channel_id": channelID, + "user_id": userID, + }, + }) +} + +// buildVoiceAnswer constructs a voice_answer message sent from server to client. +func buildVoiceAnswer(channelID int64, sdp string) []byte { + return buildJSON(map[string]any{ + "type": "voice_answer", + "payload": map[string]any{ + "channel_id": channelID, + "sdp": sdp, + }, + }) +} + +// buildVoiceOffer constructs a voice_offer message sent from server to client. +func buildVoiceOffer(channelID int64, sdp string) []byte { + return buildJSON(map[string]any{ + "type": "voice_offer", + "payload": map[string]any{ + "channel_id": channelID, + "sdp": sdp, + }, + }) +} + +// buildVoiceICE constructs a voice_ice message sent from server to client. +func buildVoiceICE(channelID int64, candidate any) []byte { + return buildJSON(map[string]any{ + "type": "voice_ice", + "payload": map[string]any{ + "channel_id": channelID, + "candidate": candidate, + }, + }) +} + +// buildSoundboardPlay constructs a soundboard_play broadcast. +func buildSoundboardPlay(soundID string, userID int64) []byte { + return buildJSON(map[string]any{ + "type": "soundboard_play", + "payload": map[string]any{ + "sound_id": soundID, + "user_id": userID, + }, + }) +} + +// buildChannelCreate constructs a channel_create broadcast. +func buildChannelCreate(ch *db.Channel) []byte { + return buildJSON(map[string]any{ + "type": "channel_create", + "payload": map[string]any{ + "id": ch.ID, + "name": ch.Name, + "type": ch.Type, + "category": ch.Category, + "topic": ch.Topic, + "position": ch.Position, + }, + }) +} + +// buildChannelUpdate constructs a channel_update broadcast. +func buildChannelUpdate(ch *db.Channel) []byte { + return buildJSON(map[string]any{ + "type": "channel_update", + "payload": map[string]any{ + "id": ch.ID, + "name": ch.Name, + "type": ch.Type, + "category": ch.Category, + "topic": ch.Topic, + "position": ch.Position, + }, + }) +} + +// buildChannelDelete constructs a channel_delete broadcast. +func buildChannelDelete(channelID int64) []byte { + return buildJSON(map[string]any{ + "type": "channel_delete", + "payload": map[string]any{ + "id": channelID, + }, + }) +} + +// buildServerRestartMsg constructs a server_restart broadcast. +func buildServerRestartMsg(reason string, delaySeconds int) []byte { + return buildJSON(map[string]any{ + "type": "server_restart", + "payload": map[string]any{ + "reason": reason, + "delay_seconds": delaySeconds, + }, + }) +} + +// parseChannelID safely extracts channel_id from a raw payload map. +func parseChannelID(payload json.RawMessage) (int64, error) { + var p struct { + ChannelID json.Number `json:"channel_id"` + } + if err := json.Unmarshal(payload, &p); err != nil { + return 0, err + } + id, err := p.ChannelID.Int64() + if err != nil { + return 0, fmt.Errorf("channel_id must be integer: %w", err) + } + return id, nil +} diff --git a/Server/ws/messages_test.go b/Server/ws/messages_test.go new file mode 100644 index 00000000..1c44ca5a --- /dev/null +++ b/Server/ws/messages_test.go @@ -0,0 +1,590 @@ +package ws + +import ( + "encoding/json" + "testing" + + "github.com/owncord/server/db" +) + +func TestBuildServerRestartMsg(t *testing.T) { + msg := buildServerRestartMsg("update", 5) + var env struct { + Type string `json:"type"` + Payload struct { + Reason string `json:"reason"` + DelaySeconds int `json:"delay_seconds"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "server_restart" { + t.Errorf("type = %q, want server_restart", env.Type) + } + if env.Payload.Reason != "update" { + t.Errorf("reason = %q, want update", env.Payload.Reason) + } + if env.Payload.DelaySeconds != 5 { + t.Errorf("delay_seconds = %d, want 5", env.Payload.DelaySeconds) + } +} + +// ─── channel CRUD message builders ─────────────────────────────────────────── + +// channelPayload is the common shape expected in channel_create/update payloads. +type channelPayload struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + Topic string `json:"topic"` + Position int `json:"position"` +} + +func sampleChannel() *db.Channel { + return &db.Channel{ + ID: 42, + Name: "general", + Type: "text", + Category: "Main", + Topic: "All chat", + Position: 3, + } +} + +func TestBuildChannelCreate_Type(t *testing.T) { + msg := buildChannelCreate(sampleChannel()) + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "channel_create" { + t.Errorf("type = %q, want channel_create", env.Type) + } +} + +func TestBuildChannelCreate_Payload(t *testing.T) { + ch := sampleChannel() + msg := buildChannelCreate(ch) + var env struct { + Type string `json:"type"` + Payload channelPayload `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + p := env.Payload + if p.ID != ch.ID { + t.Errorf("payload.id = %d, want %d", p.ID, ch.ID) + } + if p.Name != ch.Name { + t.Errorf("payload.name = %q, want %q", p.Name, ch.Name) + } + if p.Type != ch.Type { + t.Errorf("payload.type = %q, want %q", p.Type, ch.Type) + } + if p.Category != ch.Category { + t.Errorf("payload.category = %q, want %q", p.Category, ch.Category) + } + if p.Topic != ch.Topic { + t.Errorf("payload.topic = %q, want %q", p.Topic, ch.Topic) + } + if p.Position != ch.Position { + t.Errorf("payload.position = %d, want %d", p.Position, ch.Position) + } +} + +func TestBuildChannelUpdate_Type(t *testing.T) { + msg := buildChannelUpdate(sampleChannel()) + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "channel_update" { + t.Errorf("type = %q, want channel_update", env.Type) + } +} + +func TestBuildChannelUpdate_Payload(t *testing.T) { + ch := sampleChannel() + msg := buildChannelUpdate(ch) + var env struct { + Payload channelPayload `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + p := env.Payload + if p.ID != ch.ID { + t.Errorf("payload.id = %d, want %d", p.ID, ch.ID) + } + if p.Name != ch.Name { + t.Errorf("payload.name = %q, want %q", p.Name, ch.Name) + } +} + +func TestBuildChannelDelete_Type(t *testing.T) { + msg := buildChannelDelete(99) + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "channel_delete" { + t.Errorf("type = %q, want channel_delete", env.Type) + } +} + +func TestBuildChannelDelete_Payload(t *testing.T) { + msg := buildChannelDelete(99) + var env struct { + Payload struct { + ID int64 `json:"id"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.ID != 99 { + t.Errorf("payload.id = %d, want 99", env.Payload.ID) + } +} + +// TestBuildChannelCreate_ValidJSON verifies the output is always valid JSON. +func TestBuildChannelCreate_ValidJSON(t *testing.T) { + msg := buildChannelCreate(sampleChannel()) + if !json.Valid(msg) { + t.Errorf("buildChannelCreate output is not valid JSON: %s", msg) + } +} + +// TestBuildChannelUpdate_ValidJSON verifies the output is always valid JSON. +func TestBuildChannelUpdate_ValidJSON(t *testing.T) { + msg := buildChannelUpdate(sampleChannel()) + if !json.Valid(msg) { + t.Errorf("buildChannelUpdate output is not valid JSON: %s", msg) + } +} + +// TestBuildChannelDelete_ValidJSON verifies the output is always valid JSON. +func TestBuildChannelDelete_ValidJSON(t *testing.T) { + msg := buildChannelDelete(1) + if !json.Valid(msg) { + t.Errorf("buildChannelDelete output is not valid JSON: %s", msg) + } +} + +// ─── buildAuthError ─────────────────────────────────────────────────────────── + +func TestBuildAuthError_Type(t *testing.T) { + msg := buildAuthError("invalid token") + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "auth_error" { + t.Errorf("type = %q, want auth_error", env.Type) + } +} + +func TestBuildAuthError_Payload(t *testing.T) { + msg := buildAuthError("session expired") + var env struct { + Payload struct { + Message string `json:"message"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.Message != "session expired" { + t.Errorf("payload.message = %q, want session expired", env.Payload.Message) + } +} + +func TestBuildAuthError_ValidJSON(t *testing.T) { + msg := buildAuthError("bad token") + if !json.Valid(msg) { + t.Errorf("buildAuthError output is not valid JSON: %s", msg) + } +} + +// ─── buildMemberJoin ────────────────────────────────────────────────────────── + +func TestBuildMemberJoin_Type(t *testing.T) { + user := &db.User{ID: 1, Username: "alice"} + msg := buildMemberJoin(user, "member") + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "member_join" { + t.Errorf("type = %q, want member_join", env.Type) + } +} + +func TestBuildMemberJoin_Payload(t *testing.T) { + user := &db.User{ID: 42, Username: "alice"} + msg := buildMemberJoin(user, "admin") + var env struct { + Payload struct { + User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + } `json:"user"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + u := env.Payload.User + if u.ID != 42 { + t.Errorf("user.id = %d, want 42", u.ID) + } + if u.Username != "alice" { + t.Errorf("user.username = %q, want alice", u.Username) + } + if u.Role != "admin" { + t.Errorf("user.role = %q, want admin", u.Role) + } +} + +func TestBuildMemberJoin_NilAvatar(t *testing.T) { + user := &db.User{ID: 1, Username: "noavatar", Avatar: nil} + msg := buildMemberJoin(user, "member") + var env struct { + Payload struct { + User struct { + Avatar any `json:"avatar"` + } `json:"user"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.User.Avatar != nil { + t.Errorf("avatar = %v, want nil for nil avatar", env.Payload.User.Avatar) + } +} + +func TestBuildMemberJoin_NonNilAvatar(t *testing.T) { + avatarURL := "https://example.com/avatar.png" + user := &db.User{ID: 1, Username: "withavatar", Avatar: &avatarURL} + msg := buildMemberJoin(user, "member") + var env struct { + Payload struct { + User struct { + Avatar string `json:"avatar"` + } `json:"user"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.User.Avatar != avatarURL { + t.Errorf("avatar = %q, want %q", env.Payload.User.Avatar, avatarURL) + } +} + +// ─── buildMemberUpdate ──────────────────────────────────────────────────────── + +func TestBuildMemberUpdate_Type(t *testing.T) { + msg := buildMemberUpdate(7, "moderator") + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "member_update" { + t.Errorf("type = %q, want member_update", env.Type) + } +} + +func TestBuildMemberUpdate_Payload(t *testing.T) { + msg := buildMemberUpdate(7, "moderator") + var env struct { + Payload struct { + UserID int64 `json:"user_id"` + Role string `json:"role"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.UserID != 7 { + t.Errorf("payload.user_id = %d, want 7", env.Payload.UserID) + } + if env.Payload.Role != "moderator" { + t.Errorf("payload.role = %q, want moderator", env.Payload.Role) + } +} + +// ─── buildMemberBan ─────────────────────────────────────────────────────────── + +func TestBuildMemberBan_Type(t *testing.T) { + msg := buildMemberBan(55) + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "member_ban" { + t.Errorf("type = %q, want member_ban", env.Type) + } +} + +func TestBuildMemberBan_Payload(t *testing.T) { + msg := buildMemberBan(55) + var env struct { + Payload struct { + UserID int64 `json:"user_id"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.UserID != 55 { + t.Errorf("payload.user_id = %d, want 55", env.Payload.UserID) + } +} + +func TestBuildMemberBan_ValidJSON(t *testing.T) { + if !json.Valid(buildMemberBan(1)) { + t.Error("buildMemberBan output is not valid JSON") + } +} + +// ─── buildChatEdited ────────────────────────────────────────────────────────── + +func TestBuildChatEdited_Type(t *testing.T) { + msg := buildChatEdited(10, 20, "new content", "2024-01-01T00:00:00Z") + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "chat_edited" { + t.Errorf("type = %q, want chat_edited", env.Type) + } +} + +func TestBuildChatEdited_Payload(t *testing.T) { + msg := buildChatEdited(10, 20, "new content", "2024-01-01T00:00:00Z") + var env struct { + Payload struct { + MessageID int64 `json:"message_id"` + ChannelID int64 `json:"channel_id"` + Content string `json:"content"` + EditedAt string `json:"edited_at"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + p := env.Payload + if p.MessageID != 10 { + t.Errorf("payload.message_id = %d, want 10", p.MessageID) + } + if p.ChannelID != 20 { + t.Errorf("payload.channel_id = %d, want 20", p.ChannelID) + } + if p.Content != "new content" { + t.Errorf("payload.content = %q, want new content", p.Content) + } + if p.EditedAt != "2024-01-01T00:00:00Z" { + t.Errorf("payload.edited_at = %q, want 2024-01-01T00:00:00Z", p.EditedAt) + } +} + +// ─── buildChatDeleted ───────────────────────────────────────────────────────── + +func TestBuildChatDeleted_Type(t *testing.T) { + msg := buildChatDeleted(11, 22) + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "chat_deleted" { + t.Errorf("type = %q, want chat_deleted", env.Type) + } +} + +func TestBuildChatDeleted_Payload(t *testing.T) { + msg := buildChatDeleted(11, 22) + var env struct { + Payload struct { + MessageID int64 `json:"message_id"` + ChannelID int64 `json:"channel_id"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.MessageID != 11 { + t.Errorf("payload.message_id = %d, want 11", env.Payload.MessageID) + } + if env.Payload.ChannelID != 22 { + t.Errorf("payload.channel_id = %d, want 22", env.Payload.ChannelID) + } +} + +func TestBuildChatDeleted_ValidJSON(t *testing.T) { + if !json.Valid(buildChatDeleted(1, 2)) { + t.Error("buildChatDeleted output is not valid JSON") + } +} + +// ─── buildReactionUpdate ────────────────────────────────────────────────────── + +func TestBuildReactionUpdate_Type(t *testing.T) { + msg := buildReactionUpdate(1, 2, 3, "👍", "add") + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "reaction_update" { + t.Errorf("type = %q, want reaction_update", env.Type) + } +} + +func TestBuildReactionUpdate_Payload(t *testing.T) { + msg := buildReactionUpdate(100, 200, 300, "❤️", "remove") + var env struct { + Payload struct { + MessageID int64 `json:"message_id"` + ChannelID int64 `json:"channel_id"` + UserID int64 `json:"user_id"` + Emoji string `json:"emoji"` + Action string `json:"action"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + p := env.Payload + if p.MessageID != 100 { + t.Errorf("payload.message_id = %d, want 100", p.MessageID) + } + if p.ChannelID != 200 { + t.Errorf("payload.channel_id = %d, want 200", p.ChannelID) + } + if p.UserID != 300 { + t.Errorf("payload.user_id = %d, want 300", p.UserID) + } + if p.Emoji != "❤️" { + t.Errorf("payload.emoji = %q, want ❤️", p.Emoji) + } + if p.Action != "remove" { + t.Errorf("payload.action = %q, want remove", p.Action) + } +} + +func TestBuildReactionUpdate_ValidJSON(t *testing.T) { + if !json.Valid(buildReactionUpdate(1, 2, 3, "😀", "add")) { + t.Error("buildReactionUpdate output is not valid JSON") + } +} + +// ─── buildTypingMsg ─────────────────────────────────────────────────────────── + +func TestBuildTypingMsg_Type(t *testing.T) { + msg := buildTypingMsg(5, 10, "alice") + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "typing" { + t.Errorf("type = %q, want typing", env.Type) + } +} + +func TestBuildTypingMsg_Payload(t *testing.T) { + msg := buildTypingMsg(5, 10, "alice") + var env struct { + Payload struct { + ChannelID int64 `json:"channel_id"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + p := env.Payload + if p.ChannelID != 5 { + t.Errorf("payload.channel_id = %d, want 5", p.ChannelID) + } + if p.UserID != 10 { + t.Errorf("payload.user_id = %d, want 10", p.UserID) + } + if p.Username != "alice" { + t.Errorf("payload.username = %q, want alice", p.Username) + } +} + +func TestBuildTypingMsg_ValidJSON(t *testing.T) { + if !json.Valid(buildTypingMsg(1, 2, "user")) { + t.Error("buildTypingMsg output is not valid JSON") + } +} + +// ─── buildVoiceAnswer ───────────────────────────────────────────────────────── + +func TestBuildVoiceAnswer_Type(t *testing.T) { + msg := buildVoiceAnswer(99, "v=0\r\n") + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "voice_answer" { + t.Errorf("type = %q, want voice_answer", env.Type) + } +} + +func TestBuildVoiceAnswer_Payload(t *testing.T) { + sdp := "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n" + msg := buildVoiceAnswer(99, sdp) + var env struct { + Payload struct { + ChannelID int64 `json:"channel_id"` + SDP string `json:"sdp"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.ChannelID != 99 { + t.Errorf("payload.channel_id = %d, want 99", env.Payload.ChannelID) + } + if env.Payload.SDP != sdp { + t.Errorf("payload.sdp = %q, want %q", env.Payload.SDP, sdp) + } +} + +func TestBuildVoiceAnswer_ValidJSON(t *testing.T) { + if !json.Valid(buildVoiceAnswer(1, "sdp-data")) { + t.Error("buildVoiceAnswer output is not valid JSON") + } +} diff --git a/Server/ws/messages_voice_test.go b/Server/ws/messages_voice_test.go new file mode 100644 index 00000000..d0e58be5 --- /dev/null +++ b/Server/ws/messages_voice_test.go @@ -0,0 +1,50 @@ +package ws_test + +import ( + "encoding/json" + "testing" + + ws "github.com/owncord/server/ws" +) + +func TestBuildVoiceOffer(t *testing.T) { + msg := ws.BuildVoiceOfferForTest(10, "v=0\r\noffer-sdp") + var m map[string]any + if err := json.Unmarshal(msg, &m); err != nil { + t.Fatal(err) + } + if m["type"] != "voice_offer" { + t.Errorf("type = %v, want voice_offer", m["type"]) + } + p := m["payload"].(map[string]any) + if p["channel_id"] != float64(10) { + t.Errorf("channel_id = %v, want 10", p["channel_id"]) + } + if p["sdp"] != "v=0\r\noffer-sdp" { + t.Errorf("sdp = %v", p["sdp"]) + } +} + +func TestBuildVoiceICE(t *testing.T) { + candidate := map[string]any{ + "candidate": "candidate:1 1 UDP 2130706431 ...", + "sdpMid": "0", + "sdpMLineIndex": float64(0), + } + msg := ws.BuildVoiceICEForTest(10, candidate) + var m map[string]any + if err := json.Unmarshal(msg, &m); err != nil { + t.Fatal(err) + } + if m["type"] != "voice_ice" { + t.Errorf("type = %v, want voice_ice", m["type"]) + } + p := m["payload"].(map[string]any) + if p["channel_id"] != float64(10) { + t.Errorf("channel_id = %v, want 10", p["channel_id"]) + } + c := p["candidate"].(map[string]any) + if c["sdpMid"] != "0" { + t.Errorf("candidate.sdpMid = %v, want 0", c["sdpMid"]) + } +} diff --git a/Server/ws/origin.go b/Server/ws/origin.go new file mode 100644 index 00000000..0ff130cd --- /dev/null +++ b/Server/ws/origin.go @@ -0,0 +1,29 @@ +package ws + +import "nhooyr.io/websocket" + +// OriginAcceptOptions builds a *websocket.AcceptOptions that enforces origin +// checking according to the provided allowed-origins list. +// +// Rules: +// - nil or empty list → InsecureSkipVerify = true (same as the old default) +// - list contains "*" → InsecureSkipVerify = true (explicit opt-in) +// - any other list → OriginPatterns set to the list; origin checking active +// +// The wildcard cases preserve backward compatibility: if a deployment has not +// set allowed_origins the server continues to work exactly as before. +func OriginAcceptOptions(allowedOrigins []string) *websocket.AcceptOptions { + if len(allowedOrigins) == 0 { + return &websocket.AcceptOptions{InsecureSkipVerify: true} + } + + for _, o := range allowedOrigins { + if o == "*" { + return &websocket.AcceptOptions{InsecureSkipVerify: true} + } + } + + return &websocket.AcceptOptions{ + OriginPatterns: allowedOrigins, + } +} diff --git a/Server/ws/origin_test.go b/Server/ws/origin_test.go new file mode 100644 index 00000000..521518bc --- /dev/null +++ b/Server/ws/origin_test.go @@ -0,0 +1,71 @@ +package ws_test + +import ( + "testing" + + "github.com/owncord/server/ws" +) + +// TestOriginAcceptOptions_WildcardEnablesInsecureSkipVerify verifies that +// when the allowed origins list contains only "*", InsecureSkipVerify is true +// (preserving the previous opt-in permissive behaviour). +func TestOriginAcceptOptions_WildcardEnablesInsecureSkipVerify(t *testing.T) { + opts := ws.OriginAcceptOptions([]string{"*"}) + if !opts.InsecureSkipVerify { + t.Error("OriginAcceptOptions([\"*\"]).InsecureSkipVerify = false, want true") + } + if len(opts.OriginPatterns) != 0 { + t.Errorf("OriginAcceptOptions([\"*\"]).OriginPatterns = %v, want empty", opts.OriginPatterns) + } +} + +// TestOriginAcceptOptions_ExplicitOrigins sets OriginPatterns and does NOT +// skip origin verification. +func TestOriginAcceptOptions_ExplicitOrigins(t *testing.T) { + origins := []string{"https://example.com", "https://app.example.com"} + opts := ws.OriginAcceptOptions(origins) + + if opts.InsecureSkipVerify { + t.Error("OriginAcceptOptions(explicit).InsecureSkipVerify = true, want false") + } + if len(opts.OriginPatterns) != 2 { + t.Errorf("OriginAcceptOptions(explicit) len(OriginPatterns) = %d, want 2", len(opts.OriginPatterns)) + } + for i, p := range opts.OriginPatterns { + if p != origins[i] { + t.Errorf("OriginPatterns[%d] = %q, want %q", i, p, origins[i]) + } + } +} + +// TestOriginAcceptOptions_EmptyList falls back to wildcard (InsecureSkipVerify) +// so that an empty configuration doesn't silently reject all connections. +func TestOriginAcceptOptions_EmptyList(t *testing.T) { + opts := ws.OriginAcceptOptions([]string{}) + if !opts.InsecureSkipVerify { + t.Error("OriginAcceptOptions([]) should fall back to InsecureSkipVerify=true") + } +} + +// TestOriginAcceptOptions_NilList same as empty. +func TestOriginAcceptOptions_NilList(t *testing.T) { + opts := ws.OriginAcceptOptions(nil) + if !opts.InsecureSkipVerify { + t.Error("OriginAcceptOptions(nil) should fall back to InsecureSkipVerify=true") + } +} + +// TestOriginAcceptOptions_MixedWithWildcard if "*" appears anywhere in the +// list we treat the whole list as wildcard (security: explicit wins over forged mix). +func TestOriginAcceptOptions_MixedWithWildcard(t *testing.T) { + opts := ws.OriginAcceptOptions([]string{"https://example.com", "*"}) + if !opts.InsecureSkipVerify { + t.Error("OriginAcceptOptions with '*' in list should use InsecureSkipVerify=true") + } +} + +// TestOriginAcceptOptions_ReturnsAcceptOptions ensures the return type is the +// correct websocket.AcceptOptions value (compile-time check via assignment). +func TestOriginAcceptOptions_ReturnsAcceptOptions(t *testing.T) { + _ = ws.OriginAcceptOptions([]string{"https://example.com"}) +} diff --git a/Server/ws/renegotiation_test.go b/Server/ws/renegotiation_test.go new file mode 100644 index 00000000..ce3fd67f --- /dev/null +++ b/Server/ws/renegotiation_test.go @@ -0,0 +1,455 @@ +package ws + +import ( + "encoding/json" + "testing" + "testing/fstest" + "time" + + "github.com/pion/webrtc/v4" + + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// renegTestSchema is a minimal schema for renegotiation tests. +var renegTestSchema = []byte(` +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT, + permissions INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES + (1, 'Owner', '#E74C3C', 2147483647, 100, 0), + (4, 'Member', NULL, 1635, 40, 1); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + avatar TEXT, + role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, + status TEXT NOT NULL DEFAULT 'offline', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS channel_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + UNIQUE(channel_id, role_id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, + edited_at TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +INSERT OR IGNORE INTO settings (key, value) VALUES ('server_name', 'Test Server'); +INSERT OR IGNORE INTO settings (key, value) VALUES ('motd', 'Welcome'); +`) + +// newRenegTestDB opens an in-memory DB with the renegotiation test schema. +func newRenegTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: renegTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// newRenegHub creates a hub suitable for renegotiation tests. +func newRenegHub(t *testing.T) (*Hub, *db.DB) { + t.Helper() + database := newRenegTestDB(t) + limiter := auth.NewRateLimiter() + hub := NewHub(database, limiter) + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + return hub, database +} + +// newTestSFU creates an SFU for tests with a small port range. +func newTestSFU(t *testing.T) *SFU { + t.Helper() + cfg := &config.VoiceConfig{ + Quality: "medium", + MediaPortMin: 50000, + MediaPortMax: 50100, + } + sfu, err := NewSFU(cfg) + if err != nil { + t.Fatalf("NewSFU: %v", err) + } + t.Cleanup(func() { sfu.Close() }) + return sfu +} + +// seedRenegUser inserts an Owner-role user for renegotiation tests. +func seedRenegUser(t *testing.T, database *db.DB, username string) *db.User { + t.Helper() + _, err := database.CreateUser(username, "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + return user +} + +// TestRenegotiateParticipant_SkipsHaveRemoteOffer verifies that when the +// PeerConnection is in have-remote-offer state, renegotiateParticipant +// returns early without creating a new offer. +func TestRenegotiateParticipant_SkipsHaveRemoteOffer(t *testing.T) { + hub, database := newRenegHub(t) + sfu := newTestSFU(t) + user := seedRenegUser(t, database, "skip-remote-offer") + + send := make(chan []byte, 32) + c := NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Create a server-side PC via the SFU. + serverPC, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection: %v", err) + } + t.Cleanup(func() { _ = serverPC.Close() }) + + // Create a client-side PC to generate a valid offer. + clientPC, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatalf("NewPeerConnection (client): %v", err) + } + t.Cleanup(func() { _ = clientPC.Close() }) + + // Add a transceiver on the client so the offer has media. + _, err = clientPC.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendrecv, + }) + if err != nil { + t.Fatalf("AddTransceiverFromKind: %v", err) + } + + clientOffer, err := clientPC.CreateOffer(nil) + if err != nil { + t.Fatalf("CreateOffer (client): %v", err) + } + if err := clientPC.SetLocalDescription(clientOffer); err != nil { + t.Fatalf("SetLocalDescription (client): %v", err) + } + + // Set the client's offer as the server PC's remote description, + // putting it into have-remote-offer state. + if err := serverPC.SetRemoteDescription(clientOffer); err != nil { + t.Fatalf("SetRemoteDescription (server): %v", err) + } + + if serverPC.SignalingState() != webrtc.SignalingStateHaveRemoteOffer { + t.Fatalf("expected have-remote-offer, got %s", serverPC.SignalingState()) + } + + // Attach the server PC to the client. + c.setVoice(1, serverPC) + + // Drain any messages that were sent during setup. + drainSend(send) + + // Call renegotiateParticipant — it should skip (no offer sent). + hub.renegotiateParticipant(c) + time.Sleep(50 * time.Millisecond) + + // Verify no voice_offer was sent. + msgs := drainSend(send) + for _, msg := range msgs { + typ := extractMsgType(t, msg) + if typ == "voice_offer" { + t.Error("renegotiateParticipant should skip in have-remote-offer state, but sent a voice_offer") + } + } +} + +// TestRenegotiateParticipant_RollsBackHaveLocalOffer verifies that when the +// PeerConnection already has a pending local offer, renegotiateParticipant +// attempts a rollback. Pion v4 does not support SDPTypeRollback, so the +// rollback fails and the function returns early without sending a new offer. +// This test documents the current behavior and ensures graceful handling. +func TestRenegotiateParticipant_RollsBackHaveLocalOffer(t *testing.T) { + hub, database := newRenegHub(t) + sfu := newTestSFU(t) + user := seedRenegUser(t, database, "rollback-local-offer") + + send := make(chan []byte, 32) + c := NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Create a server-side PC. + serverPC, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection: %v", err) + } + t.Cleanup(func() { _ = serverPC.Close() }) + + // Add a transceiver so offers contain media. + _, err = serverPC.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendrecv, + }) + if err != nil { + t.Fatalf("AddTransceiverFromKind: %v", err) + } + + // Put the server PC into have-local-offer state by creating and setting + // an offer manually. + initialOffer, err := serverPC.CreateOffer(nil) + if err != nil { + t.Fatalf("CreateOffer: %v", err) + } + if err := serverPC.SetLocalDescription(initialOffer); err != nil { + t.Fatalf("SetLocalDescription: %v", err) + } + if serverPC.SignalingState() != webrtc.SignalingStateHaveLocalOffer { + t.Fatalf("expected have-local-offer, got %s", serverPC.SignalingState()) + } + + // Attach the server PC to the client with a voice channel ID. + c.setVoice(1, serverPC) + + // Drain setup messages. + drainSend(send) + + // Call renegotiateParticipant — it attempts rollback which fails in Pion v4, + // so it returns early without sending a new offer. + hub.renegotiateParticipant(c) + time.Sleep(50 * time.Millisecond) + + // Verify no voice_offer was sent (rollback failed, function returned early). + msgs := drainSend(send) + for _, msg := range msgs { + typ := extractMsgType(t, msg) + if typ == "voice_offer" { + t.Error("renegotiateParticipant should return early when rollback fails, but sent a voice_offer") + } + } + + // The PC remains in have-local-offer since rollback failed. + if serverPC.SignalingState() != webrtc.SignalingStateHaveLocalOffer { + t.Errorf("expected have-local-offer (unchanged after failed rollback), got %s", serverPC.SignalingState()) + } +} + +// TestHandleVoiceOffer_RollsBackOnGlare verifies glare condition handling: +// when the server has a pending local offer and the client sends an offer +// simultaneously. The code attempts to rollback the server's offer before +// accepting the client's. Since Pion v4 does not support SDPTypeRollback, +// the rollback fails and handleVoiceOffer sends a VOICE_ERROR to the client. +// This test documents the current behavior and ensures graceful error handling. +func TestHandleVoiceOffer_RollsBackOnGlare(t *testing.T) { + hub, database := newRenegHub(t) + sfu := newTestSFU(t) + user := seedRenegUser(t, database, "glare-rollback") + + send := make(chan []byte, 32) + c := NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Create a server-side PC via the SFU. + serverPC, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection (server): %v", err) + } + t.Cleanup(func() { _ = serverPC.Close() }) + + // Create a client-side PC to generate a valid offer. + clientPC, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatalf("NewPeerConnection (client): %v", err) + } + t.Cleanup(func() { _ = clientPC.Close() }) + + // Add audio transceivers on both sides. + _, err = serverPC.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendrecv, + }) + if err != nil { + t.Fatalf("AddTransceiverFromKind (server): %v", err) + } + + _, err = clientPC.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendrecv, + }) + if err != nil { + t.Fatalf("AddTransceiverFromKind (client): %v", err) + } + + // Put the server PC into have-local-offer state (server sent an offer). + serverOffer, err := serverPC.CreateOffer(nil) + if err != nil { + t.Fatalf("CreateOffer (server): %v", err) + } + if err := serverPC.SetLocalDescription(serverOffer); err != nil { + t.Fatalf("SetLocalDescription (server): %v", err) + } + if serverPC.SignalingState() != webrtc.SignalingStateHaveLocalOffer { + t.Fatalf("expected server in have-local-offer, got %s", serverPC.SignalingState()) + } + + // Attach the server PC to the client. + chanID := int64(42) + c.setVoice(chanID, serverPC) + + // Generate a client offer (simulating the client also sending an offer). + clientOffer, err := clientPC.CreateOffer(nil) + if err != nil { + t.Fatalf("CreateOffer (client): %v", err) + } + if err := clientPC.SetLocalDescription(clientOffer); err != nil { + t.Fatalf("SetLocalDescription (client): %v", err) + } + + // Drain any messages from setup. + drainSend(send) + + // Build and dispatch the voice_offer payload as handleVoiceOffer expects. + payload, _ := json.Marshal(map[string]any{ + "channel_id": chanID, + "sdp": clientOffer.SDP, + }) + + hub.handleVoiceOffer(c, payload) + time.Sleep(50 * time.Millisecond) + + // Since Pion v4 does not support rollback, the glare path sends a + // VOICE_ERROR back to the client indicating the conflict could not + // be resolved. + msgs := drainSend(send) + foundError := false + for _, msg := range msgs { + typ := extractMsgType(t, msg) + if typ == "error" { + foundError = true + // Verify the error code is VOICE_ERROR (signaling conflict). + var env struct { + Payload struct { + Code string `json:"code"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("failed to parse error message: %v", err) + } + if env.Payload.Code != "VOICE_ERROR" { + t.Errorf("expected error code VOICE_ERROR, got %q", env.Payload.Code) + } + } + if typ == "voice_answer" { + t.Error("should not produce a voice_answer when rollback fails") + } + } + if !foundError { + t.Error("handleVoiceOffer should send a VOICE_ERROR when glare rollback fails, but no error was sent") + } + + // The server PC remains in have-local-offer since rollback failed. + if serverPC.SignalingState() != webrtc.SignalingStateHaveLocalOffer { + t.Errorf("expected have-local-offer (unchanged after failed rollback), got %s", serverPC.SignalingState()) + } +} + +// drainSend reads all pending messages from a channel. +func drainSend(ch chan []byte) [][]byte { + var msgs [][]byte + for { + select { + case m := <-ch: + msgs = append(msgs, m) + default: + return msgs + } + } +} + +// extractMsgType parses a JSON message and returns the "type" field. +func extractMsgType(t *testing.T, msg []byte) string { + t.Helper() + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("extractMsgType unmarshal: %v", err) + } + typ, _ := env["type"].(string) + return typ +} diff --git a/Server/ws/rtp_audio_level.go b/Server/ws/rtp_audio_level.go new file mode 100644 index 00000000..f1677c63 --- /dev/null +++ b/Server/ws/rtp_audio_level.go @@ -0,0 +1,83 @@ +package ws + +import "encoding/binary" + +// audioLevelExtID is the RTP header extension ID for RFC 6464 audio level. +const audioLevelExtID = 1 + +// extractAudioLevel parses raw RTP bytes to extract the audio level from +// a one-byte header extension (profile 0xBEDE) with ID == audioLevelExtID. +// Returns the 7-bit level (0=loudest, 127=silence) and true if found. +// +// This avoids a full rtp.Packet.Unmarshal on every packet (~50 pps/user). +func extractAudioLevel(buf []byte, n int) (level byte, ok bool) { + if n < 12 { + return 0, false // too short for RTP fixed header + } + + // Check X bit (extension present) at byte 0, bit 4. + if buf[0]&0x10 == 0 { + return 0, false // no header extension + } + + // CC = CSRC count (lower 4 bits of byte 0). + cc := int(buf[0] & 0x0F) + extOffset := 12 + 4*cc // skip fixed header + CSRCs + + // Need at least 4 bytes for extension header (profile + length). + if n < extOffset+4 { + return 0, false + } + + // Extension profile must be 0xBEDE (one-byte header format). + profile := binary.BigEndian.Uint16(buf[extOffset:]) + if profile != 0xBEDE { + return 0, false + } + + // Extension length in 32-bit words. + extWords := int(binary.BigEndian.Uint16(buf[extOffset+2:])) + extDataStart := extOffset + 4 + extDataEnd := extDataStart + extWords*4 + + if n < extDataEnd { + return 0, false // extension data extends past packet + } + + // Walk one-byte header extension elements. + // Format: ID (4 bits) | L (4 bits) | data[L+1 bytes] + // ID=0 is padding, ID=15 terminates. + pos := extDataStart + for pos < extDataEnd { + b := buf[pos] + + // Padding byte. + if b == 0 { + pos++ + continue + } + + id := b >> 4 + dataLen := int(b&0x0F) + 1 + + // ID=15 means end of extensions. + if id == 15 { + break + } + + pos++ // advance past the ID|L byte + + if pos+dataLen > extDataEnd { + break // malformed: data extends past extension block + } + + if id == audioLevelExtID && dataLen >= 1 { + // RFC 6464: V(1 bit) + level(7 bits) + return buf[pos] & 0x7F, true + } + + pos += dataLen + } + + return 0, false +} diff --git a/Server/ws/rtp_audio_level_test.go b/Server/ws/rtp_audio_level_test.go new file mode 100644 index 00000000..131357b4 --- /dev/null +++ b/Server/ws/rtp_audio_level_test.go @@ -0,0 +1,150 @@ +package ws + +import ( + "encoding/binary" + "testing" +) + +// buildRTPPacket constructs a minimal RTP packet with optional one-byte +// header extensions. csrcCount specifies the number of dummy CSRCs. +func buildRTPPacket(csrcCount int, extensions []struct{ id, value byte }) []byte { + // Fixed header: V=2, P=0, X=(1 if extensions), CC=csrcCount + header := make([]byte, 12+4*csrcCount) + header[0] = 0x80 | byte(csrcCount) // V=2, CC + header[1] = 111 // PT (opus) + binary.BigEndian.PutUint16(header[2:], 1) // seq + binary.BigEndian.PutUint32(header[4:], 1000) // timestamp + binary.BigEndian.PutUint32(header[8:], 0xDEADBEEF) // SSRC + + // Fill dummy CSRCs. + for i := 0; i < csrcCount; i++ { + binary.BigEndian.PutUint32(header[12+4*i:], uint32(i+1)) + } + + if len(extensions) == 0 { + return header + } + + // Set X bit. + header[0] |= 0x10 + + // Build one-byte extension block. + // Each element: 1 byte (ID<<4 | L) + (L+1) data bytes. + // For simplicity each extension here has 1 byte of data (L=0). + var extData []byte + for _, ext := range extensions { + extData = append(extData, ext.id<<4) // ID | L=0 (1 byte data) + extData = append(extData, ext.value) + } + + // Pad to 32-bit boundary. + for len(extData)%4 != 0 { + extData = append(extData, 0x00) + } + + extWords := len(extData) / 4 + extHeader := make([]byte, 4) + binary.BigEndian.PutUint16(extHeader[0:], 0xBEDE) + binary.BigEndian.PutUint16(extHeader[2:], uint16(extWords)) + + pkt := make([]byte, 0, len(header)+len(extHeader)+len(extData)) + pkt = append(pkt, header...) + pkt = append(pkt, extHeader...) + pkt = append(pkt, extData...) + return pkt +} + +func TestExtractAudioLevel_Valid(t *testing.T) { + // Audio level 42 with V bit set (0x80 | 42 = 0xAA). + pkt := buildRTPPacket(0, []struct{ id, value byte }{{1, 0x80 | 42}}) + level, ok := extractAudioLevel(pkt, len(pkt)) + if !ok { + t.Fatal("expected ok=true for valid audio level extension") + } + if level != 42 { + t.Fatalf("expected level=42, got %d", level) + } +} + +func TestExtractAudioLevel_NoExtension(t *testing.T) { + // Packet without X bit (no extensions). + pkt := buildRTPPacket(0, nil) + _, ok := extractAudioLevel(pkt, len(pkt)) + if ok { + t.Fatal("expected ok=false for packet without extension") + } +} + +func TestExtractAudioLevel_WrongExtensionID(t *testing.T) { + // Extension with ID=5 instead of ID=1. + pkt := buildRTPPacket(0, []struct{ id, value byte }{{5, 0x80 | 10}}) + _, ok := extractAudioLevel(pkt, len(pkt)) + if ok { + t.Fatal("expected ok=false when extension ID does not match") + } +} + +func TestExtractAudioLevel_Truncated(t *testing.T) { + // Too short to even contain the fixed header. + _, ok := extractAudioLevel([]byte{0x90, 0x6F}, 2) + if ok { + t.Fatal("expected ok=false for truncated packet") + } + + // Has X bit but truncated before extension header. + pkt := buildRTPPacket(0, []struct{ id, value byte }{{1, 50}}) + _, ok = extractAudioLevel(pkt, 14) // cut off inside extension header + if ok { + t.Fatal("expected ok=false for packet truncated in extension header") + } +} + +func TestExtractAudioLevel_MultipleCSRCs(t *testing.T) { + // 3 CSRCs, valid audio level extension. + pkt := buildRTPPacket(3, []struct{ id, value byte }{{1, 0x80 | 99}}) + level, ok := extractAudioLevel(pkt, len(pkt)) + if !ok { + t.Fatal("expected ok=true with CSRCs present") + } + if level != 99 { + t.Fatalf("expected level=99, got %d", level) + } +} + +func TestExtractAudioLevel_MultipleExtensions(t *testing.T) { + // Extension ID=3 first, then ID=1 (audio level). + pkt := buildRTPPacket(0, []struct{ id, value byte }{ + {3, 0xFF}, + {1, 0x80 | 17}, + }) + level, ok := extractAudioLevel(pkt, len(pkt)) + if !ok { + t.Fatal("expected ok=true when audio level is second extension") + } + if level != 17 { + t.Fatalf("expected level=17, got %d", level) + } +} + +func TestExtractAudioLevel_VBitStripped(t *testing.T) { + // Audio level 0 with V bit set — should return 0. + pkt := buildRTPPacket(0, []struct{ id, value byte }{{1, 0x80}}) + level, ok := extractAudioLevel(pkt, len(pkt)) + if !ok { + t.Fatal("expected ok=true") + } + if level != 0 { + t.Fatalf("expected level=0, got %d", level) + } +} + +func TestExtractAudioLevel_NonBEDEProfile(t *testing.T) { + // Manually construct a packet with X bit but non-0xBEDE profile. + pkt := buildRTPPacket(0, []struct{ id, value byte }{{1, 50}}) + // Overwrite the profile field (bytes 12-13) with something else. + binary.BigEndian.PutUint16(pkt[12:], 0x1000) + _, ok := extractAudioLevel(pkt, len(pkt)) + if ok { + t.Fatal("expected ok=false for non-BEDE profile") + } +} diff --git a/Server/ws/serve.go b/Server/ws/serve.go new file mode 100644 index 00000000..a724408a --- /dev/null +++ b/Server/ws/serve.go @@ -0,0 +1,297 @@ +package ws + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "nhooyr.io/websocket" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +const authDeadline = 10 * time.Second +const writeTimeout = 10 * time.Second +const settingsCacheTTL = 30 * time.Second + +// ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth, +// then drives the client's read/write loops. +// Do not wrap with AuthMiddleware — WS does its own auth. +// +// allowedOrigins controls which HTTP origins may open a WebSocket connection. +// Pass nil or []string{"*"} to allow all origins (insecure, for development). +// Pass explicit origins such as []string{"https://example.com"} to restrict access. +func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFunc { + acceptOpts := OriginAcceptOptions(allowedOrigins) + return func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, acceptOpts) + if err != nil { + slog.Warn("ws upgrade failed", "err", err) + return + } + conn.SetReadLimit(1 << 20) // 1 MB — match client-side limit + + user, tokenHash, err := authenticateConn(conn, database) + if err != nil { + slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr) + _ = conn.Close(websocket.StatusPolicyViolation, "authentication failed") + return + } + + // Reject duplicate connections — prevent ping-pong reconnect loops. + if hub.IsUserConnected(user.ID) { + slog.Warn("ws duplicate login rejected", "user_id", user.ID, "remote", r.RemoteAddr) + ctx := r.Context() + _ = conn.Write(ctx, websocket.MessageText, buildAuthError("already connected from another client")) + _ = conn.Close(websocket.StatusPolicyViolation, "already connected") + return + } + + c := newClient(hub, conn, user, tokenHash) + hub.Register(c) + + // Look up role name for protocol-compliant payloads and cache on client. + roleName := "member" + if role, roleErr := database.GetRoleByID(user.RoleID); roleErr == nil && role != nil { + roleName = strings.ToLower(role.Name) + } + c.roleName = roleName + + slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr) + _ = database.LogAudit(user.ID, "ws_connect", "user", user.ID, + "WebSocket connected from "+r.RemoteAddr) + + if updateErr := database.UpdateUserStatus(user.ID, "online"); updateErr != nil { + slog.Warn("ws UpdateUserStatus", "err", updateErr) + } + + // Send auth_ok followed by the ready payload. + ctx := r.Context() + slog.Info("ws sending auth_ok", "user_id", user.ID, "username", user.Username, "role", roleName) + _ = conn.Write(ctx, websocket.MessageText, hub.buildAuthOK(user, roleName)) + if ready, readyErr := hub.buildReady(database, user.ID); readyErr == nil { + slog.Info("ws sending ready payload", "user_id", user.ID, "payload_bytes", len(ready)) + _ = conn.Write(ctx, websocket.MessageText, ready) + } else { + slog.Error("buildReady failed", "user_id", user.ID, "err", readyErr) + _ = conn.Write(ctx, websocket.MessageText, + buildErrorMsg("INTERNAL", "failed to build ready payload")) + } + + slog.Info("ws broadcasting member_join and presence", "user_id", user.ID, "username", user.Username) + hub.BroadcastToAll(buildMemberJoin(user, roleName)) + hub.BroadcastToAll(buildPresenceMsg(user.ID, "online")) + + // writePump runs in background; readPump blocks. + writeCtx, writeCancel := context.WithCancel(ctx) + go writePump(writeCtx, conn, c) + readPump(ctx, conn, hub, c) + writeCancel() + } +} + +// writePump drains the client's send channel and writes to the WebSocket. +func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { + for { + select { + case msg, ok := <-c.send: + if !ok { + _ = conn.Close(websocket.StatusNormalClosure, "") + return + } + wCtx, cancel := context.WithTimeout(ctx, writeTimeout) + err := conn.Write(wCtx, websocket.MessageText, msg) + cancel() + if err != nil { + slog.Warn("ws writePump error", "user_id", c.userID, "err", err) + return + } + case <-ctx.Done(): + return + } + } +} + +// readPump reads from the WebSocket and dispatches messages. Blocks until disconnect. +func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { + defer func() { + hub.Unregister(c) + hub.handleVoiceLeave(c) + if c.user != nil { + slog.Info("websocket disconnected", "username", c.user.Username, "user_id", c.userID) + _ = hub.db.UpdateUserStatus(c.userID, "offline") + hub.BroadcastToAll(buildPresenceMsg(c.userID, "offline")) + } + }() + + for { + _, msg, err := conn.Read(ctx) + if err != nil { + return + } + hub.handleMessage(c, msg) + } +} + +// authenticateConn reads the first WebSocket message and validates the session +// token. Returns the authenticated user and the token hash (for later +// periodic session revalidation). +func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), authDeadline) + defer cancel() + + _, raw, err := conn.Read(ctx) + if err != nil { + return nil, "", err + } + + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "invalid message")) + return nil, "", fmt.Errorf("auth: invalid JSON: %w", err) + } + if env.Type != "auth" { + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "first message must be auth")) + return nil, "", fmt.Errorf("auth: unexpected type %q", env.Type) + } + + var p struct { + Token string `json:"token"` + } + if err := json.Unmarshal(env.Payload, &p); err != nil || p.Token == "" { + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "missing token")) + return nil, "", fmt.Errorf("auth: missing token") + } + + hash := auth.HashToken(p.Token) + sess, err := database.GetSessionByTokenHash(hash) + if err != nil || sess == nil { + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "invalid token")) + return nil, "", fmt.Errorf("auth: invalid session") + } + + if auth.IsSessionExpired(sess.ExpiresAt) { + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "session expired")) + return nil, "", fmt.Errorf("auth: session expired") + } + + user, err := database.GetUserByID(sess.UserID) + if err != nil || user == nil { + _ = conn.Write(ctx, websocket.MessageText, buildAuthError( "user not found")) + return nil, "", fmt.Errorf("auth: user not found") + } + + if auth.IsEffectivelyBanned(user) { + _ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("BANNED", "you are banned")) + return nil, "", fmt.Errorf("auth: banned user %d", user.ID) + } + + return user, hash, nil +} + +// buildAuthOK constructs the auth_ok server→client message. +// Per PROTOCOL.md, user object contains only id, username, avatar, role (no status). +func (h *Hub) buildAuthOK(user *db.User, roleName string) []byte { + var avatarVal any + if user.Avatar != nil { + avatarVal = *user.Avatar + } + + serverName, motd := h.getCachedSettings() + + return buildJSON(map[string]any{ + "type": "auth_ok", + "payload": map[string]any{ + "user": map[string]any{ + "id": user.ID, + "username": user.Username, + "avatar": avatarVal, + "role": roleName, + }, + "server_name": serverName, + "motd": motd, + }, + }) +} + +// buildReady constructs the ready server→client message. +// Per PROTOCOL.md, channels include unread_count and last_message_id per user, +// and only protocol-specified fields (no slow_mode, archived, voice_* extras). +func (h *Hub) buildReady(database *db.DB, userID int64) ([]byte, error) { + channels, err := database.ListChannels() + if err != nil { + return nil, fmt.Errorf("buildReady ListChannels: %w", err) + } + roles, err := database.ListRoles() + if err != nil { + return nil, fmt.Errorf("buildReady ListRoles: %w", err) + } + + members, err := database.ListMembers() + if err != nil { + slog.Warn("buildReady ListMembers", "err", err) + members = []db.MemberSummary{} + } + + // Per-user unread counts. + unreadMap, err := database.GetChannelUnreadCounts(userID) + if err != nil { + slog.Warn("buildReady GetChannelUnreadCounts", "err", err) + unreadMap = map[int64]db.ChannelUnread{} + } + + // Build protocol-compliant channel objects (strip extra fields). + channelPayloads := make([]map[string]any, 0, len(channels)) + for _, ch := range channels { + entry := map[string]any{ + "id": ch.ID, + "name": ch.Name, + "type": ch.Type, + "category": ch.Category, + "position": ch.Position, + } + if ch.Type == "text" { + if u, ok := unreadMap[ch.ID]; ok { + entry["unread_count"] = u.UnreadCount + entry["last_message_id"] = u.LastMessageID + } else { + entry["unread_count"] = 0 + entry["last_message_id"] = 0 + } + } + channelPayloads = append(channelPayloads, entry) + } + + // Collect all active voice states across every voice channel. + voiceStates, err := collectAllVoiceStates(database, channels) + if err != nil { + // Non-fatal: send empty list rather than failing the whole ready payload. + slog.Warn("buildReady collectAllVoiceStates", "err", err) + voiceStates = []db.VoiceState{} + } + + serverName, motd := h.getCachedSettings() + + return buildJSON(map[string]any{ + "type": "ready", + "payload": map[string]any{ + "channels": channelPayloads, + "members": members, + "voice_states": voiceStates, + "roles": roles, + "server_name": serverName, + "motd": motd, + }, + }), nil +} + +// collectAllVoiceStates gathers voice states across all channels in a single +// query, replacing the previous N+1 per-channel pattern. +func collectAllVoiceStates(database *db.DB, _ []db.Channel) ([]db.VoiceState, error) { + return database.GetAllVoiceStates() +} diff --git a/Server/ws/serve_test.go b/Server/ws/serve_test.go new file mode 100644 index 00000000..07a7b36d --- /dev/null +++ b/Server/ws/serve_test.go @@ -0,0 +1,827 @@ +package ws_test + +import ( + "encoding/json" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/ws" +) + +// ─── schema used by serve tests ─────────────────────────────────────────────── + +// serveTestSchema extends hubTestSchema with voice_states so that +// collectAllVoiceStates can be exercised via buildReady. +var serveTestSchema = append(hubTestSchema, []byte(` +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel_serve ON voice_states(channel_id); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT '', + target_id INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +`)...) + +func openServeTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: serveTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +func newServeHub(t *testing.T) (*ws.Hub, *db.DB) { + t.Helper() + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + return hub, database +} + +// seedServeUser inserts an Owner-role user and returns the full *db.User. +func seedServeUser(t *testing.T, database *db.DB, username string) *db.User { + t.Helper() + _, err := database.CreateUser(username, "hash", 1) + if err != nil { + t.Fatalf("seedServeUser: %v", err) + } + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + t.Fatalf("seedServeUser GetUserByUsername: %v", err) + } + return user +} + +// ─── buildAuthOK ───────────────────────────────────────────────────────────── + +func TestBuildAuthOK_Type(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "authok-user1") + + msg := hub.BuildAuthOKForTest(user, "admin") + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "auth_ok" { + t.Errorf("type = %q, want auth_ok", env.Type) + } +} + +func TestBuildAuthOK_UserPayload(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "authok-user2") + + msg := hub.BuildAuthOKForTest(user, "member") + var env struct { + Payload struct { + User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + } `json:"user"` + ServerName string `json:"server_name"` + MOTD string `json:"motd"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.User.ID != user.ID { + t.Errorf("payload.user.id = %d, want %d", env.Payload.User.ID, user.ID) + } + if env.Payload.User.Username != user.Username { + t.Errorf("payload.user.username = %q, want %q", env.Payload.User.Username, user.Username) + } + if env.Payload.User.Role != "member" { + t.Errorf("payload.user.role = %q, want member", env.Payload.User.Role) + } +} + +func TestBuildAuthOK_ContainsServerName(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "authok-user3") + + msg := hub.BuildAuthOKForTest(user, "owner") + var env struct { + Payload struct { + ServerName string `json:"server_name"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // server_name is seeded from settings table; must be non-empty. + if env.Payload.ServerName == "" { + t.Error("payload.server_name must not be empty") + } +} + +func TestBuildAuthOK_NilAvatar(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "authok-noavatar") + // Avatar is nil by default after insert. + + msg := hub.BuildAuthOKForTest(user, "member") + var env struct { + Payload struct { + User struct { + Avatar any `json:"avatar"` + } `json:"user"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.User.Avatar != nil { + t.Errorf("payload.user.avatar = %v, want nil", env.Payload.User.Avatar) + } +} + +func TestBuildAuthOK_ValidJSON(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "authok-validjson") + msg := hub.BuildAuthOKForTest(user, "member") + if !json.Valid(msg) { + t.Errorf("buildAuthOK output is not valid JSON: %s", msg) + } +} + +// ─── buildReady ─────────────────────────────────────────────────────────────── + +func TestBuildReady_Type(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "ready-user1") + + msg, err := hub.BuildReadyForTest(database, user.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "ready" { + t.Errorf("type = %q, want ready", env.Type) + } +} + +func TestBuildReady_ContainsRequiredFields(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "ready-user2") + + msg, err := hub.BuildReadyForTest(database, user.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + + var env struct { + Payload struct { + Channels []any `json:"channels"` + Members []any `json:"members"` + VoiceStates []any `json:"voice_states"` + Roles []any `json:"roles"` + ServerName string `json:"server_name"` + MOTD string `json:"motd"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // channels, members, voice_states, roles must all be present (even if empty slices). + if env.Payload.Channels == nil { + t.Error("payload.channels must not be nil") + } + if env.Payload.Members == nil { + t.Error("payload.members must not be nil") + } + if env.Payload.VoiceStates == nil { + t.Error("payload.voice_states must not be nil") + } + if env.Payload.Roles == nil { + t.Error("payload.roles must not be nil") + } + if env.Payload.ServerName == "" { + t.Error("payload.server_name must not be empty") + } +} + +func TestBuildReady_IncludesSeededChannel(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "ready-user3") + + // Seed a text channel. + chID, err := database.CreateChannel("general", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + msg, err := hub.BuildReadyForTest(database, user.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + + var env struct { + Payload struct { + Channels []struct { + ID float64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + } `json:"channels"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + found := false + for _, ch := range env.Payload.Channels { + if int64(ch.ID) == chID && ch.Name == "general" && ch.Type == "text" { + found = true + break + } + } + if !found { + t.Errorf("ready payload does not include seeded channel (id=%d)", chID) + } +} + +func TestBuildReady_TextChannelHasUnreadCount(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "ready-user4") + + _, err := database.CreateChannel("unread-chan", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + msg, err := hub.BuildReadyForTest(database, user.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + + var env struct { + Payload struct { + Channels []map[string]any `json:"channels"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + for _, ch := range env.Payload.Channels { + if ch["type"] == "text" { + if _, ok := ch["unread_count"]; !ok { + t.Error("text channel missing unread_count field") + } + if _, ok := ch["last_message_id"]; !ok { + t.Error("text channel missing last_message_id field") + } + } + } +} + +func TestBuildReady_ValidJSON(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "ready-validjson") + msg, err := hub.BuildReadyForTest(database, user.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + if !json.Valid(msg) { + t.Errorf("buildReady output is not valid JSON: %s", msg) + } +} + +// ─── collectAllVoiceStates ──────────────────────────────────────────────────── + +func TestCollectAllVoiceStates_EmptyChannels(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "collect-empty-user") + + // No channels exist — ready should return empty voice_states. + msg, err := hub.BuildReadyForTest(database, user.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + var env struct { + Payload struct { + VoiceStates []any `json:"voice_states"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Payload.VoiceStates) != 0 { + t.Errorf("voice_states = %d entries, want 0 with no channels", len(env.Payload.VoiceStates)) + } +} + +func TestCollectAllVoiceStates_SkipsTextChannels(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "collect-text-user") + + // Only text channels — no voice states should be collected. + _, err := database.CreateChannel("text-only", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + msg, err := hub.BuildReadyForTest(database, user.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + var env struct { + Payload struct { + VoiceStates []any `json:"voice_states"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Payload.VoiceStates) != 0 { + t.Errorf("voice_states = %d entries, want 0 for text-only channels", len(env.Payload.VoiceStates)) + } +} + +func TestCollectAllVoiceStates_IncludesVoiceParticipants(t *testing.T) { + hub, database := newServeHub(t) + + user1 := seedServeUser(t, database, "collect-voice-u1") + user2 := seedServeUser(t, database, "collect-voice-u2") + requester := seedServeUser(t, database, "collect-voice-req") + + chID, err := database.CreateChannel("voice-room", "voice", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + // Insert voice states for user1 and user2. + if err := database.JoinVoiceChannel(user1.ID, chID); err != nil { + t.Fatalf("JoinVoiceChannel user1: %v", err) + } + if err := database.JoinVoiceChannel(user2.ID, chID); err != nil { + t.Fatalf("JoinVoiceChannel user2: %v", err) + } + + msg, err := hub.BuildReadyForTest(database, requester.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + var env struct { + Payload struct { + VoiceStates []struct { + ChannelID int64 `json:"channel_id"` + UserID int64 `json:"user_id"` + } `json:"voice_states"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Payload.VoiceStates) != 2 { + t.Errorf("voice_states count = %d, want 2", len(env.Payload.VoiceStates)) + } + for _, vs := range env.Payload.VoiceStates { + if vs.ChannelID != chID { + t.Errorf("voice_state channel_id = %d, want %d", vs.ChannelID, chID) + } + } +} + +// ─── getCachedSettings ──────────────────────────────────────────────────────── + +func TestGetCachedSettings_CacheHit(t *testing.T) { + hub, _ := newServeHub(t) + + // Call twice in quick succession; second call must return the same values + // (cache hit path, no DB re-read within TTL). + name1, motd1 := hub.GetCachedSettingsForTest() + name2, motd2 := hub.GetCachedSettingsForTest() + + if name1 != name2 { + t.Errorf("server_name changed between calls: %q vs %q", name1, name2) + } + if motd1 != motd2 { + t.Errorf("motd changed between calls: %q vs %q", motd1, motd2) + } +} + +func TestGetCachedSettings_ReturnsNonEmptyValues(t *testing.T) { + hub, _ := newServeHub(t) + name, motd := hub.GetCachedSettingsForTest() + if name == "" { + t.Error("server_name must not be empty after NewHub") + } + if motd == "" { + t.Error("motd must not be empty after NewHub") + } +} + +func TestGetCachedSettings_ReflectsDBValues(t *testing.T) { + _, database := newServeHub(t) + + // Verify the default settings were loaded correctly from the seeded DB. + var name string + if err := database.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&name); err != nil { + t.Fatalf("query server_name: %v", err) + } + if name != "OwnCord Server" { + t.Errorf("DB server_name = %q, want OwnCord Server", name) + } +} + +// ─── Broadcast* hub methods ─────────────────────────────────────────────────── + +func TestHub_BroadcastServerRestart_DeliversToAllClients(t *testing.T) { + hub, database := newServeHub(t) + + u1 := seedTestUser(t, database, "restart-u1") + u2 := seedTestUser(t, database, "restart-u2") + s1 := make(chan []byte, 4) + s2 := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, u1, s1)) + hub.Register(ws.NewTestClient(hub, u2, s2)) + time.Sleep(20 * time.Millisecond) + + hub.BroadcastServerRestart("update", 5) + time.Sleep(20 * time.Millisecond) + + for _, s := range []chan []byte{s1, s2} { + select { + case msg := <-s: + var env struct { + Type string `json:"type"` + Payload struct { + Reason string `json:"reason"` + DelaySeconds int `json:"delay_seconds"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "server_restart" { + t.Errorf("type = %q, want server_restart", env.Type) + } + if env.Payload.Reason != "update" { + t.Errorf("payload.reason = %q, want update", env.Payload.Reason) + } + if env.Payload.DelaySeconds != 5 { + t.Errorf("payload.delay_seconds = %d, want 5", env.Payload.DelaySeconds) + } + case <-time.After(500 * time.Millisecond): + t.Error("client did not receive server_restart within timeout") + } + } +} + +func TestHub_BroadcastServerRestart_NoClients_NoPanic(t *testing.T) { + hub, _ := newServeHub(t) + // Must not panic with no clients connected. + hub.BroadcastServerRestart("maintenance", 30) +} + +func TestHub_BroadcastChannelCreate_DeliversToAllClients(t *testing.T) { + hub, database := newServeHub(t) + + u1 := seedTestUser(t, database, "chcreate-u1") + s1 := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, u1, s1)) + time.Sleep(20 * time.Millisecond) + + ch := &db.Channel{ID: 77, Name: "announcements", Type: "text", Category: "News", Position: 1} + hub.BroadcastChannelCreate(ch) + time.Sleep(20 * time.Millisecond) + + select { + case msg := <-s1: + var env struct { + Type string `json:"type"` + Payload struct { + ID float64 `json:"id"` + Name string `json:"name"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "channel_create" { + t.Errorf("type = %q, want channel_create", env.Type) + } + if int64(env.Payload.ID) != ch.ID { + t.Errorf("payload.id = %d, want %d", int64(env.Payload.ID), ch.ID) + } + if env.Payload.Name != ch.Name { + t.Errorf("payload.name = %q, want %q", env.Payload.Name, ch.Name) + } + case <-time.After(500 * time.Millisecond): + t.Error("client did not receive channel_create within timeout") + } +} + +func TestHub_BroadcastChannelUpdate_DeliversToAllClients(t *testing.T) { + hub, database := newServeHub(t) + + u1 := seedTestUser(t, database, "chupdate-u1") + s1 := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, u1, s1)) + time.Sleep(20 * time.Millisecond) + + ch := &db.Channel{ID: 88, Name: "updated-channel", Type: "text", Category: "General", Position: 2} + hub.BroadcastChannelUpdate(ch) + time.Sleep(20 * time.Millisecond) + + select { + case msg := <-s1: + var env struct { + Type string `json:"type"` + Payload struct { + ID float64 `json:"id"` + Name string `json:"name"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "channel_update" { + t.Errorf("type = %q, want channel_update", env.Type) + } + if int64(env.Payload.ID) != ch.ID { + t.Errorf("payload.id = %d, want %d", int64(env.Payload.ID), ch.ID) + } + case <-time.After(500 * time.Millisecond): + t.Error("client did not receive channel_update within timeout") + } +} + +func TestHub_BroadcastChannelDelete_DeliversToAllClients(t *testing.T) { + hub, database := newServeHub(t) + + u1 := seedTestUser(t, database, "chdel-u1") + s1 := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, u1, s1)) + time.Sleep(20 * time.Millisecond) + + hub.BroadcastChannelDelete(123) + time.Sleep(20 * time.Millisecond) + + select { + case msg := <-s1: + var env struct { + Type string `json:"type"` + Payload struct { + ID float64 `json:"id"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "channel_delete" { + t.Errorf("type = %q, want channel_delete", env.Type) + } + if int64(env.Payload.ID) != 123 { + t.Errorf("payload.id = %d, want 123", int64(env.Payload.ID)) + } + case <-time.After(500 * time.Millisecond): + t.Error("client did not receive channel_delete within timeout") + } +} + +func TestHub_BroadcastMemberBan_DeliversToAllClients(t *testing.T) { + hub, database := newServeHub(t) + + u1 := seedTestUser(t, database, "ban-u1") + s1 := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, u1, s1)) + time.Sleep(20 * time.Millisecond) + + hub.BroadcastMemberBan(999) + time.Sleep(20 * time.Millisecond) + + select { + case msg := <-s1: + var env struct { + Type string `json:"type"` + Payload struct { + UserID float64 `json:"user_id"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "member_ban" { + t.Errorf("type = %q, want member_ban", env.Type) + } + if int64(env.Payload.UserID) != 999 { + t.Errorf("payload.user_id = %d, want 999", int64(env.Payload.UserID)) + } + case <-time.After(500 * time.Millisecond): + t.Error("client did not receive member_ban within timeout") + } +} + +func TestHub_BroadcastMemberUpdate_DeliversToAllClients(t *testing.T) { + hub, database := newServeHub(t) + + u1 := seedTestUser(t, database, "memupdate-u1") + s1 := make(chan []byte, 4) + hub.Register(ws.NewTestClient(hub, u1, s1)) + time.Sleep(20 * time.Millisecond) + + hub.BroadcastMemberUpdate(888, "moderator") + time.Sleep(20 * time.Millisecond) + + select { + case msg := <-s1: + var env struct { + Type string `json:"type"` + Payload struct { + UserID float64 `json:"user_id"` + Role string `json:"role"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "member_update" { + t.Errorf("type = %q, want member_update", env.Type) + } + if int64(env.Payload.UserID) != 888 { + t.Errorf("payload.user_id = %d, want 888", int64(env.Payload.UserID)) + } + if env.Payload.Role != "moderator" { + t.Errorf("payload.role = %q, want moderator", env.Payload.Role) + } + case <-time.After(500 * time.Millisecond): + t.Error("client did not receive member_update within timeout") + } +} + +func TestHub_BroadcastMemberBan_NoClients_NoPanic(t *testing.T) { + hub, _ := newServeHub(t) + hub.BroadcastMemberBan(1) +} + +func TestHub_BroadcastMemberUpdate_NoClients_NoPanic(t *testing.T) { + hub, _ := newServeHub(t) + hub.BroadcastMemberUpdate(1, "member") +} + +func TestHub_BroadcastChannelCreate_NoClients_NoPanic(t *testing.T) { + hub, _ := newServeHub(t) + hub.BroadcastChannelCreate(&db.Channel{ID: 1, Name: "x", Type: "text"}) +} + +func TestHub_BroadcastChannelUpdate_NoClients_NoPanic(t *testing.T) { + hub, _ := newServeHub(t) + hub.BroadcastChannelUpdate(&db.Channel{ID: 1, Name: "x", Type: "text"}) +} + +func TestHub_BroadcastChannelDelete_NoClients_NoPanic(t *testing.T) { + hub, _ := newServeHub(t) + hub.BroadcastChannelDelete(1) +} + +// ─── getCachedSettings — cache expiry path ──────────────────────────────────── + +func TestGetCachedSettings_CacheMiss_RefreshesFromDB(t *testing.T) { + hub, database := newServeHub(t) + + // Update the DB settings value so we can detect a refresh. + _, err := database.Exec("UPDATE settings SET value='Refreshed Server' WHERE key='server_name'") + if err != nil { + t.Fatalf("UPDATE settings: %v", err) + } + + // Force the cache to appear stale. + hub.ExpireSettingsCacheForTest() + + // Next call must re-read from the DB and return the updated value. + name, _ := hub.GetCachedSettingsForTest() + if name != "Refreshed Server" { + t.Errorf("server_name after cache miss = %q, want Refreshed Server", name) + } +} + +func TestGetCachedSettings_CacheMiss_DoubleCheck(t *testing.T) { + // Expire the cache and call twice rapidly to exercise the double-check + // (write-lock re-check) branch inside getCachedSettings. + hub, _ := newServeHub(t) + hub.ExpireSettingsCacheForTest() + + name1, _ := hub.GetCachedSettingsForTest() + // Second call should hit the cache (now warm). + name2, _ := hub.GetCachedSettingsForTest() + if name1 != name2 { + t.Errorf("server_name changed after refresh: %q vs %q", name1, name2) + } +} + +// ─── parseChannelID error paths ─────────────────────────────────────────────── + +func TestParseChannelID_ValidPayload(t *testing.T) { + raw := json.RawMessage(`{"channel_id": 42}`) + id, err := ws.ParseChannelIDForTest(raw) + if err != nil { + t.Fatalf("ParseChannelIDForTest: %v", err) + } + if id != 42 { + t.Errorf("channel_id = %d, want 42", id) + } +} + +func TestParseChannelID_InvalidJSON(t *testing.T) { + raw := json.RawMessage(`NOT JSON`) + _, err := ws.ParseChannelIDForTest(raw) + if err == nil { + t.Error("expected error for invalid JSON, got nil") + } +} + +func TestParseChannelID_NonIntegerChannelID(t *testing.T) { + raw := json.RawMessage(`{"channel_id": "not-a-number"}`) + _, err := ws.ParseChannelIDForTest(raw) + if err == nil { + t.Error("expected error for non-integer channel_id, got nil") + } +} + +func TestParseChannelID_MissingField(t *testing.T) { + // Missing channel_id field — json.Number.Int64 on zero value returns 0, no error. + raw := json.RawMessage(`{}`) + id, err := ws.ParseChannelIDForTest(raw) + if err == nil && id != 0 { + t.Errorf("expected id=0 for missing channel_id, got %d", id) + } +} + +// ─── buildJSON error fallback path ──────────────────────────────────────────── + +func TestBuildJSON_ValidValue_ReturnsJSON(t *testing.T) { + // Normal path: marshalable value produces valid JSON. + out := ws.BuildJSONForTest(map[string]string{"type": "test"}) + if !json.Valid(out) { + t.Errorf("BuildJSONForTest output is not valid JSON: %s", out) + } +} + +// ─── buildReady error path (nil members fallback) ───────────────────────────── + +func TestBuildReady_NoVoiceChannels_EmptyVoiceStates(t *testing.T) { + hub, database := newServeHub(t) + user := seedServeUser(t, database, "ready-novch") + + // Create only a text channel — voice_states list must still be non-nil. + _, err := database.CreateChannel("text-chan", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + msg, err := hub.BuildReadyForTest(database, user.ID) + if err != nil { + t.Fatalf("BuildReadyForTest: %v", err) + } + var env struct { + Payload struct { + VoiceStates []any `json:"voice_states"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // collectAllVoiceStates returns []db.VoiceState{} (not nil) when no voice channels exist. + if env.Payload.VoiceStates == nil { + t.Error("voice_states must be a non-null JSON array even when empty") + } +} diff --git a/Server/ws/sfu.go b/Server/ws/sfu.go new file mode 100644 index 00000000..5d8204f3 --- /dev/null +++ b/Server/ws/sfu.go @@ -0,0 +1,102 @@ +package ws + +import ( + "fmt" + "log/slog" + + "github.com/pion/interceptor" + "github.com/pion/webrtc/v4" + + "github.com/owncord/server/config" +) + +// SFU wraps Pion's WebRTC API with pre-configured MediaEngine, +// InterceptorRegistry, and SettingEngine. +type SFU struct { + api *webrtc.API + config *config.VoiceConfig +} + +// NewSFU creates a new SFU with the given voice configuration. It sets up +// the Pion MediaEngine with default codecs, registers the ssrc-audio-level +// RTP header extension, configures interceptors, and applies NAT/port settings. +func NewSFU(cfg *config.VoiceConfig) (*SFU, error) { + var me webrtc.MediaEngine + if err := me.RegisterDefaultCodecs(); err != nil { + return nil, err + } + + // Register ssrc-audio-level header extension for active speaker detection. + const audioLevelURI = "urn:ietf:params:rtp-hdrext:ssrc-audio-level" + for _, dir := range []webrtc.RTPTransceiverDirection{ + webrtc.RTPTransceiverDirectionSendonly, + webrtc.RTPTransceiverDirectionRecvonly, + } { + if err := me.RegisterHeaderExtension( + webrtc.RTPHeaderExtensionCapability{URI: audioLevelURI}, + webrtc.RTPCodecTypeAudio, + dir, + ); err != nil { + return nil, err + } + } + + var ir interceptor.Registry + if err := webrtc.RegisterDefaultInterceptors(&me, &ir); err != nil { + return nil, err + } + + var se webrtc.SettingEngine + _ = se.SetEphemeralUDPPortRange(uint16(cfg.MediaPortMin), uint16(cfg.MediaPortMax)) + + if cfg.ExternalIP != "" { + if err := se.SetICEAddressRewriteRules(webrtc.ICEAddressRewriteRule{ + External: []string{cfg.ExternalIP}, + AsCandidateType: webrtc.ICECandidateTypeHost, + Mode: webrtc.ICEAddressRewriteReplace, + }); err != nil { + return nil, fmt.Errorf("setting ICE address rewrite rules: %w", err) + } + } + + api := webrtc.NewAPI( + webrtc.WithMediaEngine(&me), + webrtc.WithInterceptorRegistry(&ir), + webrtc.WithSettingEngine(se), + ) + + slog.Info("SFU initialized", + "quality", cfg.Quality, + "media_port_range", fmt.Sprintf("%d-%d", cfg.MediaPortMin, cfg.MediaPortMax), + "external_ip", cfg.ExternalIP) + return &SFU{api: api, config: cfg}, nil +} + +// NewPeerConnection creates a new PeerConnection using the SFU's pre-configured +// WebRTC API. The SFU is the media server itself — it does not need STUN/TURN +// to discover its own address. NAT traversal is handled by ExternalIP config +// which rewrites ICE candidates via SetICEAddressRewriteRules in the +// SettingEngine. +func (s *SFU) NewPeerConnection() (*webrtc.PeerConnection, error) { + return s.api.NewPeerConnection(webrtc.Configuration{}) +} + +// Close is a placeholder for SFU cleanup. Future implementations may close +// active peer connections or release resources. +func (s *SFU) Close() { + // Placeholder for cleanup. +} + +// QualityBitrate returns the target audio bitrate in bits/s based on the +// configured quality preset. +func (s *SFU) QualityBitrate() int { + switch s.config.Quality { + case "low": + return 32000 + case "high": + return 128000 + default: + return 64000 + } +} + diff --git a/Server/ws/sfu_test.go b/Server/ws/sfu_test.go new file mode 100644 index 00000000..68252abb --- /dev/null +++ b/Server/ws/sfu_test.go @@ -0,0 +1,106 @@ +package ws_test + +import ( + "testing" + + "github.com/owncord/server/config" + "github.com/owncord/server/ws" +) + +func testVoiceConfig() *config.VoiceConfig { + return &config.VoiceConfig{ + Quality: "medium", + MediaPortMin: 50000, + MediaPortMax: 50100, + } +} + +func TestNewSFU_Success(t *testing.T) { + sfu, err := ws.NewSFU(testVoiceConfig()) + if err != nil { + t.Fatalf("NewSFU() returned error: %v", err) + } + if sfu == nil { + t.Fatal("NewSFU() returned nil SFU") + } + defer sfu.Close() +} + +func TestNewSFU_CreatesValidPeerConnection(t *testing.T) { + sfu, err := ws.NewSFU(testVoiceConfig()) + if err != nil { + t.Fatalf("NewSFU() returned error: %v", err) + } + defer sfu.Close() + + pc, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection() returned error: %v", err) + } + if pc == nil { + t.Fatal("NewPeerConnection() returned nil PeerConnection") + } + if err := pc.Close(); err != nil { + t.Fatalf("PeerConnection.Close() returned error: %v", err) + } +} + +func TestSFU_QualityBitrate_Presets(t *testing.T) { + tests := []struct { + quality string + want int + }{ + {"low", 32000}, + {"medium", 64000}, + {"high", 128000}, + {"unknown", 64000}, + {"", 64000}, + } + + for _, tt := range tests { + t.Run(tt.quality, func(t *testing.T) { + cfg := testVoiceConfig() + cfg.Quality = tt.quality + + sfu, err := ws.NewSFU(cfg) + if err != nil { + t.Fatalf("NewSFU() returned error: %v", err) + } + defer sfu.Close() + + got := sfu.QualityBitrate() + if got != tt.want { + t.Errorf("QualityBitrate() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestSFU_Close(t *testing.T) { + sfu, err := ws.NewSFU(testVoiceConfig()) + if err != nil { + t.Fatalf("NewSFU() returned error: %v", err) + } + + // Close should not panic. + sfu.Close() +} + +func TestNewSFU_WithExternalIP(t *testing.T) { + cfg := testVoiceConfig() + cfg.ExternalIP = "203.0.113.1" + + sfu, err := ws.NewSFU(cfg) + if err != nil { + t.Fatalf("NewSFU() returned error: %v", err) + } + defer sfu.Close() + + pc, err := sfu.NewPeerConnection() + if err != nil { + t.Fatalf("NewPeerConnection() returned error: %v", err) + } + if err := pc.Close(); err != nil { + t.Fatalf("PeerConnection.Close() returned error: %v", err) + } +} diff --git a/Server/ws/speaker_broadcast.go b/Server/ws/speaker_broadcast.go new file mode 100644 index 00000000..c4d33acd --- /dev/null +++ b/Server/ws/speaker_broadcast.go @@ -0,0 +1,79 @@ +package ws + +import ( + "log/slog" + "strconv" + "time" +) + +const speakerBroadcastInterval = 200 * time.Millisecond + +// runSpeakerBroadcast periodically checks all voice rooms for speaker changes +// and broadcasts voice_speakers to the channel. Runs until stop is closed. +func (h *Hub) runSpeakerBroadcast(stop <-chan struct{}) { + ticker := time.NewTicker(speakerBroadcastInterval) + defer ticker.Stop() + + // Track previous speaker lists to avoid redundant broadcasts. + prevSpeakers := make(map[int64]string) // channelID → comma-joined speaker IDs + + for { + select { + case <-stop: + return + case <-ticker.C: + h.voiceRoomsMu.RLock() + rooms := make(map[int64]*VoiceRoom, len(h.voiceRooms)) + for id, room := range h.voiceRooms { + rooms[id] = room + } + h.voiceRoomsMu.RUnlock() + + for channelID, room := range rooms { + speakers := room.TopSpeakers() + mode := room.Mode() + + // Build a simple key to detect changes. + key := speakerKey(speakers) + if prev, ok := prevSpeakers[channelID]; ok && prev == key { + continue // no change + } + prevSpeakers[channelID] = key + + msg := buildVoiceSpeakers(channelID, speakers, mode) + slog.Debug("speaker broadcast", "channel_id", channelID, "speakers", speakers, "mode", mode) + h.BroadcastToChannel(channelID, msg) + } + + // Clean up stale entries for rooms that no longer exist. + for id := range prevSpeakers { + if _, exists := rooms[id]; !exists { + delete(prevSpeakers, id) + } + } + } + } +} + +// speakerKey builds a simple string key from speaker IDs for change detection. +// Order matters: [1,2,3] and [3,2,1] produce different keys. +func speakerKey(speakers []int64) string { + if len(speakers) == 0 { + return "" + } + // Simple concatenation — order matters for change detection. + b := make([]byte, 0, len(speakers)*4) + for i, id := range speakers { + if i > 0 { + b = append(b, ',') + } + b = append(b, []byte(strconv.FormatInt(id, 10))...) + } + return string(b) +} + +// SpeakerKeyForTest exposes speakerKey for use in external test packages. +// Only call from *_test.go files. +func SpeakerKeyForTest(speakers []int64) string { + return speakerKey(speakers) +} diff --git a/Server/ws/speaker_detector.go b/Server/ws/speaker_detector.go new file mode 100644 index 00000000..0a00708e --- /dev/null +++ b/Server/ws/speaker_detector.go @@ -0,0 +1,164 @@ +package ws + +import ( + "sort" + "sync" + "time" +) + +const defaultHoldoff = 500 * time.Millisecond + +// speakerLevel tracks the running audio level average for one user. +type speakerLevel struct { + userID int64 + levels [10]uint8 // ring buffer, 10 samples = 200ms at 20ms frames + pos int + count int // how many samples collected (up to 10) + average float64 + lastActive time.Time // last time this speaker was in top-N +} + +// SpeakerDetector selects the top-N loudest speakers by RFC 6464 audio level. +type SpeakerDetector struct { + speakers map[int64]*speakerLevel + topN int + holdoff time.Duration // how long a speaker stays in top-N after going quiet + mu sync.Mutex +} + +// NewSpeakerDetector creates a detector with the default 500ms holdoff. +func NewSpeakerDetector(topN int) *SpeakerDetector { + return NewSpeakerDetectorWithHoldoff(topN, defaultHoldoff) +} + +// NewSpeakerDetectorWithHoldoff creates a detector with a custom holdoff duration. +func NewSpeakerDetectorWithHoldoff(topN int, holdoff time.Duration) *SpeakerDetector { + return &SpeakerDetector{ + speakers: make(map[int64]*speakerLevel), + topN: topN, + holdoff: holdoff, + } +} + +// UpdateLevel adds an audio level sample to the ring buffer for the given user +// and recalculates the running average. Level is RFC 6464 dBov: 0 = loudest, +// 127 = silence. +func (d *SpeakerDetector) UpdateLevel(userID int64, level uint8) { + d.mu.Lock() + defer d.mu.Unlock() + + sl, ok := d.speakers[userID] + if !ok { + sl = &speakerLevel{userID: userID} + d.speakers[userID] = sl + } + + sl.levels[sl.pos] = level + sl.pos = (sl.pos + 1) % len(sl.levels) + if sl.count < len(sl.levels) { + sl.count++ + } + + // Recalculate average over collected samples. + var sum int + for i := range sl.count { + sum += int(sl.levels[i]) + } + sl.average = float64(sum) / float64(sl.count) + + // Mark as active if not silent. + if sl.average < 127 { + sl.lastActive = time.Now() + } +} + +// TopSpeakers returns up to top-N user IDs sorted by lowest average level +// (loudest first). Silent speakers (average == 127) are excluded unless they +// are within the holdoff window. +func (d *SpeakerDetector) TopSpeakers() []int64 { + d.mu.Lock() + defer d.mu.Unlock() + + now := time.Now() + + // Collect candidates: not silent, or within holdoff. + candidates := make([]*speakerLevel, 0, len(d.speakers)) + for _, sl := range d.speakers { + if sl.average < 127 { + candidates = append(candidates, sl) + } else if !sl.lastActive.IsZero() && now.Sub(sl.lastActive) <= d.holdoff { + candidates = append(candidates, sl) + } + } + + // Sort by average level ascending (loudest first). + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].average < candidates[j].average + }) + + n := d.topN + if len(candidates) < n { + n = len(candidates) + } + + result := make([]int64, n) + for i := range n { + result[i] = candidates[i].userID + } + return result +} + +// RemoveSpeaker removes a speaker from the detector (e.g., when they leave). +func (d *SpeakerDetector) RemoveSpeaker(userID int64) { + d.mu.Lock() + defer d.mu.Unlock() + + delete(d.speakers, userID) +} + +// ParseAudioLevel parses an RFC 6464 one-byte header extension from raw RTP +// extension data (RFC 5285 one-byte header format). It scans for the given +// extensionID and extracts the voice activity bit and 7-bit level. +// +// Returns ok=false if the extension is not found. +func ParseAudioLevel(buf []byte, extensionID uint8) (level uint8, voice bool, ok bool) { + if len(buf) == 0 { + return 0, false, false + } + + // Walk RFC 5285 one-byte header extensions. + // Each element: 4-bit ID | 4-bit (length-1), followed by (length) data bytes. + // ID=0 is padding, ID=15 terminates. + i := 0 + for i < len(buf) { + id := buf[i] >> 4 + dataLen := int(buf[i]&0x0F) + 1 + + if id == 0 { + // Padding byte — skip. + i++ + continue + } + if id == 15 { + // Terminator. + break + } + + i++ // move past header byte + + if i+dataLen > len(buf) { + break + } + + if id == extensionID && dataLen >= 1 { + b := buf[i] + voice = (b & 0x80) != 0 + level = b & 0x7F + return level, voice, true + } + + i += dataLen + } + + return 0, false, false +} diff --git a/Server/ws/speaker_detector_test.go b/Server/ws/speaker_detector_test.go new file mode 100644 index 00000000..385a4f84 --- /dev/null +++ b/Server/ws/speaker_detector_test.go @@ -0,0 +1,293 @@ +package ws_test + +import ( + "slices" + "testing" + "time" + + "github.com/owncord/server/ws" +) + +func TestNewSpeakerDetector(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetector(3) + if sd == nil { + t.Fatal("NewSpeakerDetector returned nil") + } + top := sd.TopSpeakers() + if len(top) != 0 { + t.Fatalf("expected empty top speakers, got %v", top) + } +} + +func TestSpeakerDetector_UpdateLevel(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetector(3) + + // Feed several level samples for a single user. + for range 5 { + sd.UpdateLevel(1, 30) // relatively loud + } + + top := sd.TopSpeakers() + if len(top) != 1 { + t.Fatalf("expected 1 speaker, got %d", len(top)) + } + if top[0] != int64(1) { + t.Fatalf("expected userID 1, got %d", top[0]) + } +} + +func TestSpeakerDetector_TopSpeakers_RankedByLoudest(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetector(3) + + // 5 users with different average levels (lower = louder in dBov). + // User 10: level 10 (loudest) + // User 20: level 30 + // User 30: level 50 + // User 40: level 80 + // User 50: level 100 (quietest) + users := []struct { + id int64 + level uint8 + }{ + {10, 10}, + {20, 30}, + {30, 50}, + {40, 80}, + {50, 100}, + } + for _, u := range users { + for range 5 { + sd.UpdateLevel(u.id, u.level) + } + } + + top := sd.TopSpeakers() + if len(top) != 3 { + t.Fatalf("expected 3 top speakers, got %d: %v", len(top), top) + } + // Should be sorted loudest first: 10, 20, 30 + expected := []int64{10, 20, 30} + for i, want := range expected { + if top[i] != want { + t.Errorf("top[%d] = %d, want %d", i, top[i], want) + } + } +} + +func TestSpeakerDetector_TopSpeakers_SilentExcluded(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetector(3) + + // User 1: loud + for range 5 { + sd.UpdateLevel(1, 20) + } + // User 2: completely silent (127 = digital silence in RFC 6464) + for range 5 { + sd.UpdateLevel(2, 127) + } + + top := sd.TopSpeakers() + if len(top) != 1 { + t.Fatalf("expected 1 speaker (silent excluded), got %d: %v", len(top), top) + } + if top[0] != int64(1) { + t.Fatalf("expected userID 1, got %d", top[0]) + } +} + +func TestSpeakerDetector_TopSpeakers_HoldoffKeepsSpeaker(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond) + + // User 1 speaks loudly. + for range 5 { + sd.UpdateLevel(1, 20) + } + + // User 1 goes silent. + for range 10 { + sd.UpdateLevel(1, 127) + } + + // Immediately check — holdoff should keep user 1 in top speakers. + top := sd.TopSpeakers() + if !slices.Contains(top, int64(1)) { + t.Fatalf("expected user 1 to remain in top speakers during holdoff, got %v", top) + } +} + +func TestSpeakerDetector_TopSpeakers_HoldoffExpires(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetectorWithHoldoff(3, 50*time.Millisecond) + + // User 1 speaks loudly. + for range 5 { + sd.UpdateLevel(1, 20) + } + + // User 1 goes silent — fill ring buffer with silence. + for range 10 { + sd.UpdateLevel(1, 127) + } + + // Wait longer than holdoff. + time.Sleep(80 * time.Millisecond) + + top := sd.TopSpeakers() + for _, id := range top { + if id == int64(1) { + t.Fatalf("expected user 1 to be evicted after holdoff expired, got %v", top) + } + } +} + +func TestSpeakerDetector_RemoveSpeaker(t *testing.T) { + t.Parallel() + sd := ws.NewSpeakerDetector(3) + + for range 5 { + sd.UpdateLevel(1, 20) + sd.UpdateLevel(2, 30) + } + + sd.RemoveSpeaker(1) + + top := sd.TopSpeakers() + for _, id := range top { + if id == int64(1) { + t.Fatalf("removed speaker should not appear in TopSpeakers, got %v", top) + } + } + if len(top) != 1 || top[0] != int64(2) { + t.Fatalf("expected [2], got %v", top) + } +} + +func TestParseAudioLevel_Valid(t *testing.T) { + t.Parallel() + + // Construct a one-byte header extension value: + // V=1, Level=42 → binary: 1_0101010 → 0xAA + extByte := byte(0x80 | 42) // voice=1, level=42 + // RFC 5285 one-byte header format: 4-bit ID | 4-bit length-1 + // For extensionID=1, length=1 byte: header = 0x10 + extensionID := uint8(1) + buf := []byte{extensionID << 4, extByte} // ID=1, L=0 (meaning 1 byte), then the data byte + + level, voice, ok := ws.ParseAudioLevel(buf, extensionID) + if !ok { + t.Fatal("expected ok=true for valid extension") + } + if level != 42 { + t.Errorf("level = %d, want 42", level) + } + if !voice { + t.Error("expected voice=true") + } + + // Test with voice=false, level=10 → binary: 0_0001010 → 0x0A + extByte2 := byte(10) // voice=0, level=10 + buf2 := []byte{extensionID << 4, extByte2} + + level2, voice2, ok2 := ws.ParseAudioLevel(buf2, extensionID) + if !ok2 { + t.Fatal("expected ok=true") + } + if level2 != 10 { + t.Errorf("level = %d, want 10", level2) + } + if voice2 { + t.Error("expected voice=false") + } +} + +func TestParseAudioLevel_NotFound(t *testing.T) { + t.Parallel() + + // Empty buffer. + _, _, ok := ws.ParseAudioLevel(nil, 1) + if ok { + t.Error("expected ok=false for nil buffer") + } + + _, _, ok = ws.ParseAudioLevel([]byte{}, 1) + if ok { + t.Error("expected ok=false for empty buffer") + } + + // Wrong extension ID — buffer has ID=2 but we ask for ID=1. + buf := []byte{2 << 4, 0x80} + _, _, ok = ws.ParseAudioLevel(buf, 1) + if ok { + t.Error("expected ok=false for wrong extension ID") + } +} + +func TestParseAudioLevel_PaddingByte(t *testing.T) { + t.Parallel() + + // Padding byte (ID=0), then actual extension ID=1. + // Padding: byte 0x00 (id=0 means skip) + // Extension: ID=1, L=0 (1 byte), data=0x8A (voice=1, level=10) + buf := []byte{0x00, 1 << 4, 0x8A} + + level, voice, ok := ws.ParseAudioLevel(buf, 1) + if !ok { + t.Fatal("expected ok=true after padding byte") + } + if level != 10 { + t.Errorf("level = %d, want 10", level) + } + if !voice { + t.Error("expected voice=true") + } +} + +func TestParseAudioLevel_Terminator(t *testing.T) { + t.Parallel() + + // Terminator byte (ID=15) before any matching extension. + buf := []byte{0xF0} // ID=15, terminates + + _, _, ok := ws.ParseAudioLevel(buf, 1) + if ok { + t.Error("expected ok=false when terminator encountered before matching ID") + } +} + +func TestParseAudioLevel_TruncatedData(t *testing.T) { + t.Parallel() + + // Extension header says 1 byte of data, but buffer ends before data. + // ID=1, L=0 (meaning 1 byte of data needed), but no data follows. + buf := []byte{1 << 4} + + _, _, ok := ws.ParseAudioLevel(buf, 1) + if ok { + t.Error("expected ok=false when data is truncated") + } +} + +func TestParseAudioLevel_SkipOtherExtension(t *testing.T) { + t.Parallel() + + // Extension ID=2 with 2 bytes of data, followed by ID=1 with actual data. + // ID=2, L=1 (2 bytes data): header 0x21, data 0x00 0x00 + // ID=1, L=0 (1 byte data): header 0x10, data 0x85 (voice=1, level=5) + buf := []byte{0x21, 0x00, 0x00, 0x10, 0x85} + + level, voice, ok := ws.ParseAudioLevel(buf, 1) + if !ok { + t.Fatal("expected ok=true after skipping other extension") + } + if level != 5 { + t.Errorf("level = %d, want 5", level) + } + if !voice { + t.Error("expected voice=true") + } +} diff --git a/Server/ws/speaker_integration_test.go b/Server/ws/speaker_integration_test.go new file mode 100644 index 00000000..80fc64d7 --- /dev/null +++ b/Server/ws/speaker_integration_test.go @@ -0,0 +1,373 @@ +package ws_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/ws" +) + +// ─── VoiceRoom speaker detection ───────────────────────────────────────────── + +func TestVoiceRoom_UpdateSpeakerLevel(t *testing.T) { + cfg := ws.VoiceRoomConfig{ + ChannelID: 1, + TopSpeakers: 3, + } + room := ws.NewVoiceRoom(cfg) + + // Add participants first. + _ = room.AddParticipant(10) + _ = room.AddParticipant(20) + _ = room.AddParticipant(30) + + // User 10 is loudest (lowest dBov = 10), user 30 quietest (90). + for range 5 { + room.UpdateSpeakerLevel(10, 10) + room.UpdateSpeakerLevel(20, 50) + room.UpdateSpeakerLevel(30, 90) + } + + top := room.TopSpeakers() + if len(top) == 0 { + t.Fatal("TopSpeakers returned empty; expected at least one active speaker") + } + if top[0] != int64(10) { + t.Errorf("top speaker = %d, want 10 (loudest)", top[0]) + } +} + +func TestVoiceRoom_TopSpeakers_EmptyRoom(t *testing.T) { + cfg := ws.VoiceRoomConfig{ + ChannelID: 2, + TopSpeakers: 3, + } + room := ws.NewVoiceRoom(cfg) + + top := room.TopSpeakers() + if len(top) != 0 { + t.Errorf("TopSpeakers on empty room = %v, want empty slice", top) + } +} + +func TestVoiceRoom_RemoveParticipant_RemovesFromDetector(t *testing.T) { + cfg := ws.VoiceRoomConfig{ + ChannelID: 3, + TopSpeakers: 3, + } + room := ws.NewVoiceRoom(cfg) + _ = room.AddParticipant(100) + _ = room.AddParticipant(200) + + // Feed audio so both appear in top speakers. + for range 5 { + room.UpdateSpeakerLevel(100, 20) + room.UpdateSpeakerLevel(200, 30) + } + + // Verify both appear before removal. + topBefore := room.TopSpeakers() + if len(topBefore) < 2 { + t.Fatalf("expected 2 speakers before removal, got %v", topBefore) + } + + // Remove user 100 from the room. + room.RemoveParticipant(100) + + // After removal, user 100 must not appear in TopSpeakers. + top := room.TopSpeakers() + for _, id := range top { + if id == int64(100) { + t.Errorf("removed user 100 still appears in TopSpeakers: %v", top) + } + } +} + +func TestVoiceRoom_Config(t *testing.T) { + cfg := ws.VoiceRoomConfig{ + ChannelID: 42, + MaxUsers: 10, + Quality: "high", + MixingThreshold: 8, + TopSpeakers: 5, + MaxVideo: 2, + } + room := ws.NewVoiceRoom(cfg) + + got := room.Config() + if got.ChannelID != 42 { + t.Errorf("Config().ChannelID = %d, want 42", got.ChannelID) + } + if got.MaxUsers != 10 { + t.Errorf("Config().MaxUsers = %d, want 10", got.MaxUsers) + } + if got.Quality != "high" { + t.Errorf("Config().Quality = %q, want %q", got.Quality, "high") + } + if got.MixingThreshold != 8 { + t.Errorf("Config().MixingThreshold = %d, want 8", got.MixingThreshold) + } + if got.TopSpeakers != 5 { + t.Errorf("Config().TopSpeakers = %d, want 5", got.TopSpeakers) + } + if got.MaxVideo != 2 { + t.Errorf("Config().MaxVideo = %d, want 2", got.MaxVideo) + } +} + +// ─── speakerKey helper ──────────────────────────────────────────────────────── + +func TestSpeakerKey_Empty(t *testing.T) { + key := ws.SpeakerKeyForTest(nil) + if key != "" { + t.Errorf("SpeakerKeyForTest(nil) = %q, want empty string", key) + } + + key2 := ws.SpeakerKeyForTest([]int64{}) + if key2 != "" { + t.Errorf("SpeakerKeyForTest([]) = %q, want empty string", key2) + } +} + +func TestSpeakerKey_SingleSpeaker(t *testing.T) { + key := ws.SpeakerKeyForTest([]int64{42}) + if key == "" { + t.Error("SpeakerKeyForTest([42]) returned empty string") + } + // Key must contain the speaker ID in some form. + if key != "42" { + t.Errorf("SpeakerKeyForTest([42]) = %q, want %q", key, "42") + } +} + +func TestSpeakerKey_MultipleSpeakers(t *testing.T) { + key1 := ws.SpeakerKeyForTest([]int64{1, 2, 3}) + key2 := ws.SpeakerKeyForTest([]int64{1, 2, 3}) + key3 := ws.SpeakerKeyForTest([]int64{3, 2, 1}) + + // Same order → same key. + if key1 != key2 { + t.Errorf("same speaker lists produced different keys: %q vs %q", key1, key2) + } + // Different order → different key (order matters for change detection). + if key1 == key3 { + t.Errorf("different speaker order should produce different keys but got %q for both", key1) + } +} + +func TestSpeakerKey_DistinctFromDifferentSpeakers(t *testing.T) { + key1 := ws.SpeakerKeyForTest([]int64{1, 2}) + key2 := ws.SpeakerKeyForTest([]int64{1, 3}) + if key1 == key2 { + t.Errorf("different speaker sets should produce different keys, both got %q", key1) + } +} + +// ─── Speaker broadcast integration ─────────────────────────────────────────── + +// TestSpeakerBroadcast_Integration creates a hub with a voice room, feeds +// speaker levels, and verifies a voice_speakers broadcast is sent within the +// ticker interval. +func TestSpeakerBroadcast_Integration(t *testing.T) { + database := openTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + // Create a voice room for channel 99. + cfg := ws.VoiceRoomConfig{ + ChannelID: 99, + TopSpeakers: 3, + } + room := hub.GetOrCreateVoiceRoom(99, cfg) + + // Register a client subscribed to channel 99 to receive the broadcast. + send := make(chan []byte, 16) + c := ws.NewTestClientWithChannel(hub, 1, 99, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Feed audio levels into the room — make user 1 a speaker. + for range 5 { + room.UpdateSpeakerLevel(1, 20) // level=20 (dBov), well below silence threshold + } + + // Wait for at least two ticker intervals (200ms each) so the broadcast fires. + time.Sleep(500 * time.Millisecond) + + // Drain and look for a voice_speakers message. + var found bool +drainLoop: + for { + select { + case msg := <-send: + var env map[string]json.RawMessage + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + msgType, ok := env["type"] + if !ok { + continue + } + var t2 string + if err := json.Unmarshal(msgType, &t2); err != nil { + continue + } + if t2 == "voice_speakers" { + found = true + break drainLoop + } + default: + break drainLoop + } + } + + if !found { + t.Error("expected voice_speakers broadcast within ticker interval, none received") + } +} + +// TestSpeakerBroadcast_NoBroadcastWhenNoChange verifies that the ticker does +// not repeatedly broadcast when the speaker list has not changed. +func TestSpeakerBroadcast_NoBroadcastWhenNoChange(t *testing.T) { + database := openTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + cfg := ws.VoiceRoomConfig{ + ChannelID: 100, + TopSpeakers: 3, + } + room := hub.GetOrCreateVoiceRoom(100, cfg) + + send := make(chan []byte, 64) + c := ws.NewTestClientWithChannel(hub, 2, 100, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Feed levels to produce a stable speaker list. + for range 5 { + room.UpdateSpeakerLevel(2, 20) + } + + // Wait for first broadcast. + time.Sleep(300 * time.Millisecond) + + // Count how many voice_speakers messages arrived after the initial one. + // In a change-detection implementation, subsequent ticks with the same + // speaker list should NOT send more broadcasts. + count := 0 + for { + select { + case msg := <-send: + var env map[string]json.RawMessage + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + var msgType string + if raw, ok := env["type"]; ok { + _ = json.Unmarshal(raw, &msgType) + } + if msgType == "voice_speakers" { + count++ + } + default: + goto done + } + } +done: + // We allow 1 broadcast (initial detection), but not many repeated ones. + // If every tick sent a message, we'd see ~2-4 in 300ms. We cap at 2. + if count > 2 { + t.Errorf("expected at most 2 voice_speakers broadcasts (dedup), got %d", count) + } +} + +// TestSpeakerBroadcast_RoomCleanup verifies that when a voice room is removed, +// the ticker cleans up its stale prevSpeakers entry so that re-creating the +// room with an active speaker triggers a new broadcast. +func TestSpeakerBroadcast_RoomCleanup(t *testing.T) { + database := openTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + const chanID = int64(101) + cfg := ws.VoiceRoomConfig{ + ChannelID: chanID, + TopSpeakers: 3, + } + room := hub.GetOrCreateVoiceRoom(chanID, cfg) + + // Use a large buffer to avoid missing messages due to timing. + send := make(chan []byte, 64) + c := ws.NewTestClientWithChannel(hub, 3, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Feed levels so the ticker broadcasts at least once. + for range 5 { + room.UpdateSpeakerLevel(3, 20) + } + + // Wait for two ticker intervals to ensure at least one broadcast fires. + time.Sleep(500 * time.Millisecond) + + // Remove the room; this should cause the ticker to clean up prevSpeakers. + hub.RemoveVoiceRoom(chanID) + + // Wait one more tick to let the cleanup run. + time.Sleep(250 * time.Millisecond) + + // Drain all pending messages. + draining: + for { + select { + case <-send: + default: + break draining + } + } + + // Re-create the room and feed a new speaker — the ticker should broadcast + // again because prevSpeakers[chanID] was deleted when the room was removed. + newRoom := hub.GetOrCreateVoiceRoom(chanID, cfg) + for range 5 { + newRoom.UpdateSpeakerLevel(3, 20) + } + + // Wait for the ticker to detect the new room and broadcast. + time.Sleep(500 * time.Millisecond) + + var found bool + collectLoop: + for { + select { + case msg := <-send: + var env map[string]json.RawMessage + if err := json.Unmarshal(msg, &env); err != nil { + continue + } + var msgType string + if raw, ok := env["type"]; ok { + _ = json.Unmarshal(raw, &msgType) + } + if msgType == "voice_speakers" { + found = true + break collectLoop + } + default: + break collectLoop + } + } + + if !found { + t.Error("expected voice_speakers broadcast after room re-creation, none received") + } +} diff --git a/Server/ws/voice_handlers.go b/Server/ws/voice_handlers.go new file mode 100644 index 00000000..e32d5bcb --- /dev/null +++ b/Server/ws/voice_handlers.go @@ -0,0 +1,862 @@ +package ws + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/pion/webrtc/v4" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// Voice rate limit settings. +const ( + voiceSignalRateLimit = 20 + voiceSignalWindow = time.Second + voiceICERateLimit = 50 // ICE candidates arrive in bursts during connection setup + voiceICEWindow = time.Second + soundboardRateLimit = 1 + soundboardWindow = 3 * time.Second + voiceCameraRateLimit = 2 + voiceCameraWindow = time.Second + voiceScreenshareRateLimit = 2 + voiceScreenshareWindow = time.Second +) + +// setupICEMonitor monitors ICE connection state changes on the client's +// PeerConnection. On failure/disconnect, it cleans up voice state. +func (h *Hub) setupICEMonitor(c *Client, channelID int64) { + pc := c.getPC() + if pc == nil { + return + } + + pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { + // Guard: ignore stale events from old PeerConnections after channel switch + if c.getPC() != pc { + slog.Debug("ignoring stale ICE event from old PC", "user_id", c.userID, "channel_id", channelID, "state", state.String()) + return + } + + slog.Info("ICE state change", "user_id", c.userID, "channel_id", channelID, "state", state.String()) + + switch state { + case webrtc.ICEConnectionStateFailed: + slog.Warn("ICE connection failed, cleaning up voice", "user_id", c.userID, "channel_id", channelID) + if c.getVoiceChID() != 0 { + h.handleVoiceLeave(c) + } + case webrtc.ICEConnectionStateClosed: + // Closed means the PC was shut down (client destroyed it). + // Safety net: only clean up if voice_leave hasn't already done it. + if c.getVoiceChID() != 0 { + slog.Info("ICE connection closed, cleaning up voice", "user_id", c.userID, "channel_id", channelID) + h.handleVoiceLeave(c) + } + case webrtc.ICEConnectionStateDisconnected: + // Disconnected is transient — ICE may recover. + // Log but don't clean up immediately. + slog.Info("ICE disconnected (may recover)", "user_id", c.userID, "channel_id", channelID) + } + }) +} + +// SetupICEMonitorForTest exposes setupICEMonitor for tests. +func (h *Hub) SetupICEMonitorForTest(c *Client, channelID int64) { + h.setupICEMonitor(c, channelID) +} + +// setupICECallback registers an OnICECandidate handler on the client's +// PeerConnection to send server-generated ICE candidates to the client. +func (h *Hub) setupICECallback(c *Client, channelID int64) { + pc := c.getPC() + if pc == nil { + return + } + pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate == nil { + slog.Debug("ICE gathering complete", "user_id", c.userID, "channel_id", channelID) + return + } + slog.Debug("SFU ICE candidate generated", + "user_id", c.userID, + "type", candidate.Typ.String(), + "address", candidate.Address, + "port", candidate.Port, + "protocol", candidate.Protocol.String()) + c.sendMsg(buildVoiceICE(channelID, candidate.ToJSON())) + }) +} + +// renegotiateParticipant creates a new SDP offer for the given client +// and sends it as voice_offer. Implements the "impolite" side of +// Perfect Negotiation — skips if PC is in have-remote-offer state. +func (h *Hub) renegotiateParticipant(c *Client) { + pc := c.getPC() + if pc == nil { + return + } + + // Perfect Negotiation: server is impolite — skip if we're already + // mid-negotiation (client sent us an offer, or we sent one and are + // waiting for an answer). + state := pc.SignalingState() + if state == webrtc.SignalingStateHaveRemoteOffer { + slog.Info("renegotiate skipped: have-remote-offer", + "user_id", c.userID) + return + } + if state == webrtc.SignalingStateHaveLocalOffer { + // Roll back our pending offer so we can create a fresh one + // that includes all current tracks. + if err := pc.SetLocalDescription(webrtc.SessionDescription{ + Type: webrtc.SDPTypeRollback, + }); err != nil { + slog.Error("renegotiateParticipant rollback failed", + "err", err, "user_id", c.userID) + return + } + } + + offer, err := pc.CreateOffer(nil) + if err != nil { + slog.Error("renegotiateParticipant CreateOffer", + "err", err, "user_id", c.userID) + return + } + + if err := pc.SetLocalDescription(offer); err != nil { + slog.Error("renegotiateParticipant SetLocalDescription", + "err", err, "user_id", c.userID) + return + } + + channelID := c.getVoiceChID() + c.sendMsg(buildVoiceOffer(channelID, offer.SDP)) +} + +// handleVoiceJoin processes a voice_join message. +// 1. Parses channel_id. +// 2. Checks CONNECT_VOICE permission. +// 3. If already in a different voice channel, leaves it first. +// 4. Gets or creates VoiceRoom with config from channel settings. +// 5. Adds participant to VoiceRoom (checks capacity). +// 6. Persists join in DB. +// 7. Creates PeerConnection if SFU is available. +// 8. Broadcasts voice_state to channel. +// 9. Sends existing voice states to joiner. +// 10. Sends voice_config to joiner. +func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { + channelID, err := parseChannelID(payload) + if err != nil || channelID <= 0 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer")) + return + } + + if !h.requireChannelPerm(c, channelID, permissions.ConnectVoice, "CONNECT_VOICE") { + return + } + + currentChID := c.getVoiceChID() + + // HIGH-2: If user is already in the same voice channel, no-op. + if currentChID == channelID { + c.sendMsg(buildErrorMsg("ALREADY_JOINED", "already in this voice channel")) + return + } + + // If user is already in a different voice channel, leave it first. + if currentChID > 0 { + h.handleVoiceLeave(c) + } + + ch, err := h.db.GetChannel(channelID) + if err != nil || ch == nil { + c.sendMsg(buildErrorMsg("NOT_FOUND", "channel not found")) + return + } + + roomCfg := h.buildVoiceRoomConfig(ch) + room := h.GetOrCreateVoiceRoom(channelID, roomCfg) + + if addErr := room.AddParticipant(c.userID); addErr != nil { + if errors.Is(addErr, ErrRoomFull) { + c.sendMsg(buildErrorMsg("CHANNEL_FULL", "voice channel is full")) + } else { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to join voice channel")) + } + return + } + + if err := h.db.JoinVoiceChannel(c.userID, channelID); err != nil { + room.RemoveParticipant(c.userID) + slog.Error("ws handleVoiceJoin JoinVoiceChannel", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to join voice channel")) + return + } + + // Create PeerConnection if SFU is available. Non-fatal on failure. + var pc *webrtc.PeerConnection + if h.sfu != nil { + var pcErr error + pc, pcErr = h.sfu.NewPeerConnection() + if pcErr != nil { + slog.Error("ws handleVoiceJoin NewPeerConnection", "err", pcErr, "user_id", c.userID) + } + } + + // Track the voice channel and PC on the client atomically (CRIT-1 fix). + c.setVoice(channelID, pc) + + // Add existing tracks to the new joiner's PC so they hear + // participants who joined before them. + if pc != nil { + existingTracks := room.GetTracks() + addedExisting := 0 + for _, vt := range existingTracks { + if vt.Local == nil || vt.UserID == c.userID { + continue + } + sender, addErr := pc.AddTrack(vt.Local) + if addErr != nil { + slog.Error("handleVoiceJoin AddTrack existing", + "err", addErr, + "from", vt.UserID, "to", c.userID) + continue + } + vt.AddSender(c.userID, sender) + addedExisting++ + } + slog.Info("existing tracks added to new joiner", + "user_id", c.userID, + "existing_tracks_total", len(existingTracks), + "tracks_added", addedExisting) + } + + if pc != nil { + h.setupOnTrack(c, channelID) + h.setupICEMonitor(c, channelID) + h.setupICECallback(c, channelID) + } + + state, err := h.db.GetVoiceState(c.userID) + if err != nil || state == nil { + slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID) + return + } + + // Broadcast the joiner's state to all connected clients so every sidebar updates. + h.BroadcastToAll(buildVoiceState(*state)) + + // Send existing channel voice states to the joiner. + existing, err := h.db.GetChannelVoiceStates(channelID) + if err != nil { + slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err) + return + } + for _, vs := range existing { + if vs.UserID == c.userID { + continue + } + c.sendMsg(buildVoiceState(vs)) + } + + // Send voice_config to the joiner with room settings. + quality := roomCfg.Quality + bitrate := 64000 // default medium + if h.sfu != nil { + bitrate = h.sfu.QualityBitrate() + } + c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, room.Mode(), roomCfg.MixingThreshold, roomCfg.TopSpeakers, roomCfg.MaxUsers)) + + slog.Info("voice join", "user_id", c.userID, "channel_id", channelID, "participants", room.ParticipantCount(), "mode", room.Mode()) +} + +// buildVoiceRoomConfig constructs a VoiceRoomConfig from channel settings and server defaults. +func (h *Hub) buildVoiceRoomConfig(ch *db.Channel) VoiceRoomConfig { + cfg := VoiceRoomConfig{ + ChannelID: ch.ID, + MaxUsers: ch.VoiceMaxUsers, + Quality: "medium", + MixingThreshold: 10, + TopSpeakers: 3, + MaxVideo: ch.VoiceMaxVideo, + } + if ch.VoiceQuality != nil && *ch.VoiceQuality != "" { + cfg.Quality = *ch.VoiceQuality + } + if ch.MixingThreshold != nil { + cfg.MixingThreshold = *ch.MixingThreshold + } + return cfg +} + +// handleVoiceLeave processes an explicit voice_leave message or a disconnect. +// 1. Reads current voice state (for broadcast). +// 2. Closes PeerConnection if active. +// 3. Removes participant from VoiceRoom; removes room if empty. +// 4. Removes voice state from DB. +// 5. Broadcasts voice_leave to the channel the user was in. +func (h *Hub) handleVoiceLeave(c *Client) { + state, err := h.db.GetVoiceState(c.userID) + if err != nil { + slog.Error("ws handleVoiceLeave GetVoiceState", "err", err, "user_id", c.userID) + } + + // Atomically clear voice state and get old values for cleanup (CRIT-1 fix). + oldChID, oldPC := c.clearVoice() + + // Close PeerConnection if active. + // This also causes any setupOnTrack goroutine to exit via track.Read error (HIGH-1). + if oldPC != nil { + if closeErr := oldPC.Close(); closeErr != nil { + slog.Error("ws handleVoiceLeave pc.Close", "err", closeErr, "user_id", c.userID) + } + } + + // Remove this user's track from all subscribers' PCs. + // Done AFTER oldPC.Close() so the RTP goroutine has exited. + if oldChID > 0 { + if room := h.GetVoiceRoom(oldChID); room != nil { + vt := room.RemoveTrack(c.userID) + if vt != nil { + senders := vt.CopySenders() + for subID, sender := range senders { + sub := h.GetClient(subID) + if sub == nil { + continue + } + subPC := sub.getPC() + if subPC == nil { + continue + } + if rmErr := subPC.RemoveTrack(sender); rmErr != nil { + slog.Error("handleVoiceLeave RemoveTrack", + "err", rmErr, "user_id", subID) + } + h.renegotiateParticipant(sub) + } + } + } + } + + // Remove from VoiceRoom and clean up empty rooms. + if oldChID > 0 { + if room := h.GetVoiceRoom(oldChID); room != nil { + room.RemoveParticipant(c.userID) + if room.IsEmpty() { + h.RemoveVoiceRoom(oldChID) + } + } + } + + if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil { + slog.Error("ws handleVoiceLeave LeaveVoiceChannel", "err", leaveErr, "user_id", c.userID) + } + + if state != nil { + h.BroadcastToAll(buildVoiceLeave(state.ChannelID, c.userID)) + } +} + +// handleVoiceMute processes a voice_mute message. +// 1. Parses muted bool. +// 2. Updates DB. +// 3. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceMute(c *Client, payload json.RawMessage) { + var p struct { + Muted bool `json:"muted"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_mute payload")) + return + } + + if err := h.db.UpdateVoiceMute(c.userID, p.Muted); err != nil { + slog.Error("ws handleVoiceMute UpdateVoiceMute", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to update mute state")) + return + } + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceDeafen processes a voice_deafen message. +// 1. Parses deafened bool. +// 2. Updates DB. +// 3. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) { + var p struct { + Deafened bool `json:"deafened"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_deafen payload")) + return + } + + if err := h.db.UpdateVoiceDeafen(c.userID, p.Deafened); err != nil { + slog.Error("ws handleVoiceDeafen UpdateVoiceDeafen", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to update deafen state")) + return + } + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceCamera processes a voice_camera message. +// 1. Rate limits at 2/sec per user. +// 2. Checks USE_VIDEO permission. +// 3. Parses enabled bool. +// 4. Updates DB. +// 5. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_camera:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) { + c.sendMsg(buildRateLimitError("too many camera toggles", voiceCameraWindow.Seconds())) + return + } + + voiceChID := c.getVoiceChID() + if voiceChID == 0 { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) + return + } + + if !h.requireChannelPerm(c, voiceChID, permissions.UseVideo, "USE_VIDEO") { + return + } + + var p struct { + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_camera payload")) + return + } + + if err := h.db.UpdateVoiceCamera(c.userID, p.Enabled); err != nil { + slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to update camera state")) + return + } + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceScreenshare processes a voice_screenshare message. +// 1. Rate limits at 2/sec per user. +// 2. Checks SHARE_SCREEN permission. +// 3. Parses enabled bool. +// 4. Updates DB. +// 5. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_screenshare:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceScreenshareRateLimit, voiceScreenshareWindow) { + c.sendMsg(buildRateLimitError("too many screenshare toggles", voiceScreenshareWindow.Seconds())) + return + } + + voiceChID := c.getVoiceChID() + if voiceChID == 0 { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) + return + } + + if !h.requireChannelPerm(c, voiceChID, permissions.ShareScreen, "SHARE_SCREEN") { + return + } + + var p struct { + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_screenshare payload")) + return + } + + if err := h.db.UpdateVoiceScreenshare(c.userID, p.Enabled); err != nil { + slog.Error("ws handleVoiceScreenshare UpdateVoiceScreenshare", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to update screenshare state")) + return + } + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceOffer processes a voice_offer from the client. +// The client sends an SDP offer; the server sets it as remote description +// on the client's PeerConnection, creates an answer, and sends it back. +func (h *Hub) handleVoiceOffer(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_signal:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) { + c.sendMsg(buildRateLimitError("too many signaling messages", voiceSignalWindow.Seconds())) + return + } + + pc := c.getPC() + if pc == nil { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) + return + } + + var p struct { + ChannelID json.Number `json:"channel_id"` + SDP string `json:"sdp"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_offer payload")) + return + } + if p.SDP == "" { + c.sendMsg(buildErrorMsg("INVALID_SDP", "SDP is required")) + return + } + + offer := webrtc.SessionDescription{ + Type: webrtc.SDPTypeOffer, + SDP: p.SDP, + } + + // Perfect Negotiation: if we already have a pending local offer (glare + // condition — server and client sent offers simultaneously), roll back + // ours so we can accept the client's offer. + if pc.SignalingState() == webrtc.SignalingStateHaveLocalOffer { + if err := pc.SetLocalDescription(webrtc.SessionDescription{ + Type: webrtc.SDPTypeRollback, + }); err != nil { + slog.Error("ws handleVoiceOffer rollback failed", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to resolve signaling conflict")) + return + } + slog.Info("handleVoiceOffer rolled back local offer (glare)", "user_id", c.userID) + } + + if err := pc.SetRemoteDescription(offer); err != nil { + slog.Error("ws handleVoiceOffer SetRemoteDescription", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INVALID_SDP", "failed to set remote description")) + return + } + + answer, err := pc.CreateAnswer(nil) + if err != nil { + slog.Error("ws handleVoiceOffer CreateAnswer", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to create answer")) + return + } + + if err := pc.SetLocalDescription(answer); err != nil { + slog.Error("ws handleVoiceOffer SetLocalDescription", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to set local description")) + return + } + + // Send the answer back to the client. + c.sendMsg(buildVoiceAnswer(c.getVoiceChID(), answer.SDP)) +} + +// handleVoiceAnswer processes a voice_answer from the client. +// This handles the case where the server sent an offer (e.g., renegotiation) +// and the client responds with an answer. +func (h *Hub) handleVoiceAnswer(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_signal:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) { + c.sendMsg(buildRateLimitError("too many signaling messages", voiceSignalWindow.Seconds())) + return + } + + pc := c.getPC() + if pc == nil { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) + return + } + + var p struct { + ChannelID json.Number `json:"channel_id"` + SDP string `json:"sdp"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_answer payload")) + return + } + if p.SDP == "" { + c.sendMsg(buildErrorMsg("INVALID_SDP", "SDP is required")) + return + } + + answer := webrtc.SessionDescription{ + Type: webrtc.SDPTypeAnswer, + SDP: p.SDP, + } + + if err := pc.SetRemoteDescription(answer); err != nil { + slog.Error("ws handleVoiceAnswer SetRemoteDescription", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INVALID_SDP", "failed to set remote description")) + return + } +} + +// handleVoiceICE processes a voice_ice (ICE candidate) from the client. +func (h *Hub) handleVoiceICE(c *Client, payload json.RawMessage) { + // ICE candidates use a separate, higher rate limit — they arrive in bursts + // during connection setup and are mandatory for connectivity. + ratKey := fmt.Sprintf("voice_ice:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceICERateLimit, voiceICEWindow) { + c.sendMsg(buildRateLimitError("too many ICE candidates", voiceICEWindow.Seconds())) + return + } + + pc := c.getPC() + if pc == nil { + c.sendMsg(buildErrorMsg("VOICE_ERROR", "not in a voice channel")) + return + } + + var p struct { + ChannelID json.Number `json:"channel_id"` + Candidate webrtc.ICECandidateInit `json:"candidate"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_ice payload")) + return + } + + slog.Debug("client ICE candidate received", + "user_id", c.userID, + "candidate", p.Candidate.Candidate) + if err := pc.AddICECandidate(p.Candidate); err != nil { + slog.Error("ws handleVoiceICE AddICECandidate", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to add ICE candidate")) + return + } +} + +// handleSoundboard processes a soundboard_play message. +// 1. Rate limits at 1 per 3 seconds. +// 2. Checks USE_SOUNDBOARD permission. +// 3. Broadcasts soundboard_play (with user_id) to all connected clients. +func (h *Hub) handleSoundboard(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("soundboard:%d", c.userID) + if !h.limiter.Allow(ratKey, soundboardRateLimit, soundboardWindow) { + c.sendMsg(buildErrorMsg("RATE_LIMITED", "soundboard is on cooldown")) + return + } + + // channelID=0: soundboard is a server-wide permission with no per-channel + // override. The client does not send a channel_id in the payload. + if !h.requireChannelPerm(c, 0, permissions.UseSoundboard, "USE_SOUNDBOARD") { + return + } + + var p struct { + SoundID string `json:"sound_id"` + } + if err := json.Unmarshal(payload, &p); err != nil || p.SoundID == "" { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "sound_id is required")) + return + } + + h.BroadcastToAll(buildSoundboardPlay(p.SoundID, c.userID)) +} + +// setupOnTrack configures the PeerConnection's OnTrack handler to: +// 1. Create a TrackLocalStaticRTP for SFU fan-out. +// 2. Store it on the VoiceRoom as a VoiceTrack. +// 3. Add the local track to all other participants' PCs and renegotiate. +// 4. Forward RTP packets while parsing audio levels for speaker detection. +// +// Must be called after c.pc is set and before SDP negotiation completes. +func (h *Hub) setupOnTrack(c *Client, channelID int64) { + pc := c.getPC() + if pc == nil { + return + } + + pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { + if track.Kind() != webrtc.RTPCodecTypeAudio { + return + } + + slog.Info("SFU OnTrack", + "user_id", c.userID, + "channel_id", channelID, + "codec", track.Codec().MimeType, + ) + + // Create local track for fan-out using the remote track's codec. + local, err := webrtc.NewTrackLocalStaticRTP( + track.Codec().RTPCodecCapability, + fmt.Sprintf("audio-%d", c.userID), + fmt.Sprintf("user-%d", c.userID), + ) + if err != nil { + slog.Error("setupOnTrack NewTrackLocalStaticRTP", + "err", err, "user_id", c.userID) + return + } + + room := h.GetVoiceRoom(channelID) + if room == nil { + return + } + + // Store track on room. + room.SetTrack(c.userID, track, local) + vt := room.GetTrack(c.userID) + + // Collect other participant IDs (lock ordering: VoiceRoom.mu released before voiceMu). + participantIDs := room.ParticipantIDs() + + // Add local track to each other participant's PC. + addedCount := 0 + for _, pid := range participantIDs { + if pid == c.userID { + continue + } + other := h.GetClient(pid) + if other == nil { + slog.Debug("setupOnTrack: participant not found", "from", c.userID, "to", pid) + continue + } + otherPC := other.getPC() + if otherPC == nil { + slog.Debug("setupOnTrack: participant has no PC", "from", c.userID, "to", pid) + continue + } + sender, addErr := otherPC.AddTrack(local) + if addErr != nil { + slog.Error("setupOnTrack AddTrack", + "err", addErr, + "from", c.userID, "to", pid) + continue + } + if vt != nil { + vt.AddSender(pid, sender) + } + addedCount++ + h.renegotiateParticipant(other) + } + slog.Info("SFU track fan-out", + "from_user", c.userID, + "channel_id", channelID, + "participants", len(participantIDs), + "tracks_added", addedCount) + + // Log transceiver state on each subscriber's PC for this track + for _, pid := range participantIDs { + if pid == c.userID { + continue + } + other := h.GetClient(pid) + if other == nil { + continue + } + otherPC := other.getPC() + if otherPC == nil { + continue + } + for _, tr := range otherPC.GetTransceivers() { + if tr.Sender() != nil && tr.Sender().Track() != nil && + tr.Sender().Track().StreamID() == fmt.Sprintf("user-%d", c.userID) { + slog.Info("subscriber transceiver state", + "subscriber", pid, + "track_from", c.userID, + "direction", tr.Direction().String(), + "mid", tr.Mid(), + "sender_track_id", tr.Sender().Track().ID(), + "sender_track_stream", tr.Sender().Track().StreamID()) + } + } + } + + // RTP forwarding + audio level goroutine. + // Capture the done channel so this goroutine exits even if PC.Close fails. + done := c.getVoiceDone() + go func() { + buf := make([]byte, 1500) + var pktCount uint64 + + // Warn if no RTP packets arrive within 5 seconds + noPacketTimer := time.AfterFunc(5*time.Second, func() { + slog.Warn("RTP: no packets received after 5s", + "user_id", c.userID, + "channel_id", channelID) + }) + defer noPacketTimer.Stop() + + for { + // Check if voice session was torn down. + select { + case <-done: + slog.Info("RTP goroutine exiting via done signal", + "user_id", c.userID, "channel_id", channelID, + "packets_forwarded", pktCount) + return + default: + } + + n, _, readErr := track.Read(buf) + if readErr != nil { + slog.Info("RTP read ended", + "user_id", c.userID, + "channel_id", channelID, + "packets_forwarded", pktCount, + "err", readErr.Error()) + return + } + + // Forward RTP to local track (Pion fans out to all subscribers). + if _, writeErr := local.Write(buf[:n]); writeErr != nil { + slog.Info("RTP write ended", + "user_id", c.userID, + "channel_id", channelID, + "packets_forwarded", pktCount, + "err", writeErr.Error()) + return + } + pktCount++ + if pktCount == 1 { + noPacketTimer.Stop() + slog.Info("RTP first packet received", + "user_id", c.userID, + "channel_id", channelID, + "bytes", n) + } else if pktCount%1000 == 0 { + slog.Info("RTP forwarding", + "user_id", c.userID, + "channel_id", channelID, + "packets", pktCount) + } + + // Extract audio level directly from raw RTP bytes (avoids full Unmarshal). + level, ok := extractAudioLevel(buf, n) + if !ok { + continue + } + + currentRoom := h.GetVoiceRoom(channelID) + if currentRoom == nil { + return + } + currentRoom.UpdateSpeakerLevel(c.userID, level) + } + }() + }) +} + +// broadcastVoiceStateUpdate fetches the current voice state for the client +// and broadcasts it to all members of the voice channel they are in. +func (h *Hub) broadcastVoiceStateUpdate(c *Client) { + state, err := h.db.GetVoiceState(c.userID) + if err != nil { + slog.Error("ws broadcastVoiceStateUpdate GetVoiceState", "err", err, "user_id", c.userID) + return + } + if state == nil { + return // user not in a voice channel — nothing to broadcast + } + h.BroadcastToAll(buildVoiceState(*state)) +} diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go new file mode 100644 index 00000000..a19a95a5 --- /dev/null +++ b/Server/ws/voice_handlers_test.go @@ -0,0 +1,1595 @@ +package ws_test + +import ( + "encoding/json" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/ws" +) + +// voiceSchema extends hubTestSchema with the voice_states table. +var voiceSchema = append(hubTestSchema, []byte(` +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screenshare INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); +`)...) + +// openVoiceTestDB opens an in-memory DB with the full voice schema. +func openVoiceTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: voiceSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// newVoiceHub creates a hub+db suitable for voice handler tests. +func newVoiceHub(t *testing.T) (*ws.Hub, *db.DB) { + t.Helper() + database := openVoiceTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + return hub, database +} + +// seedVoiceOwner inserts an Owner-role user for permission-passing tests. +func seedVoiceOwner(t *testing.T, database *db.DB, username string) *db.User { + t.Helper() + _, err := database.CreateUser(username, "hash", 1) // roleID=1 → Owner + if err != nil { + t.Fatalf("seedVoiceOwner CreateUser: %v", err) + } + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + t.Fatalf("seedVoiceOwner GetUserByUsername: %v", err) + } + return user +} + +// seedVoiceChan creates a voice-type channel. +func seedVoiceChan(t *testing.T, database *db.DB, name string) int64 { + t.Helper() + id, err := database.CreateChannel(name, "voice", "", "", 0) + if err != nil { + t.Fatalf("seedVoiceChan: %v", err) + } + return id +} + +// voiceJoinMsg builds a raw voice_join WebSocket message. +func voiceJoinMsg(channelID int64) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{"channel_id": channelID}, + }) + return raw +} + +// voiceLeaveMsg builds a raw voice_leave WebSocket message. +func voiceLeaveMsg() []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_leave", + "payload": map[string]any{}, + }) + return raw +} + +// voiceMuteMsg builds a voice_mute message. +func voiceMuteMsg(muted bool) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_mute", + "payload": map[string]any{"muted": muted}, + }) + return raw +} + +// voiceDeafenMsg builds a voice_deafen message. +func voiceDeafenMsg(deafened bool) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_deafen", + "payload": map[string]any{"deafened": deafened}, + }) + return raw +} + +// voiceSignalMsg builds a voice_offer/answer/ice message. +func voiceSignalMsg(msgType string, channelID int64, sdp string) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": msgType, + "payload": map[string]any{ + "channel_id": channelID, + "sdp": sdp, + }, + }) + return raw +} + +// voiceICEMsg builds a voice_ice message. +func voiceICEMsg(channelID int64, candidate string) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_ice", + "payload": map[string]any{ + "channel_id": channelID, + "candidate": candidate, + }, + }) + return raw +} + +// extractType parses a JSON message and returns the "type" field. +func extractType(t *testing.T, msg []byte) string { + t.Helper() + var env map[string]any + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("extractType unmarshal: %v", err) + } + typ, _ := env["type"].(string) + return typ +} + +// extractCode parses a JSON error message and returns the payload "code" field. +// Returns an empty string if the message is not an error envelope. +func extractCode(t *testing.T, msg []byte) string { + t.Helper() + var env struct { + Type string `json:"type"` + Payload struct { + Code string `json:"code"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + return "" + } + if env.Type != "error" { + return "" + } + return env.Payload.Code +} + +// drainChan reads all pending messages from ch into a slice. +func drainChan(ch <-chan []byte) [][]byte { + var msgs [][]byte + for { + select { + case m := <-ch: + msgs = append(msgs, m) + default: + return msgs + } + } +} + +// ─── voice_join ─────────────────────────────────────────────────────────────── + +func TestVoice_Join_SetsStateInDB(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "alice") + chanID := seedVoiceChan(t, database, "vc-alice") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil { + t.Fatal("voice state is nil after voice_join") + } + if state.ChannelID != chanID { + t.Errorf("ChannelID = %d, want %d", state.ChannelID, chanID) + } +} + +func TestVoice_Join_BroadcastsVoiceState(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "bob") + chanID := seedVoiceChan(t, database, "vc-bob") + + // A second client in the same voice channel to receive the broadcast. + send2 := make(chan []byte, 16) + user2 := seedVoiceOwner(t, database, "bob2") + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + // Look for a voice_state message in either send or send2. + foundVoiceState := false + allMsgs := append(drainChan(send), drainChan(send2)...) + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_state" { + foundVoiceState = true + break + } + } + if !foundVoiceState { + t.Error("voice_state broadcast not received after voice_join") + } +} + +func TestVoice_Join_SendsCurrentStatesToJoiner(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-existing") + + // user1 joins first. + user1 := seedVoiceOwner(t, database, "carol1") + send1 := make(chan []byte, 16) + c1 := ws.NewTestClientWithUser(hub, user1, chanID, send1) + hub.Register(c1) + time.Sleep(20 * time.Millisecond) + hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + // Drain send1 to clear join broadcast. + drainChan(send1) + + // user2 joins — should receive voice_state for user1. + user2 := seedVoiceOwner(t, database, "carol2") + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + // user2 should have received a voice_state for user1. + msgs2 := drainChan(send2) + voiceStateCount := 0 + for _, msg := range msgs2 { + if extractType(t, msg) == "voice_state" { + voiceStateCount++ + } + } + if voiceStateCount == 0 { + t.Error("joining client did not receive existing voice states") + } +} + +func TestVoice_Join_MissingChannelID_SendsError(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "dave") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + badMsg, _ := json.Marshal(map[string]any{ + "type": "voice_join", + "payload": map[string]any{"channel_id": 0}, + }) + hub.HandleMessageForTest(c, badMsg) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractType(t, m) == "error" { + found = true + } + } + if !found { + t.Error("expected error response for invalid channel_id") + } +} + +func TestVoice_Join_NoPermission_SendsError(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-noperm") + + // Member role (id=4) has permissions 1635 (0x663). Bit 9 (0x200 = 512) for CONNECT_VOICE. + // Check if member has it: 1635 & 512 = 512, so member DOES have it. + // We need a role without it. We'll set a custom role using direct DB exec. + // For simplicity, use a user with nil user (no role) to fail perm check. + send := make(chan []byte, 16) + c := ws.NewTestClient(hub, 9999, send) // no user set → hasChannelPerm returns false + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractType(t, m) == "error" { + found = true + } + } + if !found { + t.Error("expected FORBIDDEN error for client without CONNECT_VOICE permission") + } +} + +// ─── voice_leave ────────────────────────────────────────────────────────────── + +func TestVoice_Leave_ClearsStateInDB(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "eve") + chanID := seedVoiceChan(t, database, "vc-eve") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceLeaveMsg()) + time.Sleep(30 * time.Millisecond) + + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState after leave: %v", err) + } + if state != nil { + t.Error("voice state still set after voice_leave") + } +} + +func TestVoice_Leave_BroadcastsVoiceLeave(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-leave-bcast") + + user := seedVoiceOwner(t, database, "frank") + user2 := seedVoiceOwner(t, database, "frank2") + + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + drainChan(send2) + + hub.HandleMessageForTest(c, voiceLeaveMsg()) + time.Sleep(50 * time.Millisecond) + + allMsgs := append(drainChan(send), drainChan(send2)...) + found := false + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_leave" { + found = true + break + } + } + if !found { + t.Error("voice_leave broadcast not received after voice_leave message") + } +} + +// ─── voice_mute ─────────────────────────────────────────────────────────────── + +func TestVoice_Mute_UpdatesStateInDB(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "grace") + chanID := seedVoiceChan(t, database, "vc-grace") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceMuteMsg(true)) + time.Sleep(30 * time.Millisecond) + + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || !state.Muted { + t.Error("Muted = false after voice_mute(true)") + } +} + +func TestVoice_Mute_BroadcastsVoiceState(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-mute-bcast") + + user := seedVoiceOwner(t, database, "henry") + user2 := seedVoiceOwner(t, database, "henry2") + + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + drainChan(send2) + + hub.HandleMessageForTest(c, voiceMuteMsg(true)) + time.Sleep(50 * time.Millisecond) + + allMsgs := append(drainChan(send), drainChan(send2)...) + found := false + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_state" { + found = true + break + } + } + if !found { + t.Error("voice_state broadcast not received after voice_mute") + } +} + +// ─── voice_deafen ───────────────────────────────────────────────────────────── + +func TestVoice_Deafen_UpdatesStateInDB(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "iris") + chanID := seedVoiceChan(t, database, "vc-iris") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceDeafenMsg(true)) + time.Sleep(30 * time.Millisecond) + + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || !state.Deafened { + t.Error("Deafened = false after voice_deafen(true)") + } +} + +func TestVoice_Deafen_BroadcastsVoiceState(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-deafen-bcast") + + user := seedVoiceOwner(t, database, "jack") + user2 := seedVoiceOwner(t, database, "jack2") + + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + drainChan(send2) + + hub.HandleMessageForTest(c, voiceDeafenMsg(true)) + time.Sleep(50 * time.Millisecond) + + allMsgs := append(drainChan(send), drainChan(send2)...) + found := false + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_state" { + found = true + break + } + } + if !found { + t.Error("voice_state broadcast not received after voice_deafen") + } +} + +// ─── voice signaling (SFU) ──────────────────────────────────────────────────── +// +// The signaling flow changed from P2P relay to SFU: offer/answer/ice are now +// exchanged between client and server, not relayed between clients. +// +// Tests focus on validation and error paths since PeerConnection operations +// require a real WebRTC stack (only exercised in integration tests). + +// TestVoice_Offer_NoPeerConnection verifies that voice_offer when the client +// has no PeerConnection returns a VOICE_ERROR. +func TestVoice_Offer_NoPeerConnection(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "offer-nopc") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "v=0 offer...")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractCode(t, m) == "VOICE_ERROR" { + found = true + break + } + } + if !found { + t.Error("expected VOICE_ERROR when sending voice_offer without a PeerConnection") + } +} + +// TestVoice_Offer_EmptySDP verifies that voice_offer with an empty SDP field +// returns INVALID_SDP before touching any PeerConnection. +func TestVoice_Offer_EmptySDP(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "offer-emptysdp") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send offer with blank SDP — pc is nil but SDP check comes after pc check, + // so we expect VOICE_ERROR (no pc) before INVALID_SDP would fire. + // To isolate the empty-SDP path we need a client with pc set. Since we + // can't construct a real PC in unit tests, we verify the pc==nil branch + // fires first, which returns VOICE_ERROR. The INVALID_SDP branch is + // separately reachable; we test its message format via the handler directly. + hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + if len(msgs) == 0 { + t.Fatal("expected at least one error response for voice_offer with no pc") + } + code := extractCode(t, msgs[0]) + if code != "VOICE_ERROR" && code != "INVALID_SDP" { + t.Errorf("expected VOICE_ERROR or INVALID_SDP, got %q", code) + } +} + +// TestVoice_Offer_RateLimit verifies that sending 25+ voice_offer messages +// rapidly results in at least one RATE_LIMITED error being sent back to the +// client. +func TestVoice_Offer_RateLimit(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "offer-ratelimit") + + send := make(chan []byte, 256) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // 25 offers rapidly — limit is 20/sec. + for range 25 { + hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "v=0...")) + } + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractCode(t, m) == "RATE_LIMITED" { + found = true + break + } + } + if !found { + t.Error("expected RATE_LIMITED error after 25 rapid voice_offer messages") + } +} + +// TestVoice_Answer_NoPeerConnection verifies that voice_answer when the client +// has no PeerConnection returns VOICE_ERROR. +func TestVoice_Answer_NoPeerConnection(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "answer-nopc") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceSignalMsg("voice_answer", 1, "v=0 answer...")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractCode(t, m) == "VOICE_ERROR" { + found = true + break + } + } + if !found { + t.Error("expected VOICE_ERROR when sending voice_answer without a PeerConnection") + } +} + +// TestVoice_Answer_EmptySDP verifies that voice_answer with blank SDP returns +// an error (VOICE_ERROR from pc==nil check, or INVALID_SDP if pc existed). +func TestVoice_Answer_EmptySDP(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "answer-emptysdp") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceSignalMsg("voice_answer", 1, "")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + if len(msgs) == 0 { + t.Fatal("expected at least one error response for empty voice_answer") + } + code := extractCode(t, msgs[0]) + if code != "VOICE_ERROR" && code != "INVALID_SDP" { + t.Errorf("expected VOICE_ERROR or INVALID_SDP, got %q", code) + } +} + +// TestVoice_ICE_NoPeerConnection verifies that voice_ice when the client has +// no PeerConnection returns VOICE_ERROR. +func TestVoice_ICE_NoPeerConnection(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ice-nopc") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceICEMsg(1, "candidate:0 1 UDP 123 192.168.1.1 5000 typ host")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractCode(t, m) == "VOICE_ERROR" { + found = true + break + } + } + if !found { + t.Error("expected VOICE_ERROR when sending voice_ice without a PeerConnection") + } +} + +// TestVoice_HandleMessage_VoiceOffer_Dispatched verifies that voice_offer is +// dispatched by handleMessage and does not produce an UNKNOWN_TYPE error. +func TestVoice_HandleMessage_VoiceOffer_Dispatched(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "offer-dispatch") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "v=0...")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + if extractCode(t, m) == "UNKNOWN_TYPE" { + t.Error("voice_offer produced UNKNOWN_TYPE — handler not registered in dispatch") + } + } +} + +// TestVoice_HandleMessage_VoiceAnswer_Dispatched verifies that voice_answer is +// dispatched by handleMessage and does not produce an UNKNOWN_TYPE error. +// This replaces the old TestVoice_HandleMessage_VoiceAnswer_Relayed which +// tested the removed P2P relay behavior. +func TestVoice_HandleMessage_VoiceAnswer_Dispatched(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "answer-dispatch") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceSignalMsg("voice_answer", 1, "v=0 answer...")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + if extractCode(t, m) == "UNKNOWN_TYPE" { + t.Error("voice_answer produced UNKNOWN_TYPE — handler not registered in dispatch") + } + } +} + +// TestVoice_HandleMessage_VoiceICE_Dispatched verifies that voice_ice is +// dispatched by handleMessage and does not produce an UNKNOWN_TYPE error. +func TestVoice_HandleMessage_VoiceICE_Dispatched(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ice-dispatch") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceICEMsg(1, "candidate:0 1 UDP 123 192.168.1.1 5000 typ host")) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + if extractCode(t, m) == "UNKNOWN_TYPE" { + t.Error("voice_ice produced UNKNOWN_TYPE — handler not registered in dispatch") + } + } +} + +// TestVoice_Signal_RateLimit_BlocksExcess verifies that rapid voice_offer +// messages get rate limited (replaces the old relay-counting test). +func TestVoice_Signal_RateLimit_BlocksExcess(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "mia") + + send := make(chan []byte, 256) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send 30 signals rapidly — limit is 20/sec, so some should be rate-limited. + for range 30 { + hub.HandleMessageForTest(c, voiceSignalMsg("voice_offer", 1, "v=0...")) + } + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + foundRateLimit := false + for _, m := range msgs { + if extractCode(t, m) == "RATE_LIMITED" { + foundRateLimit = true + break + } + } + if !foundRateLimit { + t.Error("expected RATE_LIMITED error after 30 rapid voice_offer messages") + } +} + +// ─── soundboard ─────────────────────────────────────────────────────────────── + +func TestVoice_Soundboard_BroadcastsToAll(t *testing.T) { + hub, database := newVoiceHub(t) + + user := seedVoiceOwner(t, database, "noah") + listener := seedVoiceOwner(t, database, "noah2") + + sendL := make(chan []byte, 16) + cL := ws.NewTestClientWithUser(hub, listener, 0, sendL) + hub.Register(cL) + + sendS := make(chan []byte, 16) + cS := ws.NewTestClientWithUser(hub, user, 0, sendS) + hub.Register(cS) + time.Sleep(20 * time.Millisecond) + + soundMsg, _ := json.Marshal(map[string]any{ + "type": "soundboard_play", + "payload": map[string]any{"sound_id": "abc-uuid-123"}, + }) + hub.HandleMessageForTest(cS, soundMsg) + time.Sleep(50 * time.Millisecond) + + listenerMsgs := drainChan(sendL) + found := false + for _, msg := range listenerMsgs { + if extractType(t, msg) == "soundboard_play" { + found = true + break + } + } + if !found { + t.Error("listener did not receive soundboard_play broadcast") + } +} + +func TestVoice_Soundboard_NoPermission_SendsError(t *testing.T) { + hub, _ := newVoiceHub(t) + + // Client with no user set → permission check fails. + send := make(chan []byte, 16) + c := ws.NewTestClient(hub, 8888, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + soundMsg, _ := json.Marshal(map[string]any{ + "type": "soundboard_play", + "payload": map[string]any{"sound_id": "abc"}, + }) + hub.HandleMessageForTest(c, soundMsg) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractType(t, m) == "error" { + found = true + } + } + if !found { + t.Error("expected FORBIDDEN error for soundboard without permission") + } +} + +func TestVoice_Soundboard_RateLimit(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "olivia") + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + soundMsg, _ := json.Marshal(map[string]any{ + "type": "soundboard_play", + "payload": map[string]any{"sound_id": "x"}, + }) + + // Send 5 soundboard plays rapidly — limit is 1 per 3 sec. + for range 5 { + hub.HandleMessageForTest(c, soundMsg) + } + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + errCount := 0 + for _, m := range msgs { + if extractType(t, m) == "error" { + errCount++ + } + } + if errCount == 0 { + t.Error("expected rate limit errors for rapid soundboard plays") + } +} + +// ─── voice_camera ───────────────────────────────────────────────────────────── + +// voiceCameraMsg builds a voice_camera WebSocket message. +func voiceCameraMsg(enabled bool) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_camera", + "payload": map[string]any{"enabled": enabled}, + }) + return raw +} + +// TestVoice_Camera_UpdatesState: join voice, send voice_camera {enabled:true}, +// verify voice_state broadcast includes camera:true. +func TestVoice_Camera_UpdatesState(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "cam-alice") + chanID := seedVoiceChan(t, database, "vc-cam-alice") + + user2 := seedVoiceOwner(t, database, "cam-alice2") + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Join voice channel first. + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + drainChan(send2) + + // Toggle camera on. + hub.HandleMessageForTest(c, voiceCameraMsg(true)) + time.Sleep(50 * time.Millisecond) + + // Verify DB state. + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || !state.Camera { + t.Error("Camera = false after voice_camera(true)") + } + + // Verify voice_state broadcast received by channel member. + allMsgs := append(drainChan(send), drainChan(send2)...) + foundVoiceState := false + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_state" { + foundVoiceState = true + + var env struct { + Type string `json:"type"` + Payload struct { + Camera bool `json:"camera"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal voice_state: %v", err) + } + if !env.Payload.Camera { + t.Error("voice_state broadcast payload.camera = false, want true") + } + break + } + } + if !foundVoiceState { + t.Error("voice_state broadcast not received after voice_camera toggle") + } +} + +// TestVoice_Camera_NoPermission: Member without USE_VIDEO gets FORBIDDEN. +func TestVoice_Camera_NoPermission(t *testing.T) { + hub, _ := newVoiceHub(t) + + // Client with no user set → hasChannelPerm returns false. + send := make(chan []byte, 16) + c := ws.NewTestClient(hub, 7001, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceCameraMsg(true)) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractType(t, m) == "error" { + found = true + } + } + if !found { + t.Error("expected FORBIDDEN error for camera toggle without USE_VIDEO permission") + } +} + +// TestVoice_Camera_RateLimit: send 3+ camera toggles rapidly, verify rate limit error. +func TestVoice_Camera_RateLimit(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "cam-ratelimit") + chanID := seedVoiceChan(t, database, "vc-cam-ratelimit") + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Send 5 camera toggles rapidly — limit is 2/sec, so some should be rate-limited. + for range 5 { + hub.HandleMessageForTest(c, voiceCameraMsg(true)) + } + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + errCount := 0 + for _, m := range msgs { + if extractType(t, m) == "error" { + errCount++ + } + } + if errCount == 0 { + t.Error("expected RATE_LIMITED error after exceeding camera rate limit") + } +} + +// ─── voice_screenshare ──────────────────────────────────────────────────────── + +// voiceScreenshareMsg builds a voice_screenshare WebSocket message. +func voiceScreenshareMsg(enabled bool) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_screenshare", + "payload": map[string]any{"enabled": enabled}, + }) + return raw +} + +// TestVoice_Screenshare_UpdatesState: join voice, send voice_screenshare {enabled:true}, +// verify voice_state broadcast includes screenshare:true. +func TestVoice_Screenshare_UpdatesState(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ss-alice") + chanID := seedVoiceChan(t, database, "vc-ss-alice") + + user2 := seedVoiceOwner(t, database, "ss-alice2") + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Join voice channel first. + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + drainChan(send2) + + // Toggle screenshare on. + hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) + time.Sleep(50 * time.Millisecond) + + // Verify DB state. + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || !state.Screenshare { + t.Error("Screenshare = false after voice_screenshare(true)") + } + + // Verify voice_state broadcast received. + allMsgs := append(drainChan(send), drainChan(send2)...) + foundVoiceState := false + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_state" { + foundVoiceState = true + + var env struct { + Type string `json:"type"` + Payload struct { + Screenshare bool `json:"screenshare"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal voice_state: %v", err) + } + if !env.Payload.Screenshare { + t.Error("voice_state broadcast payload.screenshare = false, want true") + } + break + } + } + if !foundVoiceState { + t.Error("voice_state broadcast not received after voice_screenshare toggle") + } +} + +// TestVoice_Screenshare_NoPermission: client without SHARE_SCREEN gets FORBIDDEN. +func TestVoice_Screenshare_NoPermission(t *testing.T) { + hub, _ := newVoiceHub(t) + + // Client with no user set → hasChannelPerm returns false. + send := make(chan []byte, 16) + c := ws.NewTestClient(hub, 7002, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractType(t, m) == "error" { + found = true + } + } + if !found { + t.Error("expected FORBIDDEN error for screenshare toggle without SHARE_SCREEN permission") + } +} + +// TestVoice_Screenshare_RateLimit: send 5+ screenshare toggles rapidly, verify rate limit error. +func TestVoice_Screenshare_RateLimit(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ss-ratelimit") + chanID := seedVoiceChan(t, database, "vc-ss-ratelimit") + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Send 5 screenshare toggles rapidly — limit is 2/sec. + for range 5 { + hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) + } + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + errCount := 0 + for _, m := range msgs { + if extractType(t, m) == "error" { + errCount++ + } + } + if errCount == 0 { + t.Error("expected RATE_LIMITED error after exceeding screenshare rate limit") + } +} + +// ─── handleMessage dispatch ─────────────────────────────────────────────────── + +// ─── SFU-integrated voice_join / voice_leave ────────────────────────────────── + +// seedVoiceChanMaxUsers creates a voice channel with a custom voice_max_users limit. +func seedVoiceChanMaxUsers(t *testing.T, database *db.DB, name string, maxUsers int) int64 { + t.Helper() + id, err := database.CreateChannel(name, "voice", "", "", 0) + if err != nil { + t.Fatalf("seedVoiceChanMaxUsers CreateChannel: %v", err) + } + if err := database.SetChannelVoiceMaxUsers(id, maxUsers); err != nil { + t.Fatalf("seedVoiceChanMaxUsers SetChannelVoiceMaxUsers: %v", err) + } + return id +} + +// TestVoice_Join_SFU_SendsVoiceConfig verifies that after voice_join the joiner +// receives a voice_config message with the expected fields. +func TestVoice_Join_SFU_SendsVoiceConfig(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "sfu-alice") + chanID := seedVoiceChan(t, database, "vc-sfu-alice") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + foundConfig := false + for _, msg := range msgs { + if extractType(t, msg) == "voice_config" { + foundConfig = true + var env struct { + Type string `json:"type"` + Payload struct { + ChannelID int64 `json:"channel_id"` + Quality string `json:"quality"` + Bitrate int `json:"bitrate"` + Mode string `json:"threshold_mode"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal voice_config: %v", err) + } + if env.Payload.ChannelID != chanID { + t.Errorf("voice_config channel_id = %d, want %d", env.Payload.ChannelID, chanID) + } + if env.Payload.Quality == "" { + t.Error("voice_config quality is empty") + } + if env.Payload.Bitrate <= 0 { + t.Errorf("voice_config bitrate = %d, want > 0", env.Payload.Bitrate) + } + if env.Payload.Mode == "" { + t.Error("voice_config threshold_mode is empty") + } + break + } + } + if !foundConfig { + t.Error("joiner did not receive voice_config after voice_join") + } +} + +// TestVoice_Join_SFU_ChannelFull verifies that a second join to a max-1 room +// returns a CHANNEL_FULL error and the first participant is unaffected. +func TestVoice_Join_SFU_ChannelFull(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChanMaxUsers(t, database, "vc-full", 1) + + user1 := seedVoiceOwner(t, database, "full-user1") + send1 := make(chan []byte, 32) + c1 := ws.NewTestClientWithUser(hub, user1, chanID, send1) + hub.Register(c1) + time.Sleep(20 * time.Millisecond) + + // First user joins — should succeed. + hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + // Verify first user is in DB. + state1, err := database.GetVoiceState(user1.ID) + if err != nil || state1 == nil { + t.Fatalf("user1 voice state missing after join: %v", err) + } + + user2 := seedVoiceOwner(t, database, "full-user2") + send2 := make(chan []byte, 32) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + + drainChan(send1) + drainChan(send2) + + // Second user joins — should get CHANNEL_FULL error. + hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + msgs2 := drainChan(send2) + foundFull := false + for _, msg := range msgs2 { + if extractType(t, msg) == "error" { + var env struct { + Payload struct { + Code string `json:"code"` + } `json:"payload"` + } + if errU := json.Unmarshal(msg, &env); errU == nil && env.Payload.Code == "CHANNEL_FULL" { + foundFull = true + break + } + } + } + if !foundFull { + t.Error("expected CHANNEL_FULL error when joining a full voice channel") + } + + // Second user should NOT be in DB voice state. + state2, err := database.GetVoiceState(user2.ID) + if err != nil { + t.Fatalf("GetVoiceState user2: %v", err) + } + if state2 != nil { + t.Error("user2 voice state should be nil after CHANNEL_FULL rejection") + } +} + +// TestVoice_Join_SFU_AddsToVoiceRoom verifies that after voice_join the +// participant is tracked in the Hub's VoiceRoom. +func TestVoice_Join_SFU_AddsToVoiceRoom(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "room-alice") + chanID := seedVoiceChan(t, database, "vc-room-alice") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + room := hub.GetVoiceRoom(chanID) + if room == nil { + t.Fatal("VoiceRoom not created after voice_join") + } + if !room.HasParticipant(user.ID) { + t.Error("user not tracked as participant in VoiceRoom after voice_join") + } + if room.ParticipantCount() != 1 { + t.Errorf("VoiceRoom participant count = %d, want 1", room.ParticipantCount()) + } +} + +// TestVoice_Leave_SFU_RemovesFromRoom verifies that after voice_leave the +// participant is no longer tracked in the VoiceRoom. +func TestVoice_Leave_SFU_RemovesFromRoom(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "leave-bob") + chanID := seedVoiceChan(t, database, "vc-leave-bob") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + // Confirm in room before leave. + room := hub.GetVoiceRoom(chanID) + if room == nil || !room.HasParticipant(user.ID) { + t.Fatal("precondition: user not in room after join") + } + + hub.HandleMessageForTest(c, voiceLeaveMsg()) + time.Sleep(50 * time.Millisecond) + + // After leave, participant should be removed (room gone or user absent). + room = hub.GetVoiceRoom(chanID) + if room != nil && room.HasParticipant(user.ID) { + t.Error("user still tracked in VoiceRoom after voice_leave") + } +} + +// TestVoice_Leave_SFU_CleansUpEmptyRoom verifies that when the last participant +// leaves, the VoiceRoom is removed from the Hub entirely. +func TestVoice_Leave_SFU_CleansUpEmptyRoom(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "empty-carol") + chanID := seedVoiceChan(t, database, "vc-empty-carol") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + if hub.GetVoiceRoom(chanID) == nil { + t.Fatal("precondition: VoiceRoom not created after join") + } + + hub.HandleMessageForTest(c, voiceLeaveMsg()) + time.Sleep(50 * time.Millisecond) + + if hub.GetVoiceRoom(chanID) != nil { + t.Error("VoiceRoom should be removed from Hub after last participant leaves") + } +} + +// TestVoice_Leave_SFU_OnDisconnect verifies that handleVoiceLeave cleans up +// room state when triggered by a disconnect without an explicit voice_leave message. +func TestVoice_Leave_SFU_OnDisconnect(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "disco-dave") + chanID := seedVoiceChan(t, database, "vc-disco-dave") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + room := hub.GetVoiceRoom(chanID) + if room == nil || !room.HasParticipant(user.ID) { + t.Fatal("precondition: user not in VoiceRoom after join") + } + + // Simulate disconnect by calling the exported test hook. + hub.HandleVoiceLeaveForTest(c) + time.Sleep(30 * time.Millisecond) + + // DB state should be cleared. + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState after disconnect: %v", err) + } + if state != nil { + t.Error("voice state still in DB after simulated disconnect") + } + + // VoiceRoom should be gone or user removed from it. + room = hub.GetVoiceRoom(chanID) + if room != nil && room.HasParticipant(user.ID) { + t.Error("user still in VoiceRoom after simulated disconnect") + } +} + +// ─── handleMessage dispatch ─────────────────────────────────────────────────── + +func TestVoice_HandleMessage_VoiceCamera_Dispatched(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "cam-dispatch") + chanID := seedVoiceChan(t, database, "vc-cam-dispatch") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Send via HandleMessageForTest to verify dispatch occurs (no unknown_type error). + hub.HandleMessageForTest(c, voiceCameraMsg(true)) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + if extractType(t, m) == "error" { + var errEnv struct { + Payload struct { + Code string `json:"code"` + } `json:"payload"` + } + if err := json.Unmarshal(m, &errEnv); err == nil { + if errEnv.Payload.Code == "UNKNOWN_TYPE" { + t.Error("voice_camera was not dispatched: got UNKNOWN_TYPE error") + } + } + } + } +} + +func TestVoice_HandleMessage_VoiceScreenshare_Dispatched(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ss-dispatch") + chanID := seedVoiceChan(t, database, "vc-ss-dispatch") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Send via HandleMessageForTest to verify dispatch occurs (no unknown_type error). + hub.HandleMessageForTest(c, voiceScreenshareMsg(true)) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + for _, m := range msgs { + if extractType(t, m) == "error" { + var errEnv struct { + Payload struct { + Code string `json:"code"` + } `json:"payload"` + } + if err := json.Unmarshal(m, &errEnv); err == nil { + if errEnv.Payload.Code == "UNKNOWN_TYPE" { + t.Error("voice_screenshare was not dispatched: got UNKNOWN_TYPE error") + } + } + } + } +} + +// ─── ICE monitor / setupICEMonitor ──────────────────────────────────────────── + +// TestVoice_SetupICEMonitor_NilPC_NoPanic verifies that setupICEMonitor does +// not panic when the client has a nil PeerConnection. +func TestVoice_SetupICEMonitor_NilPC_NoPanic(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "ice-monitor-nil") + chanID := seedVoiceChan(t, database, "vc-ice-nil") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + + // SetupICEMonitorForTest should not panic when c.pc is nil. + hub.SetupICEMonitorForTest(c, chanID) +} + +// ─── duplicate voice_join (channel switch) ──────────────────────────────────── + +// TestVoice_Join_SwitchChannel_LeavesOldChannel verifies that joining channel B +// while already in channel A results in the user leaving channel A first. +func TestVoice_Join_SwitchChannel_LeavesOldChannel(t *testing.T) { + hub, database := newVoiceHub(t) + userA := seedVoiceOwner(t, database, "switch-alice") + chanA := seedVoiceChan(t, database, "vc-switch-a") + chanB := seedVoiceChan(t, database, "vc-switch-b") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, userA, chanA, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Join channel A. + hub.HandleMessageForTest(c, voiceJoinMsg(chanA)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Verify in channel A. + roomA := hub.GetVoiceRoom(chanA) + if roomA == nil { + t.Fatal("room A should exist after joining") + } + if !roomA.HasParticipant(userA.ID) { + t.Fatal("user should be participant in room A") + } + + // Join channel B — should leave A first. + hub.HandleMessageForTest(c, voiceJoinMsg(chanB)) + time.Sleep(50 * time.Millisecond) + + // Room A should no longer have the user. + roomA = hub.GetVoiceRoom(chanA) + if roomA != nil && roomA.HasParticipant(userA.ID) { + t.Error("user should have been removed from room A after joining room B") + } + + // Room B should have the user. + roomB := hub.GetVoiceRoom(chanB) + if roomB == nil { + t.Fatal("room B should exist after joining") + } + if !roomB.HasParticipant(userA.ID) { + t.Error("user should be participant in room B after switching") + } +} + +// TestVoice_Join_SameChannel_IsIdempotent verifies that joining the same channel +// twice does not result in errors or duplicate participation. +func TestVoice_Join_SameChannel_IsIdempotent(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "idempotent-join") + chanID := seedVoiceChan(t, database, "vc-idempotent") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + + // Join same channel again. + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + // Should not receive an error for the second join. + msgs := drainChan(send) + for _, m := range msgs { + if code := extractCode(t, m); code == "CHANNEL_FULL" || code == "VOICE_ERROR" { + t.Errorf("unexpected error %q on re-join of same channel", code) + } + } + + // Participant count should remain 1. + room := hub.GetVoiceRoom(chanID) + if room == nil { + t.Fatal("room should exist") + } + if count := room.ParticipantCount(); count != 1 { + t.Errorf("ParticipantCount = %d, want 1 after idempotent join", count) + } +} + diff --git a/Server/ws/voice_room.go b/Server/ws/voice_room.go new file mode 100644 index 00000000..fcc088fe --- /dev/null +++ b/Server/ws/voice_room.go @@ -0,0 +1,300 @@ +package ws + +import ( + "errors" + "log/slog" + "sync" + "time" + + "github.com/pion/webrtc/v4" +) + +// ErrRoomFull is returned when attempting to add a participant to a full voice room. +var ErrRoomFull = errors.New("voice room is full") + +// VoiceTrack pairs an incoming remote track with its local fan-out track. +type VoiceTrack struct { + UserID int64 + Remote *webrtc.TrackRemote + Local *webrtc.TrackLocalStaticRTP + senderMu sync.RWMutex + Senders map[int64]*webrtc.RTPSender // subscriber userID -> sender +} + +// AddSender records a subscriber's RTPSender (thread-safe). +func (vt *VoiceTrack) AddSender(userID int64, s *webrtc.RTPSender) { + vt.senderMu.Lock() + defer vt.senderMu.Unlock() + vt.Senders[userID] = s +} + +// RemoveSender removes and returns a subscriber's RTPSender. +func (vt *VoiceTrack) RemoveSender(userID int64) *webrtc.RTPSender { + vt.senderMu.Lock() + defer vt.senderMu.Unlock() + s := vt.Senders[userID] + delete(vt.Senders, userID) + return s +} + +// CopySenders returns a snapshot of the senders map for iteration. +func (vt *VoiceTrack) CopySenders() map[int64]*webrtc.RTPSender { + vt.senderMu.RLock() + defer vt.senderMu.RUnlock() + cp := make(map[int64]*webrtc.RTPSender, len(vt.Senders)) + for k, v := range vt.Senders { + cp[k] = v + } + return cp +} + +// VoiceParticipant represents one user in a voice room. +type VoiceParticipant struct { + UserID int64 + JoinedAt time.Time +} + +// VoiceRoomConfig holds per-room configuration derived from channel settings and server defaults. +type VoiceRoomConfig struct { + ChannelID int64 + MaxUsers int // 0 = unlimited + Quality string // low|medium|high + MixingThreshold int // forwarding → selective threshold + TopSpeakers int // N for top-N selection + MaxVideo int // max simultaneous video streams +} + +// VoiceRoom manages voice participants for a single channel. +// It does NOT hold PeerConnections yet — those come in Phase 3/4. +type VoiceRoom struct { + config VoiceRoomConfig + participants map[int64]*VoiceParticipant + tracks map[int64]*VoiceTrack + mode string // "forwarding" or "selective" + detector *SpeakerDetector + mu sync.RWMutex +} + +// NewVoiceRoom creates a new voice room in "forwarding" mode. +func NewVoiceRoom(cfg VoiceRoomConfig) *VoiceRoom { + topN := cfg.TopSpeakers + if topN <= 0 { + topN = 3 + } + return &VoiceRoom{ + config: cfg, + participants: make(map[int64]*VoiceParticipant), + tracks: make(map[int64]*VoiceTrack), + mode: "forwarding", + detector: NewSpeakerDetector(topN), + } +} + +// AddParticipant adds a user to the voice room. Returns ErrRoomFull if +// MaxUsers > 0 and the room is already at capacity. Adding a duplicate +// user ID is a no-op. +func (r *VoiceRoom) AddParticipant(userID int64) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Duplicate check — already present, nothing to do. + if _, exists := r.participants[userID]; exists { + return nil + } + + if r.config.MaxUsers > 0 && len(r.participants) >= r.config.MaxUsers { + return ErrRoomFull + } + + r.participants[userID] = &VoiceParticipant{ + UserID: userID, + JoinedAt: time.Now(), + } + + r.updateMode() + return nil +} + +// RemoveParticipant removes a user from the voice room. No-op if the user +// is not present. +func (r *VoiceRoom) RemoveParticipant(userID int64) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.participants[userID]; !exists { + return + } + + delete(r.participants, userID) + r.detector.RemoveSpeaker(userID) + r.updateMode() +} + +// ParticipantCount returns the number of participants (thread-safe). +func (r *VoiceRoom) ParticipantCount() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.participants) +} + +// IsEmpty returns true if the room has no participants. +func (r *VoiceRoom) IsEmpty() bool { + return r.ParticipantCount() == 0 +} + +// Mode returns the current mixing mode ("forwarding" or "selective"). +func (r *VoiceRoom) Mode() string { + r.mu.RLock() + defer r.mu.RUnlock() + return r.mode +} + +// ParticipantIDs returns a slice of all participant user IDs. +func (r *VoiceRoom) ParticipantIDs() []int64 { + r.mu.RLock() + defer r.mu.RUnlock() + + ids := make([]int64, 0, len(r.participants)) + for id := range r.participants { + ids = append(ids, id) + } + return ids +} + +// HasParticipant checks whether the given user is in the room. +func (r *VoiceRoom) HasParticipant(userID int64) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, exists := r.participants[userID] + return exists +} + +// Close clears all participants and tracks from the room. +func (r *VoiceRoom) Close() { + r.mu.Lock() + defer r.mu.Unlock() + slog.Info("voice room closing", + "channel_id", r.config.ChannelID, + "participants", len(r.participants), + "tracks", len(r.tracks)) + r.participants = make(map[int64]*VoiceParticipant) + r.tracks = make(map[int64]*VoiceTrack) + r.mode = "forwarding" +} + +// SetTrack stores a VoiceTrack for the given user (replaces any existing one). +func (r *VoiceRoom) SetTrack(userID int64, remote *webrtc.TrackRemote, local *webrtc.TrackLocalStaticRTP) { + r.mu.Lock() + defer r.mu.Unlock() + _, replaced := r.tracks[userID] + r.tracks[userID] = &VoiceTrack{ + UserID: userID, + Remote: remote, + Local: local, + Senders: make(map[int64]*webrtc.RTPSender), + } + codec := "" + if remote != nil { + codec = remote.Codec().MimeType + } + slog.Debug("voice room track set", + "channel_id", r.config.ChannelID, + "user_id", userID, + "replaced", replaced, + "codec", codec, + "total_tracks", len(r.tracks)) +} + +// RemoveTrack removes and returns the VoiceTrack for the given user. +// Returns nil if no track exists for that user. +func (r *VoiceRoom) RemoveTrack(userID int64) *VoiceTrack { + r.mu.Lock() + defer r.mu.Unlock() + vt, ok := r.tracks[userID] + if ok { + delete(r.tracks, userID) + slog.Debug("voice room track removed", + "channel_id", r.config.ChannelID, + "user_id", userID, + "remaining_tracks", len(r.tracks)) + } + return vt +} + +// GetTracks returns a snapshot of all current tracks. +func (r *VoiceRoom) GetTracks() []*VoiceTrack { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]*VoiceTrack, 0, len(r.tracks)) + for _, vt := range r.tracks { + result = append(result, vt) + } + return result +} + +// TrackUserIDs returns the user IDs of all users that have an active track. +func (r *VoiceRoom) TrackUserIDs() []int64 { + r.mu.RLock() + defer r.mu.RUnlock() + ids := make([]int64, 0, len(r.tracks)) + for id := range r.tracks { + ids = append(ids, id) + } + return ids +} + +// GetTrack returns the VoiceTrack for the given user, or nil if not present. +func (r *VoiceRoom) GetTrack(userID int64) *VoiceTrack { + r.mu.RLock() + defer r.mu.RUnlock() + return r.tracks[userID] +} + +// UpdateSpeakerLevel updates the audio level for a user in this room's detector. +// level is the raw RFC 6464 dBov value: 0 = loudest, 127 = silence. +func (r *VoiceRoom) UpdateSpeakerLevel(userID int64, level uint8) { + r.detector.UpdateLevel(userID, level) +} + +// TopSpeakers returns the current top-N active speakers for this room. +func (r *VoiceRoom) TopSpeakers() []int64 { + return r.detector.TopSpeakers() +} + +// Config returns a copy of the room's configuration. +func (r *VoiceRoom) Config() VoiceRoomConfig { + r.mu.RLock() + defer r.mu.RUnlock() + return r.config +} + +// updateMode checks participant count vs threshold with ±2 hysteresis. +// Must be called with r.mu held. +func (r *VoiceRoom) updateMode() { + count := len(r.participants) + threshold := r.config.MixingThreshold + + if threshold <= 0 { + return + } + + oldMode := r.mode + switch r.mode { + case "forwarding": + if count >= threshold { + r.mode = "selective" + } + case "selective": + if count <= threshold-2 { + r.mode = "forwarding" + } + } + if r.mode != oldMode { + slog.Info("voice room mode changed", + "channel_id", r.config.ChannelID, + "old_mode", oldMode, + "new_mode", r.mode, + "participants", count, + "threshold", threshold) + } +} diff --git a/Server/ws/voice_room_test.go b/Server/ws/voice_room_test.go new file mode 100644 index 00000000..35d4fe09 --- /dev/null +++ b/Server/ws/voice_room_test.go @@ -0,0 +1,346 @@ +package ws_test + +import ( + "errors" + "sort" + "sync" + "testing" + + "github.com/owncord/server/ws" +) + +func defaultRoomConfig() ws.VoiceRoomConfig { + return ws.VoiceRoomConfig{ + ChannelID: 1, + MaxUsers: 0, + Quality: "medium", + MixingThreshold: 5, + TopSpeakers: 3, + MaxVideo: 4, + } +} + +func TestNewVoiceRoom(t *testing.T) { + cfg := defaultRoomConfig() + room := ws.NewVoiceRoom(cfg) + + if room.Mode() != "forwarding" { + t.Errorf("NewVoiceRoom() mode = %q, want %q", room.Mode(), "forwarding") + } + if !room.IsEmpty() { + t.Error("NewVoiceRoom() should be empty") + } + if room.ParticipantCount() != 0 { + t.Errorf("NewVoiceRoom() count = %d, want 0", room.ParticipantCount()) + } +} + +func TestVoiceRoom_AddParticipant(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + + if err := room.AddParticipant(100); err != nil { + t.Fatalf("AddParticipant(100) returned error: %v", err) + } + if err := room.AddParticipant(200); err != nil { + t.Fatalf("AddParticipant(200) returned error: %v", err) + } + + if room.ParticipantCount() != 2 { + t.Errorf("ParticipantCount() = %d, want 2", room.ParticipantCount()) + } + if room.IsEmpty() { + t.Error("room should not be empty after adding participants") + } +} + +func TestVoiceRoom_AddParticipant_Full(t *testing.T) { + cfg := defaultRoomConfig() + cfg.MaxUsers = 2 + room := ws.NewVoiceRoom(cfg) + + if err := room.AddParticipant(1); err != nil { + t.Fatalf("AddParticipant(1) returned error: %v", err) + } + if err := room.AddParticipant(2); err != nil { + t.Fatalf("AddParticipant(2) returned error: %v", err) + } + + err := room.AddParticipant(3) + if err == nil { + t.Fatal("AddParticipant(3) should return error when room is full") + } + if !errors.Is(err, ws.ErrRoomFull) { + t.Errorf("error = %v, want ErrRoomFull", err) + } + if room.ParticipantCount() != 2 { + t.Errorf("ParticipantCount() = %d, want 2 (third should not be added)", room.ParticipantCount()) + } +} + +func TestVoiceRoom_AddParticipant_Unlimited(t *testing.T) { + cfg := defaultRoomConfig() + cfg.MaxUsers = 0 + room := ws.NewVoiceRoom(cfg) + + for i := int64(1); i <= 50; i++ { + if err := room.AddParticipant(i); err != nil { + t.Fatalf("AddParticipant(%d) returned error: %v", i, err) + } + } + if room.ParticipantCount() != 50 { + t.Errorf("ParticipantCount() = %d, want 50", room.ParticipantCount()) + } +} + +func TestVoiceRoom_RemoveParticipant(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(1) + _ = room.AddParticipant(2) + _ = room.AddParticipant(3) + + room.RemoveParticipant(2) + + if room.ParticipantCount() != 2 { + t.Errorf("ParticipantCount() = %d, want 2", room.ParticipantCount()) + } + if room.HasParticipant(2) { + t.Error("HasParticipant(2) = true after removal") + } +} + +func TestVoiceRoom_RemoveParticipant_NotPresent(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(1) + + // Should not panic. + room.RemoveParticipant(999) + + if room.ParticipantCount() != 1 { + t.Errorf("ParticipantCount() = %d, want 1", room.ParticipantCount()) + } +} + +func TestVoiceRoom_HasParticipant(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(42) + + if !room.HasParticipant(42) { + t.Error("HasParticipant(42) = false, want true") + } + if room.HasParticipant(99) { + t.Error("HasParticipant(99) = true, want false") + } +} + +func TestVoiceRoom_ParticipantIDs(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(10) + _ = room.AddParticipant(20) + _ = room.AddParticipant(30) + + ids := room.ParticipantIDs() + if len(ids) != 3 { + t.Fatalf("ParticipantIDs() returned %d IDs, want 3", len(ids)) + } + + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + want := []int64{10, 20, 30} + for i, id := range ids { + if id != want[i] { + t.Errorf("ParticipantIDs()[%d] = %d, want %d", i, id, want[i]) + } + } +} + +func TestVoiceRoom_Mode_ForwardingToSelective(t *testing.T) { + cfg := defaultRoomConfig() + cfg.MixingThreshold = 3 + room := ws.NewVoiceRoom(cfg) + + _ = room.AddParticipant(1) + _ = room.AddParticipant(2) + if room.Mode() != "forwarding" { + t.Errorf("mode after 2 users = %q, want %q", room.Mode(), "forwarding") + } + + _ = room.AddParticipant(3) + if room.Mode() != "selective" { + t.Errorf("mode after 3 users (threshold=3) = %q, want %q", room.Mode(), "selective") + } +} + +func TestVoiceRoom_Mode_SelectiveToForwarding_Hysteresis(t *testing.T) { + cfg := defaultRoomConfig() + cfg.MixingThreshold = 5 + room := ws.NewVoiceRoom(cfg) + + // Add 5 participants to trigger selective mode. + for i := int64(1); i <= 5; i++ { + _ = room.AddParticipant(i) + } + if room.Mode() != "selective" { + t.Fatalf("mode after 5 users (threshold=5) = %q, want %q", room.Mode(), "selective") + } + + // Remove 1: count=4, still selective (4 > 5-2=3). + room.RemoveParticipant(5) + if room.Mode() != "selective" { + t.Errorf("mode at count=4 should still be %q (hysteresis), got %q", "selective", room.Mode()) + } + + // Remove 1 more: count=3, 3 <= 5-2=3 → switch to forwarding. + room.RemoveParticipant(4) + if room.Mode() != "forwarding" { + t.Errorf("mode at count=3 should be %q (3 <= threshold-2=3), got %q", "forwarding", room.Mode()) + } +} + +func TestVoiceRoom_Close(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(1) + _ = room.AddParticipant(2) + + room.Close() + + if !room.IsEmpty() { + t.Error("room should be empty after Close()") + } + if room.ParticipantCount() != 0 { + t.Errorf("ParticipantCount() = %d after Close(), want 0", room.ParticipantCount()) + } +} + +func TestVoiceRoom_Concurrent(t *testing.T) { + cfg := defaultRoomConfig() + cfg.MaxUsers = 0 + room := ws.NewVoiceRoom(cfg) + + var wg sync.WaitGroup + const goroutines = 50 + + // Add participants concurrently. + for i := int64(1); i <= goroutines; i++ { + wg.Add(1) + go func(id int64) { + defer wg.Done() + _ = room.AddParticipant(id) + }(i) + } + wg.Wait() + + if room.ParticipantCount() != goroutines { + t.Errorf("ParticipantCount() = %d after concurrent adds, want %d", room.ParticipantCount(), goroutines) + } + + // Remove participants concurrently. + for i := int64(1); i <= goroutines; i++ { + wg.Add(1) + go func(id int64) { + defer wg.Done() + room.RemoveParticipant(id) + }(i) + } + wg.Wait() + + if !room.IsEmpty() { + t.Errorf("room should be empty after concurrent removes, count = %d", room.ParticipantCount()) + } + + // Mix add/remove concurrently. + for i := int64(1); i <= goroutines; i++ { + wg.Add(2) + go func(id int64) { + defer wg.Done() + _ = room.AddParticipant(id) + }(i) + go func(id int64) { + defer wg.Done() + room.RemoveParticipant(id) + }(i) + } + wg.Wait() + + // Just verify no panic and count is non-negative. + if room.ParticipantCount() < 0 { + t.Errorf("ParticipantCount() = %d, should not be negative", room.ParticipantCount()) + } +} + +func TestVoiceRoom_AddParticipant_Duplicate(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(1) + _ = room.AddParticipant(1) // duplicate + + // Should not double-count. + if room.ParticipantCount() != 1 { + t.Errorf("ParticipantCount() = %d after duplicate add, want 1", room.ParticipantCount()) + } +} + +func TestVoiceRoom_AddTrack(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(100) + room.SetTrack(100, nil, nil) + tracks := room.GetTracks() + if len(tracks) != 1 { + t.Fatalf("GetTracks() len = %d, want 1", len(tracks)) + } +} + +func TestVoiceRoom_RemoveTrack(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(100) + room.SetTrack(100, nil, nil) + vt := room.RemoveTrack(100) + if vt == nil { + t.Fatal("RemoveTrack returned nil") + } + tracks := room.GetTracks() + if len(tracks) != 0 { + t.Fatalf("GetTracks() after remove len = %d, want 0", len(tracks)) + } +} + +func TestVoiceRoom_GetTrackUserIDs(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(100) + _ = room.AddParticipant(200) + room.SetTrack(100, nil, nil) + room.SetTrack(200, nil, nil) + ids := room.TrackUserIDs() + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + if len(ids) != 2 || ids[0] != 100 || ids[1] != 200 { + t.Fatalf("TrackUserIDs() = %v, want [100 200]", ids) + } +} + +func TestVoiceRoom_Close_ClearsTracks(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(100) + room.SetTrack(100, nil, nil) + room.Close() + if len(room.GetTracks()) != 0 { + t.Fatal("Close() should clear tracks") + } +} + +func TestVoiceTrack_AddRemoveSender(t *testing.T) { + room := ws.NewVoiceRoom(defaultRoomConfig()) + _ = room.AddParticipant(100) + room.SetTrack(100, nil, nil) + vt := room.GetTrack(100) + if vt == nil { + t.Fatal("GetTrack returned nil") + } + // AddSender with nil (unit test, no real sender) + vt.AddSender(200, nil) + senders := vt.CopySenders() + if len(senders) != 1 { + t.Fatalf("CopySenders len = %d, want 1", len(senders)) + } + vt.RemoveSender(200) + senders = vt.CopySenders() + if len(senders) != 0 { + t.Fatalf("CopySenders after remove len = %d, want 0", len(senders)) + } +} diff --git a/Server/ws/ws_integration_test.go b/Server/ws/ws_integration_test.go new file mode 100644 index 00000000..63e096ea --- /dev/null +++ b/Server/ws/ws_integration_test.go @@ -0,0 +1,497 @@ +package ws_test + +// ws_integration_test.go covers ServeWS, authenticateConn, writePump, and +// readPump by spinning up a real httptest server and dialing it with the +// nhooyr.io/websocket client. + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "nhooyr.io/websocket" + + "github.com/owncord/server/auth" + "github.com/owncord/server/ws" +) + +// ─── ServeWS / authenticateConn happy path ──────────────────────────────────── + +// TestServeWS_InvalidUpgrade verifies that a plain HTTP GET (non-WS) returns +// a non-101 status without panicking. +func TestServeWS_InvalidUpgrade_ReturnsError(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + // Plain GET without WebSocket upgrade headers should fail gracefully. + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("http.Get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + // nhooyr.io/websocket returns 400 or 426 when upgrade is absent. + if resp.StatusCode == 200 { + t.Errorf("expected non-200 for plain HTTP, got %d", resp.StatusCode) + } +} + +// ─── authenticateConn — error paths ────────────────────────────────────────── + +// TestAuthenticateConn_NoAuthMessage verifies that a connection that closes +// immediately (without sending auth) causes the server to close it gracefully. +func TestAuthenticateConn_NoAuthMessage_ServerClosesConn(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("websocket.Dial: %v", err) + } + + // Close without sending auth — the server's authDeadline (10s) will fire, + // but closing immediately should cause a read error on the server side. + _ = conn.Close(websocket.StatusNormalClosure, "no auth") + + // Give the server a moment to react. + time.Sleep(50 * time.Millisecond) + + // Hub should have no clients registered. + if hub.ClientCount() != 0 { + t.Errorf("ClientCount = %d after unauthenticated connection, want 0", hub.ClientCount()) + } +} + +// TestAuthenticateConn_InvalidJSON verifies that sending invalid JSON as the +// first message causes the server to send an auth_error and close. +func TestAuthenticateConn_InvalidJSON_ReceivesAuthError(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("websocket.Dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + // Send invalid JSON as first message. + if err := conn.Write(ctx, websocket.MessageText, []byte("NOT JSON")); err != nil { + t.Fatalf("write: %v", err) + } + + // Server should respond with auth_error. + _, raw, readErr := conn.Read(ctx) + if readErr != nil { + // Server may close connection — also acceptable. + return + } + var msg map[string]any + if err := json.Unmarshal(raw, &msg); err == nil { + if msg["type"] == "auth_error" { + return // expected + } + t.Errorf("expected auth_error, got type=%q", msg["type"]) + } +} + +// TestAuthenticateConn_WrongMessageType verifies that sending a non-auth +// first message causes the server to send an auth_error. +func TestAuthenticateConn_WrongMessageType_ReceivesAuthError(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("websocket.Dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + // Send a chat_send instead of auth. + wrongMsg := map[string]any{ + "type": "chat_send", + "payload": map[string]string{"content": "hello"}, + } + raw, _ := json.Marshal(wrongMsg) + if err := conn.Write(ctx, websocket.MessageText, raw); err != nil { + t.Fatalf("write: %v", err) + } + + _, respRaw, readErr := conn.Read(ctx) + if readErr != nil { + return // server closed — acceptable + } + var msg map[string]any + if err := json.Unmarshal(respRaw, &msg); err == nil { + if msg["type"] == "auth_error" { + return // expected + } + t.Errorf("expected auth_error, got type=%q", msg["type"]) + } +} + +// TestAuthenticateConn_MissingToken verifies that an auth message without +// a token field receives an auth_error. +func TestAuthenticateConn_MissingToken_ReceivesAuthError(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("websocket.Dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]string{}, // no token field + } + raw, _ := json.Marshal(authMsg) + if err := conn.Write(ctx, websocket.MessageText, raw); err != nil { + t.Fatalf("write: %v", err) + } + + _, respRaw, readErr := conn.Read(ctx) + if readErr != nil { + return + } + var msg map[string]any + if err := json.Unmarshal(respRaw, &msg); err == nil { + if msg["type"] == "auth_error" { + return + } + t.Errorf("expected auth_error, got type=%q", msg["type"]) + } +} + +// TestAuthenticateConn_InvalidToken verifies that an auth message with a +// non-existent token receives an auth_error. +func TestAuthenticateConn_InvalidToken_ReceivesAuthError(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("websocket.Dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]string{"token": "totally-invalid-token-xyz"}, + } + raw, _ := json.Marshal(authMsg) + if err := conn.Write(ctx, websocket.MessageText, raw); err != nil { + t.Fatalf("write: %v", err) + } + + _, respRaw, readErr := conn.Read(ctx) + if readErr != nil { + return + } + var msg map[string]any + if err := json.Unmarshal(respRaw, &msg); err == nil { + if msg["type"] == "auth_error" { + return + } + t.Errorf("expected auth_error, got type=%q", msg["type"]) + } +} + +// TestServeWS_ValidAuth_FullHandshake verifies the complete happy path: +// valid token → auth_ok + ready received, client counted in hub. +func TestServeWS_ValidAuth_FullHandshake(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + // Seed user and session. + userID, err := database.CreateUser("ws-handshake-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + tokenHash := auth.HashToken(token) + if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("websocket.Dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + // Send auth. + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]string{"token": token}, + } + raw, _ := json.Marshal(authMsg) + if err := conn.Write(ctx, websocket.MessageText, raw); err != nil { + t.Fatalf("write auth: %v", err) + } + + // Expect auth_ok. + _, respRaw, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read auth_ok: %v", err) + } + var authOK map[string]any + if err := json.Unmarshal(respRaw, &authOK); err != nil { + t.Fatalf("unmarshal auth_ok: %v", err) + } + if authOK["type"] != "auth_ok" { + t.Errorf("first response type = %q, want auth_ok", authOK["type"]) + } + + // Expect ready. + _, respRaw2, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read ready: %v", err) + } + var readyMsg map[string]any + if err := json.Unmarshal(respRaw2, &readyMsg); err != nil { + t.Fatalf("unmarshal ready: %v", err) + } + if readyMsg["type"] != "ready" { + t.Errorf("second response type = %q, want ready", readyMsg["type"]) + } + + // Give hub a moment to register the client. + time.Sleep(30 * time.Millisecond) + if hub.ClientCount() != 1 { + t.Errorf("ClientCount = %d after successful auth, want 1", hub.ClientCount()) + } +} + +// TestServeWS_writePump_MessageDelivered verifies that messages queued on the +// hub are written through writePump to the connected client. +func TestServeWS_writePump_MessageDelivered(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + // Seed user and session. + userID, err := database.CreateUser("ws-pump-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + tokenHash := auth.HashToken(token) + if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("websocket.Dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + // Authenticate. + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]string{"token": token}, + } + raw, _ := json.Marshal(authMsg) + _ = conn.Write(ctx, websocket.MessageText, raw) + + // Drain auth_ok and ready. + for i := 0; i < 2; i++ { + _, _, err := conn.Read(ctx) + if err != nil { + t.Fatalf("drain initial messages: %v", err) + } + } + + // Wait for client to be registered and then broadcast a server_restart. + time.Sleep(50 * time.Millisecond) + hub.BroadcastServerRestart("test", 0) + + // The client should receive the broadcast via writePump. + readCtx, readCancel := context.WithTimeout(ctx, 2*time.Second) + defer readCancel() + _, broadcastRaw, err := conn.Read(readCtx) + if err != nil { + t.Fatalf("read broadcast: %v", err) + } + var bcast map[string]any + if err := json.Unmarshal(broadcastRaw, &bcast); err != nil { + t.Fatalf("unmarshal broadcast: %v", err) + } + // May receive member_join or presence first; drain until server_restart found. + found := bcast["type"] == "server_restart" + if !found { + // Drain a few more messages. + for i := 0; i < 5 && !found; i++ { + rCtx, rCancel := context.WithTimeout(ctx, 500*time.Millisecond) + _, raw2, err2 := conn.Read(rCtx) + rCancel() + if err2 != nil { + break + } + var m map[string]any + if json.Unmarshal(raw2, &m) == nil && m["type"] == "server_restart" { + found = true + } + } + } + if !found { + t.Error("did not receive server_restart broadcast via writePump") + } +} + +// TestServeWS_BannedUser_ReceivesError verifies that a banned user cannot connect. +func TestServeWS_BannedUser_ReceivesError(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + defer hub.Stop() + + // Seed user, then ban them. + userID, err := database.CreateUser("ws-banned-user", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + tokenHash := auth.HashToken(token) + if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + // Ban the user permanently. + if err := database.BanUser(userID, "test ban", nil); err != nil { + t.Fatalf("BanUser: %v", err) + } + + handler := ws.ServeWS(hub, database, []string{"*"}) + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("websocket.Dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + authMsg := map[string]any{ + "type": "auth", + "payload": map[string]string{"token": token}, + } + raw, _ := json.Marshal(authMsg) + if err := conn.Write(ctx, websocket.MessageText, raw); err != nil { + t.Fatalf("write auth: %v", err) + } + + _, respRaw, readErr := conn.Read(ctx) + if readErr != nil { + return // server closed connection — acceptable + } + var msg map[string]any + if err := json.Unmarshal(respRaw, &msg); err == nil { + msgType, _ := msg["type"].(string) + if msgType == "auth_ok" { + t.Error("banned user should not receive auth_ok") + } + } +} + diff --git a/docs/brain/05-Bugs/Open Bugs.md b/docs/brain/05-Bugs/Open Bugs.md new file mode 100644 index 00000000..26cfbcba --- /dev/null +++ b/docs/brain/05-Bugs/Open Bugs.md @@ -0,0 +1,125 @@ +# Open Bugs + +Bug tracker for the OwnCord project. + +## Active + +### Critical + +(none) + +### High + +(none) + +### Medium + +(none) + +## Resolved + +- **BUG-039**: `switchOutputDevice` early return on partial failure — fixed 2026-03-18 + - Replaced `return` with error tracking; all elements attempted before reporting +- **BUG-040**: Stale `onErrorCallback` after MainPage destroy — fixed 2026-03-18 + - Added `clearOnError()` export; MainPage calls it on destroy to prevent stale refs +- **BUG-041**: Voice store `resetStore` missing new fields in tests — fixed 2026-03-18 + - Added `localCamera`/`localScreenshare` to resetStore; tests for setLocalCamera, + setLocalScreenshare, setLocalSpeaking +- **BUG-042**: `updateUser` and UserBar option callbacks untested — fixed 2026-03-18 + - Added updateUser tests to auth.store.test.ts; added mute/deafen callback tests + to user-bar.test.ts +- **BUG-043**: `switchInputDevice` triggers `getUserMedia` with no session — fixed 2026-03-18 + - Added `webrtcService === null` guard; skips mic acquisition when not in voice +- **BUG-044**: `confirm()` blocks Tauri WebView renderer — fixed 2026-03-18 + - Replaced synchronous `confirm()` with double-click-to-delete pattern using toast +- **BUG-045**: Image `att.url` not scheme-validated — fixed 2026-03-18 + - Added `isSafeUrl()` check; only http/https URLs render as images + +- **BUG-031**: VoiceAudioTab device selection not applied to WebRTC — fixed 2026-03-18 + - Added `switchInputDevice`/`switchOutputDevice` to voiceSession; VoiceAudioTab + calls on change +- **BUG-032**: No WS handlers for channel_create/update/delete — closed 2026-03-18 + - Handlers wired in dispatcher.ts:173-200; `wireDispatcher` called in main.ts:141 +- **BUG-033**: No WS handlers for member_update/member_ban — closed 2026-03-18 + - Handlers wired in dispatcher.ts:219-229; `wireDispatcher` called in main.ts:141 +- **BUG-034**: InviteManager mutates state before API resolves — closed 2026-03-18 + - Filter is inside `.then()` — only runs after promise resolves +- **BUG-035**: DmSidebar active highlight never updates — fixed 2026-03-18 + - Click handler now removes `.active` from siblings and adds to clicked item +- **BUG-036**: WebRTC failure silently disconnects user — fixed 2026-03-18 + - Added `setOnError` callback pattern; MainPage wires it to toast + +- **BUG-026**: Image attachments render placeholder, not actual images — fixed 2026-03-18 + - Replaced placeholder `
` with `` + error fallback +- **BUG-030**: Orphaned MessageActionsBar + ReactionBar components — fixed 2026-03-18 + - Deleted dead code: both components and their tests (never imported anywhere) + +- **BUG-024**: Reactions cannot be removed — fixed 2026-03-18 + - Toggles `reaction_add`/`reaction_remove` based on `me` field per PROTOCOL.md +- **BUG-028**: Message delete fires with no confirmation — fixed 2026-03-18 + - Added `confirm()` guard before sending `chat_delete`; success toast added +- **BUG-029**: Message edit sends without validation — fixed 2026-03-18 + - Added empty-check, no-op detection, and toast feedback +- **BUG-037**: Reaction rate limit silently swallows clicks — fixed 2026-03-18 + - Shows error toast when rate limited +- **BUG-038**: No toasts for chat edit/delete/reaction operations — fixed 2026-03-18 + - Added success toasts for delete and edit; error toast for rate-limited reactions + +- **BUG-021**: Camera toggle hardcoded to `enabled: false` — fixed 2026-03-18 + - Added `localCamera` state to voice store; toggle reads actual state +- **BUG-022**: Screenshare handler completely empty — fixed 2026-03-18 + - Added `localScreenshare` state; sends `voice_screenshare` WS message +- **BUG-023**: UserBar mute/deafen buttons have no event listeners — fixed 2026-03-18 + - Added `UserBarOptions` interface; MainPage passes mute/deafen handlers +- **BUG-027**: VAD speaking state never sent to server — fixed 2026-03-18 + - Wired `vadDetector.onSpeakingChange` → `setLocalSpeaking` in voice store + +- **BUG-020**: Account settings do nothing — fixed 2026-03-18 + - Wired `api.changePassword()` and `api.updateProfile()` into MainPage callbacks + - Added `updateUser()` to auth store for username sync after profile edit + - Added toast feedback for success/error on both operations +- **BUG-025**: Theme changes don't sync to uiStore — fixed 2026-03-18 + - Added `setTheme(name)` call in AppearanceTab click handler + - Store now stays in sync with localStorage and applied CSS + +- **BUG-001**: NilHub tests pass mockHub not nil — fixed 2026-03-18 (#12) + - Added nil hub tests for PatchUser ban and role change paths +- **BUG-002**: window-state.ts untyped `any` — fixed (already resolved) (#10) + - Code already uses proper types (`Record`, `typeof import(...)`) + - No `any` or `getInvoke()` pattern found — was fixed in a prior refactor + +- **BUG-003**: Hub double-close panic — fixed 2026-03-17 (issue #3) + - Added `sync.Once` guard on quit channel close +- **BUG-004**: golangci-lint version incompatibility — fixed 2026-03-17 (issue #4) + - Pinned compatible linter version in CI +- **BUG-005**: SearchMessages missing validation — fixed 2026-03-17 (issue #5) + - Added input length and channel access checks +- **BUG-006**: InviteManager unhandled rejections — fixed 2026-03-17 (issue #6) + - Wrapped async calls with proper error handling +- **BUG-007**: Test schema missing columns — fixed 2026-03-17 (issue #7) + - Synced test fixtures with production schema +- **BUG-008**: Capacity over-allocation in + getReactionsBatch — fixed 2026-03-17 (#9) + - Corrected slice capacity to match actual batch size +- **BUG-009**: golangci-lint violations blocking CI — fixed 2026-03-17 (issue #13) + - Resolved all outstanding lint errors +- **BUG-010**: buildReady() silent hang — fixed 2026-03-17 (T-038) + - Server now sends INTERNAL error to client on buildReady failure +- **BUG-011**: Banned user keeps chatting — fixed 2026-03-17 (T-044) + - Added ban check to periodic session validation in WS handler +- **BUG-012**: Reaction error DB leak — fixed 2026-03-17 (T-039) + - Sanitized error messages, raw DB errors logged server-side only +- **BUG-013**: WS proxy no connect timeout — fixed 2026-03-17 (T-046) + - Added 10s connect timeout to Rust WS proxy +- **BUG-014**: Channel delete stale view — fixed 2026-03-17 (T-045) + - Client auto-redirects to first text channel on active channel deletion +- **BUG-015**: Missing rate limits on chat_edit/chat_delete — fixed 2026-03-17 (#18) + - Added rate limiting to edit and delete message endpoints +- **BUG-016**: Cert mismatch event not handled — fixed 2026-03-17 (#19) + - TOFU flow now properly handles certificate mismatch events +- **BUG-017**: SHA-256 fingerprint validation incorrect — fixed 2026-03-17 (#20) + - Fixed fingerprint comparison logic in cert pinning +- **BUG-018**: Session+ban query N+1 — fixed 2026-03-17 (#21) + - Optimized with JOIN query instead of separate lookups +- **BUG-019**: Channel position sorting broken — fixed 2026-03-17 (#22) + - Channels now sort correctly by position field diff --git a/docs/specs/2026-03-14-phase7-distribution-updates-design.md b/docs/specs/2026-03-14-phase7-distribution-updates-design.md new file mode 100644 index 00000000..56abce31 --- /dev/null +++ b/docs/specs/2026-03-14-phase7-distribution-updates-design.md @@ -0,0 +1,215 @@ +# Phase 7: Distribution & Updates — Design Spec + +## Overview + +Add CI/CD pipelines, auto-update for both server and client, and project documentation. Installer deferred to a later phase. + +## 1. GitHub Actions CI + +### `ci.yml` — Continuous Integration + +**Triggers:** push/PR to `main` and `feature/*` branches. + +**Jobs:** + +- **server-build-test:** (runs on `windows-latest`) + - `actions/checkout@v4` + - `actions/setup-go@v5` (Go 1.25) + - `go build -o chatserver.exe -ldflags "-s -w" .` + - `go test ./... -cover` + - `golangci-lint run ./...` via `golangci/golangci-lint-action` + +- **client-build-test:** (runs on `windows-latest`) + - `actions/checkout@v4` + - `actions/setup-dotnet@v4` (.NET 8) + - `dotnet build Client/OwnCord.Client.sln` + - `dotnet test Client/OwnCord.Client.Tests/` + +### `release.yml` — Release Pipeline + +**Trigger:** push tag matching `v*` (e.g. `v1.0.0`). + +**Steps:** + +1. Checkout code +2. Extract version from tag (strip `v` prefix) +3. Build server: `go build -o chatserver.exe -ldflags "-s -w -X main.version=$VERSION" .` +4. Build client as single-file: `dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o dist/client` +5. Generate SHA256 checksums for all binaries +6. Create GitHub Release via `gh release create` with `--generate-notes` +7. Attach binaries and checksum file to the release + +**Artifacts:** +- `chatserver.exe` — server binary (~13MB) +- `OwnCord.Client.exe` — single-file self-contained client +- `checksums.sha256` — SHA256 hashes for integrity verification + +## 2. Server Auto-Update + +### Version Accessibility + +Create a shared `version` package or pass the version string to `admin.NewAdminAPI` as a parameter. The `main.version` variable (set via ldflags) is passed down at startup: + +``` +main.go → admin.NewAdminAPI(database, versionString) → update handler uses it for comparison +``` + +### Semver Comparison + +Use `golang.org/x/mod/semver` for version comparison. All tags must follow `vMAJOR.MINOR.PATCH` format. + +### API Endpoints + +**`GET /admin/api/updates`** (admin-only) +- Server-side HTTP call to `https://api.github.com/repos/J3vb/OwnCord/releases/latest` +- Parses latest tag, compares against running version using `semver.Compare` +- Caches result for 1 hour (in-memory, reset on restart) +- Optional `github_token` in config.yaml for authenticated requests (5,000 req/hr vs 60 unauthenticated) +- Response: `{ "current": "1.0.0", "latest": "1.2.0", "update_available": true, "release_url": "...", "download_url": "...", "release_notes": "..." }` + +**`POST /admin/api/updates/apply`** (owner-only, Bearer token auth — not cookie-based, so no CSRF risk) +- Validates download URL matches `https://github.com/J3vb/OwnCord/releases/download/...` pattern before downloading +- Downloads `chatserver.exe` from the GitHub Release to `chatserver.exe.new` +- Verifies SHA256 checksum against `checksums.sha256` from the release +- Broadcasts `server_restart` WebSocket message to all connected clients (see below) +- Waits 5 seconds for clients to prepare +- Renames: `chatserver.exe` → `chatserver.exe.old`, `chatserver.exe.new` → `chatserver.exe` +- Spawns new process detached from parent (`os.StartProcess` with `syscall.SysProcAttr{CreationFlags: DETACHED_PROCESS}`) +- Exits current process via graceful shutdown + +**On startup:** +- New process retries port binding for up to 10 seconds (old process may still be releasing) +- Verifies it can open the database and bind the port successfully before deleting `chatserver.exe.old` +- If startup fails, `chatserver.exe.old` remains for manual rollback + +### WebSocket Restart Notification + +Add to PROTOCOL.md: +```json +{ "type": "server_restart", "payload": { "reason": "update", "delay_seconds": 5 } } +``` +Clients display "Server restarting for update..." and auto-reconnect after the delay. + +### Admin Panel UI + +- Dashboard banner: "Update available: v1.2.0 — [Apply Update]" when `update_available` is true +- Confirmation dialog before applying: "This will restart the server. All connected users will be briefly disconnected." +- Status feedback during download/apply + +### Security Considerations + +- **Download URL validation:** Only accept URLs matching `https://github.com/J3vb/OwnCord/releases/download/...` +- **Integrity:** SHA256 checksum verification (authenticity via code signing deferred — documented as known limitation) +- **Authorization:** Only server owner can apply updates +- **No CSRF risk:** Endpoint uses Bearer token auth, not cookies + +## 3. Client Auto-Update + +### Single-File Distribution + +Client is published with `-p:PublishSingleFile=true` so the entire app is one `.exe`. This enables the same rename-swap pattern as the server. + +### Update Check Flow + +1. On launch, client makes HTTP GET to `https://api.github.com/repos/J3vb/OwnCord/releases/latest` +2. Caches result — checks at most once per 24 hours (stored in local app data) +3. Compares local assembly version against latest tag +4. If newer version exists and not in "skipped" list, shows update dialog + +### Update Dialog + +- Shows current version, new version, and release notes (from GitHub Release body) +- Three buttons: **Update Now**, **Skip This Version**, **Remind Me Later** +- "Skip This Version" persists the skipped version in local settings +- "Remind Me Later" dismisses until next launch (but respects 24h cache) + +### Update Apply Flow + +1. Validate download URL matches `https://github.com/J3vb/OwnCord/releases/download/...` +2. Download new `OwnCord.Client.exe` from GitHub Release to temp location +3. Verify SHA256 checksum +4. Rename current exe to `.old`, move new to current path +5. Restart application +6. On startup, delete `.old` if present + +### Installation Location + +Client runs from a user-writable location (`%LOCALAPPDATA%\OwnCord`) to avoid UAC elevation requirements for self-update. When the installer is added later, it will use this path by default. + +### Version Storage + +- Client version embedded at build time (assembly version from `.csproj`) +- Skipped version and last-check timestamp stored in `%LOCALAPPDATA%\OwnCord\settings.json` + +## 4. Documentation + +### `README.md` (project root) + +- Project name and one-line description +- Feature highlights (real-time chat, voice, admin panel, self-hosted) +- Quick start: build server, build client, connect +- Architecture overview (server + client diagram) +- Link to detailed docs +- License + +### `SECURITY.md` (project root) + +- How to report vulnerabilities (GitHub Security Advisories) +- Response timeline commitment +- Known limitations: no code signing yet (SHA256 integrity only) +- Security hardening checklist for operators: + - Enable TLS (self-signed minimum) + - Use invite-only registration + - Set strong admin password + - Configure rate limits + - Regular backups + - Keep server updated + - Firewall: only expose needed ports + +### `CONTRIBUTING.md` (project root) + +- Development setup (Go 1.25, .NET 8, tools) +- Branch naming: `feature/`, `fix/`, `docs/` +- Commit format: conventional commits +- PR process: branch from main, CI must pass, code review +- Test requirements: 80%+ coverage, TDD workflow + +### `docs/quick-start.md` + +- Download latest release from GitHub +- Run server, first-run config generation +- Access admin panel, create invite +- Install client, connect with invite + +### `docs/port-forwarding.md` + +- Why it's needed (friends outside your LAN) +- Find your router admin page +- Forward TCP port (default 8443) to server machine +- Find your public IP +- Test the connection + +### `docs/tailscale.md` + +- What Tailscale is and why it's simpler than port forwarding +- Install Tailscale on server and client machines +- Connect using Tailscale IP +- Set TLS mode to "off" (Tailscale encrypts the tunnel) +- Benefits: no port forwarding, no dynamic DNS, works behind CGNAT + +## 5. API.md Updates + +Update API.md to add the update endpoints under the admin section, matching the existing `/admin/api/*` routing pattern. + +## 6. PROTOCOL.md Updates + +Add `server_restart` message type for pre-restart notification. + +## 7. Out of Scope (Deferred) + +- NSIS/WiX installer — will be added in a follow-up +- `chatserver://` protocol handler registration +- Windows Service mode (`--service install`) +- Code signing (documented as known limitation in SECURITY.md) +- Auto-start registry key +- GPG signing of release checksums (mitigates GitHub account compromise — add when code signing is implemented) diff --git a/docs/specs/2026-03-15-voice-optimization-design.md b/docs/specs/2026-03-15-voice-optimization-design.md new file mode 100644 index 00000000..3b6e3fb8 --- /dev/null +++ b/docs/specs/2026-03-15-voice-optimization-design.md @@ -0,0 +1,777 @@ +# Voice Channel Optimization — Design Spec + +**Date:** 2026-03-15 +**Status:** Draft +**Scope:** Server-side Pion SFU, client audio, noise suppression + +--- + +## Problem + +P2P voice creates N×(N-1)/2 connections per channel. +Each client uploads N-1 streams. +Limit: 5-8 users before quality degrades. + +## Goal + +Maximize voice capacity on modest hardware (4-core, 20Mbps). +No external deps — everything in `chatserver.exe`. +Pure Go, no CGO. + +--- + +## Architecture: Hybrid SFU with Top-N Speaker Selection + +### Overview + +A Pion-based SFU runs inside the server process. Each +participant has one PeerConnection to the server. Two modes: + +- **Forwarding mode** (below threshold): all streams forwarded +- **Selective mode** (above threshold): top N speakers only + +### Forwarding Mode (Below Threshold) + +- Default threshold: 10 users per channel (configurable) +- Server receives one audio track per participant +- Forwards each track to all other participants +- Clients mix audio locally with per-user volume control +- Server CPU: negligible — packet routing only + +### Selective Forwarding Mode (Above Threshold) + +When occupancy exceeds the threshold: + +1. **Speaker detection via RFC 6464**: Clients include + `ssrc-audio-level` RTP header extension (0-127 dBov). + Server reads this without decoding media. + +2. **Top-N selection**: Top N by audio level (default N=3) + are forwarded to all others. + +3. **Hysteresis**: Speaker must drop below threshold for + 500ms before replacement. Each speaker has a + `last_active` timestamp for eviction logic. + +4. **Remaining streams**: Non-top-N streams are not + forwarded. Since most are listening, no audible effect. + New loud speakers enter top-N within 200-300ms. + +5. **Client receives N+1 streams max**: N active speakers + plus 1 placeholder silent track. + +6. **Speaker changes**: Broadcast via `voice_speakers` + WebSocket event for UI updates. + +**Why no background mix?** Mixing requires Opus decode/encode +via CGO (`hraban/opus.v2` + libopus). Selective forwarding +caps outbound streams without audio processing. Tradeoff: +only the loudest N are heard. In practice, conversations +naturally have few active speakers. + +### Mode Transition + +When a channel crosses the threshold: + +- **Upward**: Server starts speaker detection. Clients get + `voice_speakers` with `threshold_mode: "selective"`. +- **Downward**: Server resumes forwarding all tracks. + Clients get `threshold_mode: "forwarding"`. + +**Hysteresis buffer**: ±2 user buffer. Selective at +`threshold`, forwarding at `threshold - 2`. + +**During transition**: Server completes the current 20ms +frame, then switches. Pion handles track add/remove +via renegotiation. No audio drops. + +--- + +## Pion SFU Implementation + +### Components + +All components run inside `chatserver.exe`. Pure Go. + +**MediaEngine:** + +- Registers Opus codec for audio (always available) +- Registers VP8/VP9 on-demand for video/screenshare +- Registers `ssrc-audio-level` RTP header extension +- No transcoding — codec negotiation via SDP + +**InterceptorRegistry:** + +- NACK for packet loss recovery +- RTCP receiver reports for quality feedback +- No TWCC initially — add if congestion is an issue + +**PeerConnection per participant:** + +- One PeerConnection between each voice user and server +- Client sends one audio track (optionally video) +- Client receives up to N or top-N tracks +- PeerConnections stored per-channel in the Hub +- DTLS-SRTP (Pion default), ephemeral certs per connection + +**Speaker Detector (selective mode only):** + +- Reads RFC 6464 audio level from RTP headers +- Running average over 200ms (10 frames) per participant +- Top-N by lowest dBov (0 = loudest, 127 = silence) +- Updates active speakers with 500ms holdoff +- Lightweight goroutine per channel in selective mode +- Goroutine exits when below threshold or empty + +### ICE and Network Configuration + +**Server-side ICE candidates**: Configuration: + +- **LAN/direct**: Server uses bind address as host candidate +- **Behind NAT**: Operator sets `voice.external_ip` +- **TURN fallback**: Built-in TURN relay serves as relay + candidate for both client and server connections + +```yaml +voice: + external_ip: "" # set if behind NAT +``` + +If `external_ip` is empty, Pion auto-discovers via STUN. +Same IP works if port forwarding is already configured. + +### DTLS-SRTP Security Model + +- All PeerConnections use DTLS-SRTP. +- Server is a **trusted media endpoint**. It decrypts + incoming SRTP, re-encrypts per outbound connection. +- Server operator **can access decrypted audio**. + Acceptable for self-hosted with trusted operator. +- No media is logged, stored, or inspected. + +### Signaling Changes + +WebSocket signaling changes from P2P relay to SFU: + +**Current flow (P2P):** + +1. Client A sends `voice_offer` → server relays to Client B +2. Client B sends `voice_answer` → server relays to Client A +3. ICE candidates exchanged via relay + +**New flow (SFU):** + +1. Client sends `voice_offer` → server creates + PeerConnection, generates `voice_answer` +2. Server sends `voice_answer` with DTLS fingerprint +3. ICE candidates exchanged with server +4. Server adds/removes tracks as participants join/leave +5. Renegotiation via Pion's AddTrack/RemoveTrack + +**Backward compatibility:** Same message types +(`voice_offer`, `voice_answer`, `voice_ice`). Same payload +structure. Server is the WebRTC peer, not another client. +Clients must include `ssrc-audio-level` in SDP offers. + +**SDP validation:** Malformed SDP returns `voice_error`; +PeerConnection is not created. Pion handles most validation. + +### New Protocol Messages + +**Speaker update (Server → Client):** + +```json +{ + "type": "voice_speakers", + "payload": { + "channel_id": 10, + "speakers": [1, 5, 12], + "threshold_mode": "forwarding" + } +} +``` + +- `speakers`: Active speaker user IDs (up to top-N) +- `threshold_mode`: `"forwarding"` or `"selective"` +- Sent on speaker list changes or mode transitions +- In forwarding mode, contains all speaking users +- Rate: at most once per 200ms per channel + +**Extended voice state (Server → Client):** + +Add `camera` and `screenshare` to `voice_state`: + +```json +{ + "type": "voice_state", + "payload": { + "channel_id": 10, + "user_id": 1, + "username": "alex", + "muted": false, + "deafened": false, + "speaking": false, + "camera": false, + "screenshare": false + } +} +``` + +New fields are additive — old clients ignore them. +Update PROTOCOL.md for `voice_state` and `voice_speakers`. + +**Voice error (Server → Client):** + +```json +{ + "type": "voice_error", + "payload": { + "code": "CHANNEL_FULL", + "message": "Voice channel is full (50/50)" + } +} +``` + +Error codes: `CHANNEL_FULL`, `FORBIDDEN`, +`INVALID_SDP`, `SERVER_ERROR` + +--- + +## Failure Handling and Recovery + +### Server Crash/Restart + +- **Startup**: Clear all `voice_states` rows. +- **Planned restart**: Send `server_restart` WebSocket + message. Tear down PeerConnections. Clients reconnect + and re-negotiate after restart. +- **Unplanned crash**: PeerConnections die (DTLS timeout). + Clients detect ICE failure, show "reconnecting." + On reconnect, client re-sends `voice_join`. + +### Participant Disconnect + +- **WebSocket disconnect**: `handleVoiceLeave()` fires, + removes state, broadcasts `voice_leave`, tears down + PeerConnection and removes tracks. +- **Network blip (WebSocket stays, PC drops)**: Pion + detects ICE failure. Client can attempt ICE restart + via new `voice_offer` without re-joining. +- **Stale cleanup**: Goroutine checks PeerConnection state + every 30s. Cleans up `Failed`/`Closed` connections. + +### Mixer/Detector Goroutine Panic + +- Uses `defer recover()` to catch panics +- On panic: log error, broadcast empty `voice_speakers`, + fall back to forwarding mode +- Channel stays functional until re-triggered + +### Voice Channel Max Users + +When joining a full channel (`voice_max_users`): + +- Server returns `voice_error` with `CHANNEL_FULL` +- Client shows "Voice channel is full (N/N)" +- Join rejected — user stays in current state + +--- + +## Audio Input Modes (Client-Side) + +Three user-selectable modes. Determines when audio is sent. +The SFU treats all modes identically. + +### Voice Activity Detection (Default) + +- Opus built-in VAD + audio energy threshold +- Sensitivity: low/medium/high (configurable) +- Silence stops RTP packets entirely (no data sent) +- Reduces bandwidth 60-80% in typical channels + +### Push-to-Talk + +- Global hotkey (default: grave/tilde key) +- Registered via `SetWindowsHookEx` for fullscreen games +- Audio only while key is held +- Best for server bandwidth +- Visual indicator in UI + +### Open Mic + +- Always transmitting while in voice channel +- No VAD gate, no PTT requirement +- Highest bandwidth usage +- For continuous transmission (music, commentary) + +--- + +## Noise Suppression (Client-Side) + +Three tiers, user-selectable. Processing before Opus +encoding. Server has no involvement. + +### Off + +- Raw mic input to Opus encoder +- Zero additional CPU usage +- For low-end hardware or external suppression + +### Standard (Default) + +- Windows Audio Processing: echo cancellation + suppression +- Built into OS via WASAPI audio processing objects +- Zero binary size impact +- Adequate for most environments + +### Enhanced + +- RNNoise ML-based noise suppression +- Bundled with installer (~2MB added) +- Inference on raw PCM frames before Opus encoding +- CPU cost: ~2-3% on modern hardware (single core) +- Best for keyboard clicks, fan noise, chatter +- Pre-trained weights, no user training needed + +--- + +## Audio Quality Configuration + +### Per-Server Default + +Configured in `config.yaml` under `voice`: + +| Preset | Opus Bitrate | BW/User | Use Case | +| ------ | ------------ | ------- | -------- | +| `low` | 32 kbps | ~5 KB/s | Max capacity | +| `medium` | 64 kbps | ~9 KB/s | Balanced (default) | +| `high` | 128 kbps | ~17 KB/s | Music, high fidelity | + +### Per-Channel Override + +Admins can override per voice channel via admin panel +or REST API. + +### How It Works + +- Server sends quality in `voice_config` after `voice_join` +- Client configures Opus encoder to specified bitrate +- SFU does not transcode — forwards as-is +- Clients must include RFC 6464 `ssrc-audio-level` header + +--- + +## Configuration + +### Server-Side (`config.yaml`) + +```yaml +voice: + quality: medium # low | medium | high + mixing_threshold: 10 # selective forwarding threshold + top_speakers: 3 # active speakers in selective + external_ip: "" # set if behind NAT + turn_enabled: true + turn_secret: "" # auto-generated on first run + stun_port: 3478 + turn_port: 3478 +``` + +### Per-Channel Override (Admin Panel / REST API) + +Stored as columns on the channels table: + +```json +{ + "voice_quality": "high", + "voice_max_users": 50, + "mixing_threshold": 5 +} +``` + +Only voice channels use these fields. + +### Client-Side Settings (Persisted Locally) + +```text +audio_input_mode: vad | push_to_talk | open_mic +vad_sensitivity: low | medium | high +ptt_keybind: "VK_GRAVE" +noise_suppression: off | standard | enhanced +input_device: +output_device: +input_volume: 1.0 +output_volume: 1.0 +per_user_volumes: { "5": 0.8 } +``` + +No server APIs needed for client settings — they stay +local. New server-to-client: `voice_speakers` event and +`voice_config` on join. + +--- + +## Capacity Estimates + +Assumes: 4-core CPU, 20 Mbps upload, 64kbps Opus, +VAD reducing active streams ~70%. + +### Per Channel + +| Size | Mode | CPU | Upload | +| ---- | ---- | --- | ------ | +| 5 | Forwarding | ~1% | 5 × 4 × 9 KB/s = 180 KB/s | +| 10 | Forwarding | ~2% | 10 × 9 × 9 KB/s = 810 KB/s | +| 20 | Selective | ~3-5% | 20 × 4 × 9 KB/s = 720 KB/s | +| 50 | Selective | ~5-8% | 50 × 4 × 9 KB/s = 1.8 MB/s | +| 100 | Selective | ~8-12% | 100 × 4 × 9 KB/s = 3.6 MB/s | + +CPU is low because selective forwarding does no audio +decoding — only RTP header inspection and packet routing. +Primary cost: PeerConnection management and SRTP. + +Forwarding BW scales as N×(N-1). Selective scales as +N×(top_speakers+1). With VAD, real-world BW is 60-80% +lower. + +### Total Server Capacity + +| Scenario | Feasibility | +| -------- | ----------- | +| 3 ch × 50 users | ~15-24% CPU, ~5.4 MB/s | +| 1 ch × 100 users | ~8-12% CPU, ~3.6 MB/s | +| 5 ch × 30 users | ~15-25% CPU, ~5.4 MB/s | +| 200+ total users | Depends on active speakers | + +100 users at 3.6 MB/s (~29 Mbps) exceeds 20 Mbps ref. +Primary target: 50 users/channel at ~14.4 Mbps. + +### Video/Screenshare + +Video/screenshare tracks are always forwarded, never +mixed or selectively dropped: + +- VP8 720p @ 30fps: ~1-2 Mbps per stream +- Screen share: ~0.5-3 Mbps (varies by content) +- Limit: 5-10 simultaneous video streams per channel +- Independent of audio selective forwarding + +--- + +## Schema Changes + +New migration: `Server/migrations/003_voice_optimization.sql` + +### Voice States Table + +Add camera and screenshare tracking: + +```sql +ALTER TABLE voice_states ADD COLUMN camera INTEGER NOT NULL DEFAULT 0; +ALTER TABLE voice_states ADD COLUMN screenshare INTEGER NOT NULL DEFAULT 0; +``` + +### Channels Table + +Add voice configuration columns: + +```sql +ALTER TABLE channels ADD COLUMN voice_max_users INTEGER NOT NULL DEFAULT 0; +ALTER TABLE channels ADD COLUMN voice_quality TEXT; +ALTER TABLE channels ADD COLUMN mixing_threshold INTEGER; +``` + +- `voice_max_users`: 0 = unlimited +- `voice_quality`: NULL = server default +- `mixing_threshold`: NULL = server default + +### Startup Cleanup + +On startup, clear stale voice states: + +```sql +DELETE FROM voice_states; +``` + +Runs before accepting connections. + +--- + +## Protocol Updates Required + +PROTOCOL.md must include: + +1. **`voice_speakers`** message with payload and rate limit +2. **`voice_state`** extended with `camera`/`screenshare` +3. **`voice_config`** sent after `voice_join` acceptance +4. **`voice_error`** with codes: `CHANNEL_FULL`, + `FORBIDDEN`, `INVALID_SDP`, `SERVER_ERROR` +5. **Note**: `voice_offer`/`voice_answer`/`voice_ice` + now go to/from server SFU, not relayed +6. **Note**: Clients must include RFC 6464 + `ssrc-audio-level` in SDP offers + +--- + +## Gap Resolutions + +### Gap 1: `voice_config` Payload Definition + +Sent server → client after `voice_join` acceptance, before signaling begins. + +```json +{ + "type": "voice_config", + "payload": { + "channel_id": 10, + "quality": "medium", + "bitrate": 64000, + "threshold_mode": "forwarding", + "mixing_threshold": 10, + "top_speakers": 3, + "max_users": 50 + } +} +``` + +- `quality`: string — `"low"` | `"medium"` | `"high"` + (channel override or server default) +- `bitrate`: integer — Opus bitrate in bps, from quality + preset (32000/64000/128000) +- `threshold_mode`: string — `"forwarding"` | `"selective"` + at time of join +- `mixing_threshold`: integer — the threshold at which selective mode activates +- `top_speakers`: integer — N for top-N selection in selective mode +- `max_users`: integer — channel capacity (0 = unlimited) + +Client uses `bitrate` for Opus encoder. Other fields +are informational for UI +(e.g., showing "Selective mode — top 3 speakers" indicator). + +Rate: sent once on join. Not re-sent unless channel config changes mid-session +(future: `voice_config_update` if admin changes quality live). + +### Gap 2: `voice_error` Uses Existing Error Envelope + +**Decision**: Voice errors use the existing `error` message type from PROTOCOL.md, +extended with voice-specific codes. No separate `voice_error` type. + +```json +{ + "type": "error", + "id": "original-req-uuid", + "payload": { + "code": "CHANNEL_FULL", + "message": "Voice channel is full (50/50)" + } +} +``` + +Additional error codes added to the existing set: + +| Code | When | +| ------ | ------ | +| `CHANNEL_FULL` | `voice_join` when at `voice_max_users` capacity | +| `INVALID_SDP` | `voice_offer` with malformed/unparseable SDP | +| `VOICE_ERROR` | Generic SFU failure (PeerConnection creation, track setup) | + +Existing codes that apply to voice unchanged: + +- `FORBIDDEN` — missing `CONNECT_VOICE` permission +- `NOT_FOUND` — channel doesn't exist +- `RATE_LIMITED` — voice signaling rate exceeded + +The `id` field enables request/response correlation (client sends `voice_join` +with an `id`, server returns `error` with the same `id`). + +**Spec update**: All references to `voice_error` in this document should be read +as `error` with voice-specific codes. The JSON examples above in "New Protocol +Messages" section are updated accordingly. + +### Gap 3: Media Port Range Configuration + +Pion requires UDP ports for WebRTC media (DTLS-SRTP, ICE). Without explicit +configuration, Pion uses OS-assigned ephemeral ports, which makes firewall +rules unpredictable for self-hosted operators. + +**Config additions** (`config.yaml`): + +```yaml +voice: + # ... existing fields ... + media_port_min: 10000 # UDP port range start for WebRTC media + media_port_max: 10100 # UDP port range end for WebRTC media +``` + +- Default range: `10000-10100` (101 ports, supports ~50 concurrent PeerConnections) +- Each PeerConnection uses 1 UDP port (Pion muxes DTLS+SRTP+RTCP on one port) +- Self-hosted operators must open `media_port_min:media_port_max/udp` in their firewall +- Range can be widened for higher capacity (e.g., `10000-10500` for 500 connections) + +**Implementation**: Pass to Pion via +`SettingEngine.SetEphemeralUDPPortRange(min, max)`. + +**Documentation requirement**: The setup guide must include: + +```text +Firewall ports to open: + - TCP 8443 (HTTPS + WebSocket) + - UDP 3478 (STUN/TURN) + - UDP 10000-10100 (WebRTC media) +``` + +### Gap 4: TURN Role in SFU Topology + +**Decision**: The built-in TURN server is for **client-side ICE only**. The server's +own Pion ICE agent does NOT use the local TURN server. + +**Rationale**: In an SFU topology, the server is a direct WebRTC endpoint. It binds +to its configured IP/ports and accepts connections. TURN is needed when *clients* +are behind symmetric NATs that prevent direct UDP connectivity to the server. +Having the server relay packets to itself via its own TURN server is circular +and unnecessary. + +**ICE configuration per topology**: + +| Scenario | Server ICE Config | Client ICE Config | +| -------- | ----------------- | ----------------- | +| LAN (direct) | Host candidate on bind address | STUN only | +| Port-forwarded | `external_ip` as server reflexive | STUN + TURN fallback | +| Behind NAT (no port forward) | `external_ip` required | TURN required | + +**Server-side Pion ICE settings**: + +- `SetNAT1To1IPs([external_ip])` when `voice.external_ip` is set +- `SetNetworkTypes([NetworkTypeUDP4])` — UDP only, no TCP ICE +- No TURN servers configured on the server's ICE agent + +**Client-side ICE servers** (returned by `GET /api/v1/voice/credentials`): + +- STUN: `stun::3478` +- TURN (if enabled): `turn::3478` with time-limited HMAC credentials + +**Updated REST response** (`GET /api/v1/voice/credentials`): + +```json +{ + "ice_servers": [ + { "urls": ["stun:chat.example.com:3478"] }, + { + "urls": ["turn:chat.example.com:3478"], + "username": "timestamp:userid", + "credential": "hmac" + } + ], + "quality": "medium", + "bitrate": 64000 +} +``` + +Added `quality` and `bitrate` so clients can pre-configure Opus before signaling. + +### Gap 5: Video/Screenshare Lifecycle + +**Signaling**: Video and screenshare are controlled via **explicit WebSocket messages**, +not by detecting track types. This keeps the protocol explicit and allows permission +checks before track negotiation. + +**New messages (Client → Server)**: + +```json +{ "type": "voice_camera", "payload": { "enabled": true } } +{ "type": "voice_screenshare", "payload": { "enabled": true } } +``` + +**Server behavior on `voice_camera`/`voice_screenshare`**: + +1. **Permission check**: Verify `USE_VIDEO` (bit 11) + for camera, `SHARE_SCREEN` (bit 12) for screenshare +2. **Capacity check**: Count active video streams. If + ≥ limit (default 10, via `voice_max_video`), return + `error` with code `VIDEO_LIMIT` +3. **Update DB**: Set `camera`/`screenshare` boolean in `voice_states` +4. **Broadcast**: Send updated `voice_state` to all channel members +5. **Renegotiation**: Server signals readiness for the + client to add the video track via WebRTC + renegotiation (`AddTransceiverFromKind`, client adds + track and sends new `voice_offer`) + +**Disabling**: Client sends `{ "enabled": false }`. +Server removes the track reference, broadcasts updated +`voice_state`, Pion handles track removal on next +renegotiation. + +**Track type mapping**: + +- Camera → `video` track with `streamId` = `"camera"` +- Screenshare → `video` track with `streamId` = `"screen"` +- Server distinguishes by stream ID, not by inspecting content + +**Additional schema** (add to migration 003): + +```sql +ALTER TABLE channels ADD COLUMN voice_max_video INTEGER NOT NULL DEFAULT 10; +``` + +**Additional error code**: + +| Code | When | +| ------ | ------ | +| `VIDEO_LIMIT` | At max simultaneous video streams | + +**Rate limit**: `voice_camera`/`voice_screenshare` — +2/sec per user (prevents toggle spam). + +--- + +## What This Design Does NOT Include + +- **Audio mixing**: No Opus decode/encode on server. + Pure packet forwarding. Preserves no-CGO builds. +- **Background mix**: Non-top-N silenced, not mixed. +- **Echo cancellation**: Client-side only. +- **Recording**: No server-side recording. Out of scope. +- **Spatial audio**: Standard stereo only. +- **Adaptive bitrate**: Fixed bitrate, no dynamic + adjustment (could add via RTCP feedback later). +- **LiveKit**: Excluded for single-binary architecture. +- **E2E encryption**: DTLS-SRTP only. Server is trusted + endpoint. No Insertable Streams / SFrame. + +--- + +## Dependencies + +### Server (Go) — All Pure Go + +- `pion/webrtc/v4` — WebRTC stack +- `pion/interceptor` — NACK, RTCP reports +- `pion/rtp` — RTP parsing, RFC 6464 header reading +- `pion/sdp/v3` — SDP parsing and validation + +No CGO. No libopus. Server reads RTP headers and +forwards encrypted packets only. + +### Client (Windows Native) + +- RNNoise (~2MB) for Enhanced noise suppression +- Opus codec (bundled via WebRTC library) +- WASAPI for audio device management +- WebRTC client with RFC 6464 support + +--- + +## Success Criteria + +1. 50+ users per channel on 4-core desktop without + quality degradation for active speakers +2. Forwarding mode: full per-user volume control +3. Selective mode: top-N with per-user volume; + non-speakers silenced +4. VAD reduces bandwidth by 60%+ in typical usage +5. Mode transitions are seamless — no audio drops +6. All voice in `chatserver.exe` — no external deps +7. Client noise suppression ≤3% CPU on quad-core +8. Graceful crash recovery — stale states cleared, + clients reconnect automatically +9. Per-user volume for all forwarded streams diff --git a/skills/go-server/SKILL.md b/skills/go-server/SKILL.md deleted file mode 100644 index b38bc800..00000000 --- a/skills/go-server/SKILL.md +++ /dev/null @@ -1,307 +0,0 @@ ---- -name: go-server -description: Patterns and best practices for building the Go chat server backend (chatserver.exe). Use this skill whenever working on the server side of the ChatServer project — API handlers, middleware, config loading, file serving, authentication, or any Go code in the server/ directory. Also use when creating new REST endpoints, adding middleware, embedding static files, or structuring Go packages. Trigger on any Go server task even if the user just says "add an endpoint" or "fix the server." ---- - -# Go Server Patterns - -Read this before writing any Go code in the `server/` directory. These patterns keep the codebase consistent. - -## Project Layout - -``` -server/ -├── main.go ← entry point, wires everything together -├── go.mod -├── config/ -│ └── config.go ← load config.yaml, env overrides, defaults -├── db/ -│ ├── db.go ← open SQLite, run migrations -│ ├── queries.go ← all SQL queries as methods on a DB struct -│ └── migrations/ ← numbered .sql files (001_init.sql, etc.) -├── auth/ -│ ├── auth.go ← bcrypt, session create/validate/revoke -│ ├── middleware.go ← RequireAuth, RequireRole, RateLimit middleware -│ └── totp.go ← TOTP setup, verify -├── api/ -│ ├── router.go ← chi router setup, mount all routes -│ ├── auth_handlers.go -│ ├── channel_handlers.go -│ ├── message_handlers.go -│ ├── upload_handlers.go -│ ├── admin_handlers.go -│ └── helpers.go ← JSON response helpers, error formatting -├── ws/ -│ ├── hub.go ← central hub, channel subscriptions, broadcast -│ ├── client.go ← per-connection read/write goroutines -│ ├── handlers.go ← handle each message type -│ └── types.go ← message structs matching PROTOCOL.md -├── voice/ -│ ├── sfu.go ← Pion SFU setup -│ ├── turn.go ← built-in TURN relay -│ └── signaling.go ← WebRTC signaling via WebSocket -├── storage/ -│ └── files.go ← upload validation, EXIF strip, serve with auth -├── admin/ -│ └── static/ ← HTML/CSS/JS for admin panel (embedded) -└── migrations/ - ├── 001_init.sql - └── 002_fts.sql -``` - -## Conventions - -### Entry Point (main.go) - -```go -package main - -import ( - "embed" - // ... -) - -//go:embed admin/static/* -var adminFS embed.FS - -var version = "dev" // set via -ldflags at build time - -func main() { - cfg := config.Load("config.yaml") - database := db.Open(cfg.DataDir + "/chatserver.db") - database.Migrate() - - hub := ws.NewHub(database) - go hub.Run() - - router := api.NewRouter(database, hub, adminFS, cfg) - - // TLS or plain HTTP based on config - server := &http.Server{Addr: ":" + cfg.Port, Handler: router} - // ... start server with appropriate TLS mode -} -``` - -### Config Loading - -```go -// Always provide sensible defaults. Never crash on missing config. -type Config struct { - Port string `yaml:"port" env:"PORT" default:"8443"` - ServerName string `yaml:"server_name" default:"My Server"` - DataDir string `yaml:"data_dir" default:"data"` - MaxUploadMB int `yaml:"max_upload_mb" default:"25"` - VoiceQuality string `yaml:"voice_quality" default:"medium"` // low, medium, high - TLSMode string `yaml:"tls_mode" default:"self-signed"` // self-signed, acme, manual, off - TLSDomain string `yaml:"tls_domain"` - TLSCert string `yaml:"tls_cert"` - TLSKey string `yaml:"tls_key"` -} -``` - -### API Handler Pattern - -Every handler follows this structure: - -```go -func (h *Handler) CreateChannel(w http.ResponseWriter, r *http.Request) { - // 1. Get authenticated user from context (set by auth middleware) - user := auth.UserFromContext(r.Context()) - - // 2. Check permissions - if !user.HasPermission(permissions.ManageChannels) { - respondError(w, http.StatusForbidden, "FORBIDDEN", "Insufficient permissions") - return - } - - // 3. Parse and validate input - var input struct { - Name string `json:"name"` - Type string `json:"type"` - Category string `json:"category"` - } - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - respondError(w, http.StatusBadRequest, "INVALID_INPUT", "Invalid JSON") - return - } - input.Name = sanitize(input.Name) - - // 4. Business logic (database call) - channel, err := h.db.CreateChannel(input.Name, input.Type, input.Category) - if err != nil { - respondError(w, http.StatusInternalServerError, "SERVER_ERROR", "Failed to create channel") - return - } - - // 5. Side effects (broadcast via WebSocket, audit log) - h.hub.BroadcastAll(ws.Message{Type: "channel_create", Payload: channel}) - h.db.AuditLog(user.ID, "channel_create", "channel", channel.ID, nil) - - // 6. Respond - respondJSON(w, http.StatusCreated, channel) -} -``` - -### Response Helpers - -```go -func respondJSON(w http.ResponseWriter, status int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(data) -} - -func respondError(w http.ResponseWriter, status int, code, message string) { - respondJSON(w, status, map[string]string{"error": code, "message": message}) -} -``` - -### Middleware Stack - -```go -r := chi.NewRouter() - -// Global middleware -r.Use(middleware.RealIP) -r.Use(middleware.Logger) // or custom slog middleware -r.Use(middleware.Recoverer) -r.Use(securityHeaders) // HSTS, CSP, X-Frame-Options -r.Use(rateLimiter(30)) // 30 req/sec per IP globally - -// Public routes -r.Post("/api/auth/register", h.Register) -r.Post("/api/auth/login", h.Login) -r.Get("/api/health", h.Health) - -// Authenticated routes -r.Group(func(r chi.Router) { - r.Use(auth.RequireAuth(db)) // validates session token - r.Get("/api/channels", h.ListChannels) - r.Get("/api/channels/{id}/messages", h.GetMessages) - // ... -}) - -// Admin routes -r.Group(func(r chi.Router) { - r.Use(auth.RequireAuth(db)) - r.Use(auth.RequireRole("admin", "owner")) - r.Get("/api/admin/stats", h.AdminStats) - // ... -}) - -// Admin panel static files -r.Handle("/admin/*", http.StripPrefix("/admin/", http.FileServer(http.FS(adminSubFS)))) -``` - -### Permission Checking - -```go -// Permissions are bitfields. Check with bitwise AND. -type Permission uint32 - -const ( - PermSendMessages Permission = 1 << 0 - PermReadMessages Permission = 1 << 1 - PermAttachFiles Permission = 1 << 5 - PermAddReactions Permission = 1 << 6 - // ... see SCHEMA.md for full list - PermAdministrator Permission = 1 << 30 -) - -func (u *User) HasPermission(p Permission) bool { - if u.Permissions&PermAdministrator != 0 { - return true // admin bypasses all - } - return u.Permissions&p != 0 -} - -// For channel-specific overrides: -func (u *User) HasChannelPermission(channelID int, p Permission, db *DB) bool { - if u.HasPermission(PermAdministrator) { - return true - } - base := u.Permissions - override := db.GetChannelOverride(channelID, u.RoleID) - effective := (base | override.Allow) & ^override.Deny - return effective&p != 0 -} -``` - -### Input Sanitization - -```go -import "github.com/microcosm-cc/bluemonday" - -var sanitizer = bluemonday.StrictPolicy() // strips ALL HTML - -func sanitize(input string) string { - // Strip HTML - clean := sanitizer.Sanitize(input) - // Remove null bytes and control characters - clean = strings.Map(func(r rune) rune { - if r < 32 && r != '\n' && r != '\r' && r != '\t' { - return -1 - } - return r - }, clean) - return strings.TrimSpace(clean) -} -``` - -### File Upload Validation - -```go -func validateUpload(file multipart.File, header *multipart.FileHeader, maxBytes int64) error { - // 1. Size check - if header.Size > maxBytes { - return errors.New("file too large") - } - - // 2. Read first 512 bytes for magic byte detection - buf := make([]byte, 512) - n, _ := file.Read(buf) - file.Seek(0, 0) // reset reader - - // 3. Detect real content type (not from extension) - mime := http.DetectContentType(buf[:n]) - - // 4. Block dangerous types - blocked := []string{".exe", ".bat", ".cmd", ".ps1", ".scr", ".msi", ".com", ".vbs", ".js", ".wsf"} - ext := strings.ToLower(filepath.Ext(header.Filename)) - for _, b := range blocked { - if ext == b { - return errors.New("file type not allowed") - } - } - - // 5. Block if MIME doesn't match safe list - if !isAllowedMIME(mime) { - return errors.New("file type not allowed") - } - - return nil -} -``` - -### Build Command - -```bash -# Development -go run . - -# Production build -go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . - -# Cross-compile (if building on Linux/Mac for Windows) -GOOS=windows GOARCH=amd64 go build -o chatserver.exe -ldflags "-s -w" . -``` - -## Security Checklist (for every new feature) - -- [ ] Input sanitized with bluemonday before storage -- [ ] Permissions checked server-side before any action -- [ ] Rate limiting applied to the endpoint -- [ ] Audit log entry for destructive/admin actions -- [ ] Error messages don't leak internal details -- [ ] File paths don't allow traversal (use UUIDs, not user filenames) -- [ ] SQL queries use parameterized statements (never string concat) diff --git a/skills/sqlite-patterns/SKILL.md b/skills/sqlite-patterns/SKILL.md deleted file mode 100644 index 94822dbf..00000000 --- a/skills/sqlite-patterns/SKILL.md +++ /dev/null @@ -1,326 +0,0 @@ ---- -name: sqlite-patterns -description: Patterns for SQLite database access in the chat server — connection setup, migrations, query patterns, full-text search, and backup. Use this skill when working on the db/ package, writing SQL queries, creating migrations, implementing search, or handling database backups. Trigger when the user mentions SQLite, database, migration, query, search, FTS5, backup, or schema changes. Also use when debugging slow queries or data integrity issues. ---- - -# SQLite Patterns for ChatServer - -## Connection Setup - -```go -import ( - "database/sql" - _ "modernc.org/sqlite" -) - -func Open(path string) (*DB, error) { - db, err := sql.Open("sqlite", path) - if err != nil { - return nil, err - } - - // Essential pragmas — run on every connection - pragmas := []string{ - "PRAGMA journal_mode=WAL", // concurrent reads, better performance - "PRAGMA foreign_keys=ON", // enforce FK constraints - "PRAGMA busy_timeout=5000", // wait 5s on lock instead of failing - "PRAGMA synchronous=NORMAL", // safe with WAL, faster than FULL - "PRAGMA cache_size=-20000", // 20MB cache - "PRAGMA temp_store=MEMORY", // temp tables in memory - } - for _, p := range pragmas { - db.Exec(p) - } - - return &DB{db: db}, nil -} - -type DB struct { - db *sql.DB -} -``` - -## Migration System - -``` -migrations/ -├── 001_init.sql ← core tables (users, channels, messages, etc.) -├── 002_fts.sql ← FTS5 virtual table + triggers -├── 003_soundboard.sql ← soundboard table -└── ... -``` - -```go -//go:embed migrations/*.sql -var migrationsFS embed.FS - -func (d *DB) Migrate() error { - // Create version tracking - d.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER)`) - - var current int - d.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(¤t) - - files, _ := fs.ReadDir(migrationsFS, "migrations") - for _, f := range files { - // Extract version number from filename: "001_init.sql" -> 1 - num := extractVersion(f.Name()) - if num <= current { - continue - } - - data, _ := fs.ReadFile(migrationsFS, "migrations/"+f.Name()) - tx, _ := d.db.Begin() - _, err := tx.Exec(string(data)) - if err != nil { - tx.Rollback() - return fmt.Errorf("migration %s failed: %w", f.Name(), err) - } - tx.Exec("INSERT INTO schema_version (version) VALUES (?)", num) - tx.Commit() - slog.Info("applied migration", "file", f.Name()) - } - return nil -} -``` - -## Query Patterns - -### Always Use Parameterized Queries - -```go -// CORRECT — parameterized -row := d.db.QueryRow("SELECT id, username FROM users WHERE username = ?", username) - -// NEVER DO THIS — SQL injection -row := d.db.QueryRow("SELECT * FROM users WHERE username = '" + username + "'") -``` - -### Common Query Methods - -```go -// Single row -func (d *DB) GetUser(id int) (*User, error) { - var u User - err := d.db.QueryRow(` - SELECT u.id, u.username, u.avatar, u.status, r.permissions, r.name as role_name - FROM users u JOIN roles r ON u.role_id = r.id - WHERE u.id = ? AND u.banned = 0 - `, id).Scan(&u.ID, &u.Username, &u.Avatar, &u.Status, &u.Permissions, &u.RoleName) - if err == sql.ErrNoRows { - return nil, nil - } - return &u, err -} - -// Multiple rows -func (d *DB) GetMessages(channelID, beforeID, limit int) ([]Message, error) { - query := ` - SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp - FROM messages m - JOIN users u ON m.user_id = u.id - WHERE m.channel_id = ? AND m.id < ? - ORDER BY m.id DESC - LIMIT ? - ` - rows, err := d.db.Query(query, channelID, beforeID, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - var messages []Message - for rows.Next() { - var m Message - rows.Scan(&m.ID, &m.ChannelID, &m.UserID, &m.Username, &m.Avatar, - &m.Content, &m.ReplyTo, &m.EditedAt, &m.Deleted, &m.Pinned, &m.Timestamp) - messages = append(messages, m) - } - return messages, rows.Err() -} - -// Insert returning ID -func (d *DB) CreateMessage(channelID, userID int, content string, replyTo *int, attachments []string) (*Message, error) { - tx, _ := d.db.Begin() - defer tx.Rollback() - - res, err := tx.Exec(` - INSERT INTO messages (channel_id, user_id, content, reply_to) - VALUES (?, ?, ?, ?) - `, channelID, userID, content, replyTo) - if err != nil { - return nil, err - } - - id, _ := res.LastInsertId() - - // Link attachments - for _, aid := range attachments { - tx.Exec("UPDATE attachments SET message_id = ? WHERE id = ?", id, aid) - } - - tx.Commit() - - // Fetch the complete message for broadcasting - return d.GetMessage(int(id)) -} -``` - -### Use Transactions for Multi-Step Operations - -```go -func (d *DB) BanUser(userID int, reason string, expiresAt *time.Time) error { - tx, _ := d.db.Begin() - defer tx.Rollback() - - // Ban the user - tx.Exec("UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?", - reason, expiresAt, userID) - - // Revoke all sessions - tx.Exec("DELETE FROM sessions WHERE user_id = ?", userID) - - return tx.Commit() -} -``` - -## Full-Text Search (FTS5) - -### Setup (in migration 002_fts.sql) - -```sql --- Virtual table -CREATE VIRTUAL TABLE messages_fts USING fts5( - content, - content='messages', - content_rowid='id' -); - --- Keep FTS in sync with triggers -CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN - INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); -END; - -CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN - INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); -END; - -CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN - INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); - INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); -END; -``` - -### Search Query - -```go -func (d *DB) Search(userID int, query string, channelID *int, limit int) ([]SearchResult, error) { - // User can only search channels they have read permission for. - // Build list of accessible channel IDs first. - accessibleChannels := d.GetAccessibleChannelIDs(userID) - - sql := ` - SELECT m.id, m.channel_id, c.name, m.user_id, u.username, - snippet(messages_fts, 0, '**', '**', '...', 32) as snippet, - m.timestamp - FROM messages_fts - JOIN messages m ON m.id = messages_fts.rowid - JOIN channels c ON m.channel_id = c.id - JOIN users u ON m.user_id = u.id - WHERE messages_fts MATCH ? - AND m.channel_id IN (` + placeholders(len(accessibleChannels)) + `) - AND m.deleted = 0 - ORDER BY rank - LIMIT ? - ` - args := []interface{}{query} - for _, id := range accessibleChannels { - args = append(args, id) - } - args = append(args, limit) - - // ... execute and scan -} -``` - -## Session Management - -```go -func (d *DB) CreateSession(userID int, ip, device string) (string, error) { - token := generateSecureToken() // 256-bit random, hex encoded - expiresAt := time.Now().Add(30 * 24 * time.Hour) - - _, err := d.db.Exec(` - INSERT INTO sessions (user_id, token, ip_address, device, expires_at) - VALUES (?, ?, ?, ?, ?) - `, userID, token, ip, device, expiresAt) - - return token, err -} - -func (d *DB) ValidateSession(token string) (*User, error) { - var u User - err := d.db.QueryRow(` - SELECT u.id, u.username, u.avatar, u.status, r.permissions, r.name - FROM sessions s - JOIN users u ON s.user_id = u.id - JOIN roles r ON u.role_id = r.id - WHERE s.token = ? AND s.expires_at > datetime('now') AND u.banned = 0 - `, token).Scan(&u.ID, &u.Username, &u.Avatar, &u.Status, &u.Permissions, &u.RoleName) - - if err == nil { - // Update last_used - d.db.Exec("UPDATE sessions SET last_used = datetime('now') WHERE token = ?", token) - } - return &u, err -} -``` - -## Backup - -```go -func (d *DB) Backup(destPath string) error { - // SQLite backup API via SQL - _, err := d.db.Exec("VACUUM INTO ?", destPath) - return err - // VACUUM INTO creates a clean copy, safe to call while the server is running. - // The backup is a standalone .db file. -} -``` - -For full backup (database + uploads): - -```go -func FullBackup(cfg Config) error { - timestamp := time.Now().Format("2006-01-02_150405") - backupDir := filepath.Join(cfg.DataDir, "backups") - os.MkdirAll(backupDir, 0755) - - // 1. Backup database - dbBackup := filepath.Join(backupDir, timestamp+"_db.sqlite") - d.Backup(dbBackup) - - // 2. Create zip of database + uploads - zipPath := filepath.Join(backupDir, timestamp+".zip") - createZip(zipPath, []string{dbBackup, filepath.Join(cfg.DataDir, "uploads")}) - - // 3. Clean up temp db copy - os.Remove(dbBackup) - - // 4. Prune old backups (keep N most recent) - pruneBackups(backupDir, cfg.BackupRetention) - - return nil -} -``` - -## Performance Notes - -- SQLite handles the read/write load of a small chat server trivially. -- WAL mode allows concurrent reads while writing. -- Single-writer is fine — at this scale, writes complete in microseconds. -- Index on `messages(channel_id, id DESC)` is critical for paginated history. -- FTS5 queries are very fast — sub-millisecond for typical search volumes. -- `VACUUM INTO` for backups doesn't block the main database. -- If the database grows large (>1GB), consider archiving old messages to a separate file. diff --git a/skills/webrtc-voice/SKILL.md b/skills/webrtc-voice/SKILL.md deleted file mode 100644 index cce08e2e..00000000 --- a/skills/webrtc-voice/SKILL.md +++ /dev/null @@ -1,229 +0,0 @@ ---- -name: webrtc-voice -description: Patterns for implementing WebRTC voice chat, video calls, screen sharing, and the Pion SFU/TURN relay. Use this skill when working on anything related to voice channels, video, screen sharing, the Pion media server, TURN relay, audio processing, noise suppression, soundboard, or WebRTC signaling. Trigger when the user mentions voice, audio, video, call, screen share, SFU, TURN, STUN, Pion, Opus, DTLS, SRTP, or RNNoise. Also use for debugging audio device issues or WebRTC connection problems. ---- - -# WebRTC Voice & Video Patterns - -## Architecture Overview - -``` -Client A Server (Pion SFU) Client B - │ │ │ - │── voice_join ─────────────►│◄──────────── voice_join ────│ - │ │ │ - │── voice_offer (SDP) ──────►│ │ - │◄── voice_answer (SDP) ─────│ │ - │◄─► voice_ice (candidates) ─│ │ - │ │── voice_offer (SDP) ───────►│ - │ │◄── voice_answer (SDP) ──────│ - │ │◄─► voice_ice (candidates) ──│ - │ │ │ - │══ DTLS-SRTP audio ═══════►│═══ DTLS-SRTP audio ════════►│ - │◄══ DTLS-SRTP audio ═══════│◄═══ DTLS-SRTP audio ════════│ -``` - -The server is an SFU (Selective Forwarding Unit): -- Each client sends ONE audio/video stream to the server. -- The server forwards that stream to every other client in the channel. -- The server never decodes or inspects media — it forwards encrypted packets. -- Much more efficient than mesh (where every client connects to every other client). - -## Server Side (Go + Pion) - -### SFU Setup - -```go -import ( - "github.com/pion/webrtc/v4" - "github.com/pion/turn/v3" -) - -// One PeerConnection per client per voice channel. -// Track forwarding: when Client A adds a track, create a new track -// on every other client's PeerConnection and forward RTP packets. - -type VoiceChannel struct { - ID int - Clients map[int]*VoiceClient // user_id -> client - mu sync.RWMutex -} - -type VoiceClient struct { - UserID int - PeerConnection *webrtc.PeerConnection - AudioTrack *webrtc.TrackLocalStaticRTP // outgoing track to this client -} -``` - -### Signaling Flow (server handles via WebSocket) - -``` -1. Client sends "voice_join" with channel_id -2. Server creates a PeerConnection for this client -3. Server sends "voice_offer" (SDP) to client -4. Client responds with "voice_answer" (SDP) -5. Both exchange ICE candidates via "voice_ice" -6. Media flows once ICE completes - -When a new client joins an existing channel: -- Create PeerConnection for new client -- For each existing client's audio track: - → Add a forwarding track to the new client's PC -- Add new client's audio track forwarding to all existing clients -- Renegotiate with all affected clients (send new offers) -``` - -### TURN Relay (built into the server binary) - -```go -// Embedded TURN server using pion/turn -// Listens on the same port as the main server or a configurable port - -func startTURN(cfg config.Config) { - // Generate time-limited credentials - // Shared secret between HTTP API and TURN server - // Client requests credentials via GET /api/voice/credentials - // Credentials are HMAC(timestamp:userid, sharedSecret) - // TURN server validates credentials using the same shared secret - // Credentials expire after 24 hours -} -``` - -### TURN Credential Generation (REST endpoint) - -```go -// GET /api/voice/credentials -func (h *Handler) VoiceCredentials(w http.ResponseWriter, r *http.Request) { - user := auth.UserFromContext(r.Context()) - - timestamp := time.Now().Add(24 * time.Hour).Unix() - username := fmt.Sprintf("%d:%d", timestamp, user.ID) - - mac := hmac.New(sha1.New, []byte(h.turnSecret)) - mac.Write([]byte(username)) - credential := base64.StdEncoding.EncodeToString(mac.Sum(nil)) - - respondJSON(w, 200, map[string]interface{}{ - "ice_servers": []map[string]interface{}{ - {"urls": "stun:" + h.cfg.PublicAddr + ":3478"}, - {"urls": "turn:" + h.cfg.PublicAddr + ":3478", - "username": username, "credential": credential}, - }, - "expires_in": 86400, - }) -} -``` - -### Voice Quality Presets - -``` -low: Opus 32kbps mono — minimal bandwidth, acceptable quality -medium: Opus 64kbps mono — good balance (default) -high: Opus 128kbps stereo — best quality, more bandwidth - -Configured in server config.yaml, applied when creating PeerConnections. -Set via SDP codec preferences or Opus parameters. -``` - -## Client Side - -### WebRTC Connection - -``` -1. Request TURN credentials from GET /api/voice/credentials -2. Create RTCPeerConnection with ICE servers from response -3. Get user media (microphone): - - Use selected audio device from settings - - Apply noise suppression (RNNoise) if enabled -4. Add audio track to PeerConnection -5. Handle signaling via existing WebSocket connection -6. On remote track received: play through selected output device -``` - -### Audio Pipeline (client) - -``` -Microphone (WASAPI) - ↓ -Noise Suppression (RNNoise, if enabled) - ↓ -Voice Activity Detection (energy-based threshold) - ↓ (if voice detected OR push-to-talk held) -Opus Encoder (via WebRTC) - ↓ -Send to Server (DTLS-SRTP) - -Received Audio (DTLS-SRTP from server) - ↓ -Opus Decoder (via WebRTC) - ↓ -Per-user Volume Adjustment (client-side mixer) - ↓ -Speaker Output (WASAPI) -``` - -### Push-to-Talk Logic - -``` -if mode == "push_to_talk": - mic_track.enabled = ptt_key_held - -if mode == "voice_activation": - mic_track.enabled = audio_level > sensitivity_threshold - -// Send voice_mute WebSocket event when mic state changes -// so other clients see the mute indicator -``` - -### Screen Sharing - -``` -1. User clicks "Share Screen" -2. Capture screen via DXGI Desktop Duplication -3. Encode as video track (H.264 or VP8) -4. Add video track to PeerConnection -5. Server forwards video track to all other clients in the channel -6. Receiving clients display in a video panel - -Cap at 720p by default. Lower resolution if bandwidth is constrained. -Show "X is sharing their screen" indicator. -Only one screen share per channel at a time. -``` - -### Soundboard - -``` -1. User triggers a soundboard sound (button click or hotkey) -2. Client sends "soundboard_play" WebSocket message -3. Server validates: user has permission, cooldown not active -4. Server loads audio file, encodes as RTP packets -5. Server mixes into the voice channel audio (or sends as separate track) -6. All clients in the channel hear the sound - -Alternative (simpler): -- Client plays the sound locally AND sends the audio via their mic track -- Requires temporarily mixing the soundboard audio into the mic stream -``` - -## Debugging Tips - -### ICE Connection Fails -- Most common cause: NAT traversal failure -- Check TURN server is reachable: `turnutils_uclient -t -u user -w pass server:3478` -- Check firewall allows UDP on TURN port -- Check TURN credentials are valid (not expired) -- Client should log ICE connection state changes - -### Audio Not Working -- Check selected audio device is valid (devices can be unplugged) -- Check mic permissions (Windows may block mic access) -- Check audio track is enabled (not muted) -- Check Opus codec is negotiated in SDP -- Verify audio levels: add a meter before and after the pipeline - -### High Latency -- Prefer UDP (TURN over UDP, not TCP) -- Check if traffic is being relayed through TURN when direct P2P is possible -- Reduce Opus frame size for lower latency (at cost of bandwidth) -- Check server CPU — SFU forwarding should be near-zero CPU diff --git a/skills/websocket-protocol/SKILL.md b/skills/websocket-protocol/SKILL.md deleted file mode 100644 index 2679ac76..00000000 --- a/skills/websocket-protocol/SKILL.md +++ /dev/null @@ -1,303 +0,0 @@ ---- -name: websocket-protocol -description: Patterns for implementing the WebSocket hub, client connections, message routing, and real-time features. Use this skill when working on the WebSocket server (ws/ package), client-side WebSocket connection, message broadcasting, typing indicators, presence tracking, reconnection logic, or any real-time messaging feature. Trigger when the user mentions WebSocket, hub, broadcast, real-time, typing, presence, reconnect, or message delivery. Also use when debugging message delivery issues or connection drops. ---- - -# WebSocket Hub Patterns - -## Server Hub Architecture - -``` - ┌─────────────┐ - │ Hub │ - │ (1 per │ - │ server) │ - └──────┬──────┘ - │ - ┌────────────┼────────────┐ - │ │ │ - ┌─────┴─────┐ ┌───┴─────┐ ┌───┴─────┐ - │ Client A │ │Client B │ │Client C │ - │ (2 gorout)│ │ │ │ │ - │ read|write│ │ │ │ │ - └───────────┘ └─────────┘ └─────────┘ -``` - -Each WebSocket connection gets: -- 1 read goroutine (reads messages from client, sends to hub) -- 1 write goroutine (reads from a channel, writes to WebSocket) - -The Hub is the central router. It holds all connections and their channel subscriptions. - -## Hub Implementation (Go) - -```go -type Hub struct { - clients map[int]*Client // user_id -> client - channels map[int]map[int]bool // channel_id -> set of user_ids - register chan *Client - unregister chan *Client - broadcast chan BroadcastMsg - db *db.DB - mu sync.RWMutex -} - -type Client struct { - UserID int - Conn *websocket.Conn - Send chan []byte // buffered channel, write goroutine reads from this - Hub *Hub - Channels map[int]bool // channels this client is subscribed to -} - -type BroadcastMsg struct { - ChannelID int // 0 = broadcast to all - Exclude int // user_id to exclude (sender) - Data []byte -} - -func (h *Hub) Run() { - for { - select { - case client := <-h.register: - h.mu.Lock() - h.clients[client.UserID] = client - h.mu.Unlock() - h.broadcastPresence(client.UserID, "online") - - case client := <-h.unregister: - h.mu.Lock() - delete(h.clients, client.UserID) - h.mu.Unlock() - close(client.Send) - h.broadcastPresence(client.UserID, "offline") - - case msg := <-h.broadcast: - h.mu.RLock() - if msg.ChannelID == 0 { - // Broadcast to all connected clients - for uid, client := range h.clients { - if uid != msg.Exclude { - select { - case client.Send <- msg.Data: - default: - // Client send buffer full, drop message - } - } - } - } else { - // Broadcast to channel subscribers - for uid := range h.channels[msg.ChannelID] { - if uid != msg.Exclude { - if client, ok := h.clients[uid]; ok { - select { - case client.Send <- msg.Data: - default: - } - } - } - } - } - h.mu.RUnlock() - } - } -} -``` - -## Client Read/Write Goroutines - -```go -// Read goroutine: reads from WebSocket, dispatches to handler -func (c *Client) ReadPump() { - defer func() { - c.Hub.unregister <- c - c.Conn.Close() - }() - c.Conn.SetReadLimit(maxMessageSize) // 64KB - c.Conn.SetReadDeadline(time.Now().Add(pongWait)) - c.Conn.SetPongHandler(func(string) error { - c.Conn.SetReadDeadline(time.Now().Add(pongWait)) - return nil - }) - for { - _, message, err := c.Conn.ReadMessage() - if err != nil { - break - } - c.Hub.handleMessage(c, message) - } -} - -// Write goroutine: reads from Send channel, writes to WebSocket -func (c *Client) WritePump() { - ticker := time.NewTicker(pingPeriod) // 30 seconds - defer func() { - ticker.Stop() - c.Conn.Close() - }() - for { - select { - case message, ok := <-c.Send: - if !ok { - c.Conn.WriteMessage(websocket.CloseMessage, []byte{}) - return - } - c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) - c.Conn.WriteMessage(websocket.TextMessage, message) - - case <-ticker.C: - c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) - c.Conn.WriteMessage(websocket.PingMessage, nil) - } - } -} -``` - -## Message Routing - -```go -func (h *Hub) handleMessage(client *Client, raw []byte) { - var msg struct { - Type string `json:"type"` - ID string `json:"id"` - Payload json.RawMessage `json:"payload"` - } - if err := json.Unmarshal(raw, &msg); err != nil { - client.sendError(msg.ID, "INVALID_INPUT", "Invalid JSON") - return - } - - // Rate limiting per message type - if !h.rateLimiter.Allow(client.UserID, msg.Type) { - client.sendError(msg.ID, "RATE_LIMITED", "Slow down") - return - } - - switch msg.Type { - case "chat_send": - h.handleChatSend(client, msg.ID, msg.Payload) - case "chat_edit": - h.handleChatEdit(client, msg.ID, msg.Payload) - case "chat_delete": - h.handleChatDelete(client, msg.ID, msg.Payload) - case "typing_start": - h.handleTyping(client, msg.Payload) - case "presence_update": - h.handlePresence(client, msg.Payload) - case "reaction_add", "reaction_remove": - h.handleReaction(client, msg.ID, msg.Type, msg.Payload) - case "voice_join": - h.handleVoiceJoin(client, msg.Payload) - case "voice_leave": - h.handleVoiceLeave(client) - case "voice_offer", "voice_answer", "voice_ice": - h.handleVoiceSignal(client, msg.Type, msg.Payload) - case "voice_mute", "voice_deafen": - h.handleVoiceControl(client, msg.Type, msg.Payload) - case "soundboard_play": - h.handleSoundboard(client, msg.Payload) - default: - client.sendError(msg.ID, "INVALID_INPUT", "Unknown message type") - } -} -``` - -## Chat Send Handler (example) - -```go -func (h *Hub) handleChatSend(client *Client, reqID string, payload json.RawMessage) { - var input struct { - ChannelID int `json:"channel_id"` - Content string `json:"content"` - ReplyTo *int `json:"reply_to"` - Attachments []string `json:"attachments"` - } - json.Unmarshal(payload, &input) - - // 1. Permission check - if !client.User.HasChannelPermission(input.ChannelID, PermSendMessages, h.db) { - client.sendError(reqID, "FORBIDDEN", "Cannot send messages here") - return - } - - // 2. Sanitize - input.Content = sanitize(input.Content) - if len(input.Content) == 0 && len(input.Attachments) == 0 { - client.sendError(reqID, "INVALID_INPUT", "Message cannot be empty") - return - } - if len(input.Content) > 2000 { - client.sendError(reqID, "INVALID_INPUT", "Message too long") - return - } - - // 3. Store in database - msg, err := h.db.CreateMessage(input.ChannelID, client.UserID, input.Content, input.ReplyTo, input.Attachments) - if err != nil { - client.sendError(reqID, "SERVER_ERROR", "Failed to save message") - return - } - - // 4. Send ack to sender - client.sendJSON(map[string]interface{}{ - "type": "chat_send_ok", "id": reqID, - "payload": map[string]interface{}{"message_id": msg.ID, "timestamp": msg.Timestamp}, - }) - - // 5. Broadcast to channel (excluding sender) - h.broadcastToChannel(input.ChannelID, client.UserID, map[string]interface{}{ - "type": "chat_message", "payload": msg, - }) - - // 6. Update read states and mention counts - h.db.UpdateReadStates(input.ChannelID, msg.ID, input.Content) -} -``` - -## Client Reconnection (client-side) - -``` -State machine: - CONNECTED → (connection lost) → RECONNECTING → (success) → CONNECTED - ↓ (failure) - RECONNECTING (retry with backoff) - ↓ (max retries or auth expired) - DISCONNECTED (show login) - -Backoff schedule: 1s, 2s, 4s, 8s, 16s, 30s, 30s, 30s... -On reconnect success: - 1. Re-authenticate with stored token - 2. Server sends new "ready" payload with current state - 3. Client requests missed messages: GET /api/channels/{id}/messages?after={last_id} - 4. Client merges missed messages into local scrollback - 5. Update presence and unread counts - -Track last_received_message_id per channel to know what was missed. -``` - -## Rate Limits (enforced server-side) - -``` -chat_send: 10 per second per user -typing_start: 1 per 3 seconds per user per channel -presence_update: 1 per 10 seconds per user -reaction_*: 5 per second per user -voice_*: 20 per second per user (signaling can be bursty) -soundboard_play: 1 per 3 seconds per user - -Implementation: token bucket per (user_id, message_type). -On limit hit: send error with retry_after seconds, don't process the message. -``` - -## Constants - -```go -const ( - maxMessageSize = 65536 // 64KB max WebSocket message - writeWait = 10 * time.Second - pongWait = 60 * time.Second - pingPeriod = 30 * time.Second // must be < pongWait - maxChatLength = 2000 // characters - sendBufferSize = 256 // messages in client Send channel -) -```