From 086979b7e85ed886b092023e907f99acfa4284fa Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:06:14 +0200 Subject: [PATCH] Release v1.2.0-alpha.1 -> main (#1309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(admin): accept same-origin first-run setup requests A freshly generated config.yaml leaves allowed_origins commented out, so the list is empty. The setup handler's CSRF guard assumed "no Origin header means same-origin", but browsers send Origin on same-origin POSTs too — Chrome and Edge always, Firefox since 70. The admin panel's own setup call is one of those POSTs, so every new install hit "cross-origin setup request blocked" and could never create an owner account. The guard now accepts a request whose Origin names the same host:port as the request's own Host header, falling back to the allowlist otherwise. That is what the original comment intended. CSRF protection is unaffected: a cross-site attacker cannot set Origin, the browser does, and a foreign origin still needs an explicit allowlist entry. Scheme is not compared. Nothing in this server derives the external scheme (no r.TLS or X-Forwarded-Proto handling exists anywhere), so a scheme check would reject legitimate requests behind a TLS-terminating proxy. Tests: isSameOrigin table covering port/host/suffix/schemeless/opaque-origin cases, plus two handler-level tests pinning both halves — same-origin succeeds against an empty allowlist, a foreign origin still 403s and creates no user. Co-Authored-By: Claude Opus 5 (1M context) * feat(identity): implement identity keypair caching and error handling * fix(client): use the real OS credential store, not keyring's mock (#1281) The `keyring` crate declares no `default` feature. Every platform arm in its lib.rs selects a backend only when that platform's feature is on and otherwise falls through to `pub use mock as default`, so the client's bare `keyring = "3"` compiled the in-memory mock store on Windows, macOS and Linux alike. The mock keeps its secret in the `Entry` object itself, and each command built its own `Entry`: save_identity_key -> Entry::new(..) -> set_password -> Ok(()) load_identity_key -> Entry::new(..) -> get_password -> NoEntry So a save reported success, the very next read in the same process returned nothing, `NoEntry` was mapped to `Ok(None)` so neither side logged anything, and no entry was ever written to Credential Manager on any machine. Downstream, the voice-E2EE identity keypair was regenerated on reconnect, the published identity key stopped matching the key that signed the announce, and peers correctly rejected it as a possible MITM. Name the platform backends explicitly, and stop trusting a store that reports a write it did not keep: - secret_store: read every write back and compare before reporting success. If the store returns a value we did not write, purge it so it cannot shadow the fallback on the next read. - On Windows only, fall back to a DPAPI-protected file in the app data dir, engaged solely after a proven round-trip failure and cleared as soon as the real store works again. The account name is mixed into the DPAPI entropy so a blob cannot be moved between entries and decrypt. macOS/Linux report an error instead of writing secrets to plaintext. - Log the compiled backend at startup and add `probe_credential_store` so an affected machine can be diagnosed from its own log file. - Guard the regression: `compiled_keyring_backend_is_persistent` fails the build if the features are ever dropped again. Verified to fail against `keyring = "3"`. The E2EE fail-closed posture is unchanged: a peer whose announce signature does not verify is still rejected. Linux builds now need `libdbus-1-dev` for the Secret Service backend. Claude-Session: https://claude.ai/code/session_016oUHtEUWWxC79eB88GvX58 Co-authored-by: Claude * fix(client, admin): make the settings panel, client, and admin panel do what they say (#1282) * fix(client): make the settings panel do what it says Functional review of every control in the settings overlay. Each fix below closes a gap between what a control promised and what it did. - Appearance: picking a theme no longer drops a saved accent colour. applyThemeByName strips every inline custom property from , which includes the accent override; under neon-glow (whose body class sets --accent) the user's colour silently reverted until restart. - Overlay: reopening the panel rebuilds the active tab. The Voice & Audio mic meter and camera preview are torn down on close, so a reopened panel showed a dead meter and a black preview; tabs also now re-read prefs. The Logs tab's live listener is released when you switch away from it. - Status: the UserBar picker always started at "online" and never persisted, while the Account tab read a pref nobody else wrote — the two surfaces disagreed. Both now go through lib/userStatus, sync live via the pref-change event, and the saved status is re-asserted on connect. - Notifications: Do Not Disturb now suppresses the desktop notification and the chime, as its description in the panel claims. The taskbar flash, a passive cue, stays. - Keybinds: Ctrl+F, Ctrl+M, Ctrl+D, Ctrl+Shift+V and Ctrl+U were listed but unimplemented. They are wired now (voice ones only while in voice, all of them suspended while the settings panel is open). "Mark as Read" had no feature behind it at all and is replaced by the Escape behaviour that actually exists. - Account: backup codes now carry a "you won't see them again" warning and a copy button; the change-password form requires the current password before spending a server attempt and disables itself while in flight. - Advanced: removed the Hardware Acceleration toggle. Nothing read the preference it wrote — the webview decides GPU compositing before any JS runs, so honouring it needs a Rust startup change. - The settings sidebar name/avatar follow a rename instead of going stale, and settings/helpers no longer keeps a drifted copy of lib/preferences (the copy lacked the write guard, so a failed save could throw). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(client): close silent-failure gaps in the inline admin surface Continuation of the settings-panel review into the rest of the client. - Member context menu had no styling at all: AdminActions renders BEM class names (context-menu__item and friends) that appear nowhere in the CSS, so the menu had no hover, no danger colour, and the "Change Role" submenu pushed the menu open instead of flying out. Added the missing rules. - The submenu offered a hardcoded admin/moderator/member list. On a server with custom roles those roles were unreachable, and picking a name that didn't resolve to a role id silently did nothing. Roles now come from the server's ready payload (owner excluded), and an unresolvable role reports an error instead of dead-ending. - Kick / ban / delete-channel now show an in-flight state, and the two-click confirm disarms after a few seconds so a menu left open can't turn a stray click into a ban (docs/architecture/ux/settings-and-admin.md §3). - Ban collects a reason, which the server already stores and displays (adminBanMember has always accepted one; the menu never passed it). - Copying an invite code was silent: no confirmation, and a clipboard rejection looked identical to success. It now toasts either way. - Creating an invite double-click-minted two of them, and revoking — which kills a live link — had neither a confirm nor an in-flight guard. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(client): restore moderator message deletion and formatting - The delete affordance was offered only on your own messages, so a moderator could not moderate anything from the client. It now also appears when the signed-in user's role carries MANAGE_MESSAGES, derived from the role bitmasks the server already sends in `ready` (this is what docs/architecture/ux/messaging.md §4 specifies as "Delete (own / moderator)"). lib/permissions.ts existed for exactly this and had no callers at all. - Developer-mode "Copy ID" was silent on success and swallowed clipboard failures; it toasts either way now. - prettier --write on AdminActions.ts (Client Static Checks). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): stop the panel reporting success it didn't have Functional review of the server admin web panel. - An expired admin session left the panel on screen toasting "invalid or expired session" for every action, with no way back to the login form — only the log-stream code handled it. api() now handles 401 centrally: clear the token, return to login, and say why. - Deleting a backup called fetch() without looking at the response, so a failed delete reported "Backup deleted" and left the file in place. It now goes through api(), and — like every other destructive action here — asks for confirmation first. - A failed update check rendered as "Up to date. You're running the latest version", which is a lie that hides a broken update path. It now says the check failed and why. A failed apply no longer leaves the button stuck on "Applying...". - The Edit Channel modal could only rename. PATCH /channels/{id} accepts topic, slow_mode, position and archived, and the channel table has an Archived column — which was read-only state with no control behind it. All four are editable now. - Banned users showed "Yes" with no reason, even though the ban reason is collected on ban and returned by the API. It's now displayed. - Login and first-run setup had no in-flight guard, so a double-click spent two attempts against the login lockout / setup rate limit. Settings' Save stayed enabled after a successful save, implying unsaved changes. - Clipboard copies (invite code, new API token) had no rejection path: a refused clipboard looked exactly like a successful copy. - Backup names in inline onclick handlers go through jsq() like every other interpolated string. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * feat(admin): add the plugin management UI the backend already had /api/v1/admin/plugins has exposed list/install/enable/disable/uninstall since Phase C Step 9 — its own header says it "exposes plugin lifecycle operations to the admin panel", and docs/architecture/ux/settings-and-admin.md tells operators plugin management lives in the web panel. The panel had no Plugins section at all, so installing a plugin meant hand-crafting a multipart POST. Panel: - Plugins section: installed table (name, manifest description and requested permissions, version, enabled state, install date), zip upload with the 16 MB server cap stated up front, enable/disable, and uninstall behind a confirm. One lifecycle call at a time. - The lifecycle API sits under a different prefix than the rest of the panel and answers errors as plain text (http.Error), not JSON, so it gets its own fetch helper — sharing api() would have surfaced "unexpected token" instead of the server's reason. 401 still routes back to login. Server: - PluginRow had no JSON tags, so the list marshalled Go field names and every column would have rendered empty. Now snake_case like the rest of the API. - GET /plugins returns X-Plugin-Runtime: enabled|disabled. An empty list means "nothing installed" on a live runtime and "you can't install anything" on a disabled one; the body can't tell them apart, so the panel's empty state had no way to be honest about it. The plugin-store test helper now hands back the database the registry writes to — the existing happy-path test wired a *different* in-memory DB into the handler, which is why nothing noticed the list was always empty. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * feat(client): gate the composer on slow mode instead of failing the send Verified the optimistic message lifecycle against docs/architecture/ux — pending → chat_send_ok → sent, failed rows with mapped reasons, retry and delete-draft all behave as documented. One thing did not: slow mode. The UX spec (§5) says slow mode should "disable send with a live countdown in the composer; do not drop the drafted message". In practice the composer knew nothing about it: you typed, sent, and got a red failed row back — the exact enabled-then-rejected pattern §6.2 forbids. The client never even received the channel's slow_mode value. - Server: channel payloads (ready, channel_create, channel_update) now carry slow_mode alongside can_send, for the same reason can_send is there — the client can express the limit as affordance. The server still enforces. - Client: after an accepted send the composer disables itself for the channel's cooldown with a per-second countdown, and a SLOW_MODE refusal restarts the full window (the server's limiter is the authority on when the next send is allowed). The draft stays in the textarea. Moderators, who bypass slow mode server-side, are not gated. - The MANAGE_MESSAGES lookup added for moderator deletes moves into lib/permissions as currentUserPermissions/currentUserHasPermission/ canManageMessages, so the composer and the message renderer share one definition instead of two. - WsErrorCode listed 9 of the server's 16 codes: SLOW_MODE, CONFLICT, BAD_REQUEST, INVALID_JSON, UNKNOWN_TYPE, BAD_PAYLOAD, NOT_KEY_HOLDER and ALREADY_JOINED were missing, so code switching on it could not name cases the server actually sends. Now mirrors Server/ws/errors.go. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): make backup restore actually restart, and fail closed without a safety copy Verification pass over the remaining review items. Two real defects in restore, one duplicate resolved; cert TOFU and the replay path checked out as-is. Restore: - The handler closed the database, swapped the file underneath it, told the admin "database restored — server restarting", broadcast a 5-second restart countdown to every client... and then kept running. Nothing restarted it, so the server answered every subsequent request against a closed DB until an operator noticed. It now respawns for real, reusing the update-apply pattern (SpawnDetached → SIGTERM → os.Exit backstop) behind a test seam. - A failed pre-restore backup was a warning, and the irreversible overwrite went ahead anyway — removing the safety net the panel explicitly promises ("A pre-restore backup will be created"), precisely when it matters. It now aborts with the database untouched. - The safety copy was written to a cwd-relative "data/backups" while every other backup handler uses the absolute backupBaseDir, so a server started from another directory filed it somewhere the operator would never find. Both new tests were confirmed to fail against the previous behaviour. Client: - SidebarArea kept a private 140-line copy of the member-list wiring that SidebarMemberSection already provides (the extracted, tested one was never imported). Fixing the silent role-change failure earlier meant patching both; now there is one copy. Verified without changes: the optimistic send lifecycle (pending → chat_send_ok → sent, failed rows with mapped reasons, retry, delete-draft), reconnect replay (monotonic last_seq, dedup on reconnect, replay suppression of unread/notifications), and cert TOFU (first-use and mismatch modals, accept re-pins and reconnects, reject disconnects back to connect). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): remove the data race in the restart test hook CI (-race) failed identically on ubuntu and windows: TestHandleRestoreBackup_ Success polled a plain bool that the restore handler's goroutine wrote, and swapped the restartSelf package var from the test goroutine while that handler read it. The hook is now behind a mutex with an atomic flag in StubRestart. Production behaviour is unchanged — the race was entirely in the test seam I added. Verified with `go test -race -count=2 ./admin/`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm --------- Co-authored-by: Claude * refactor + perf: split largest source files into modules; optimize hot paths (#1283) * refactor(updater): split updater.go into cohesive files Split the 1070-line updater.go into four files within the same package: updater.go (core types, release checking), download.go (download and tarball extraction), verify.go (signatures, checksums, staged binary), and assets.go (client assets, text-asset cache, HTTP fetching). Pure mechanical move — no behavior or API changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(ws): split hub.go into cohesive files Split the 1289-line hub.go into five files within the same package: hub.go (Hub struct, lifecycle, register/unregister), hub_broadcast.go (broadcast fan-out and per-user sends), hub_events.go (sequencing, replay, persistence), hub_sweep.go (stale client/session/voice sweepers), and hub_livekit.go (LiveKit accessors). Also optimizes wrapWithSeq on the hot broadcast path: build the seq prefix with a single preallocated append + strconv.AppendUint instead of fmt.Sprintf, halving allocations per broadcast message. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(client): extract E2EEManager from livekitSession Move all client-side E2EE key-exchange logic (~550 lines) out of LiveKitSession into a new E2EEManager class in livekitE2EE.ts: ECDH keypair management, identity signing and TOFU pin verification, announce/offer handling, key-holder election, membership rekeying, and periodic key rotation. Dependencies are injected following the existing roomEventHandlers pattern. LiveKitSession keeps thin public delegates (handleE2EEAnnounce, handleE2EEOffer, handleParticipantLeft, rePinPeerIdentity) so the module-level bound exports and the public API are unchanged. livekitSession.ts shrinks from 1955 to 1409 lines. Adds focused unit tests for E2EEManager (key-holder setup, pending announce queue, offer resolution, clearState, rotation). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(server): hot-path and query optimizations Logging (biggest win): rewrite the admin log RingBuffer as a true ring (fixed array + head/count) instead of allocating a fresh 2000-entry slice + full copy per log line; gate the ring handler on a configurable level instead of unconditional DEBUG capture; move the broadcast debug log out of the seqMu critical section; drop the per-message slog.With clone in the WS handler. Database: new migration 019 adds idx_attachments_message (message pages no longer scan the attachments table), a covering role-leading index on channel_overrides (replacing a duplicate of the UNIQUE auto-index), a partial index for pinned messages, and narrows the FTS trigger to content changes only; ANALYZE runs after migrations. Rewrite GetChannelUnreadCounts and GetUserDMChannels to correlated subqueries that range-scan idx_messages_channel — O(unread) instead of O(all messages) per WS connect. New GetUserDMChannelIDs replaces the full DM query where only IDs are needed. CreateMessage/EditMessageContent use RETURNING, removing the re-read after every send/edit. Write-path contention: TouchSession throttled to once per minute per session (was one UPDATE per authenticated request); EventPersister flushes its batch in a single transaction with per-row fallback; revoked-session and stale-voice sweeps run off the hub dispatch goroutine with an in-flight guard, and session checks are batched into one IN query; the rate limiter is sharded into 32 buckets with allocation-free strconv key building (auth.Key). WS structural: voice E2EE channel fan-out goes through the existing pubsub voice topic instead of scanning every connected client under h.mu; channelReadAudience memoizes role lookups per call; hasChannelAccess drops its redundant duplicate permission check; voice_join batches SPEAK/VIDEO/SCREENSHARE checks via HasChannelPermBatch. Also: pubsub topic builders and NewAppMetrics stop allocating via Sprintf/global mutex. Verified with go test -race across all packages, go vet, gofmt, and sqlc generate idempotency. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(client): render-path, logging, and bundle optimizations Logging: the logger no longer runs permanently at debug — level is set from the environment at startup (debug in dev, info in prod), so every hot-path debug entry stops being serialized, buffered, consoled, and persisted to disk; per-URL debug logs in embed rendering removed. Render path: MessageList's store selector is scoped to the mounted channel, so messages in other channels no longer trigger re-renders, and a new incremental tail-append fast path appends rows instead of tearing down the whole window; Intl.DateTimeFormat instances are cached at module level; parseTimestamp memoizes epoch millis; media prefs (showEmbeds/inlineMedia/showLinkPreviews/animateGifs) are cached with pref-change invalidation; members store gains a roleRevision counter so MessageList stops rebuilding a role map on every presence/typing event. MemberList patches presence changes in place (status dot + offline class) via a row map instead of rebuilding every row, with single-pass role grouping. ChannelSidebar splits its voice subscription into a structural selector (excluding speaking) and a speaking-only patcher using a cached element map instead of per-user querySelector on every speaker event. Memory: GIF/media elements are unobserved before the message window discards them, fixing unbounded IntersectionObserver retention of detached DOM (including frozen-frame data URLs). Bundle: livekit-client (1.3 MB) moves to its own chunk via dynamic imports and manualChunks; the READY handler's stale-voice check reads the voice store instead of requiring the module synchronously. Adds 11 focused tests (different-channel no-rerender, append fast path, media release, presence patch, speaking patch). Full unit suite: 3606/3606 passing; typecheck, lint, and production build clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude * fix(ci): skip alloc test under deadlock tag; cut bcrypt cost in tests (#1284) The deadlock-tag CI pass failed on TestRingBuffer_WriteDoesNotAllocate: under -tags deadlock, syncutil.Mutex is the go-deadlock mutex whose Lock allocates, so the steady-state ring write measures 1 alloc/call. Extend the build constraint to !race && !deadlock — the test's guarantee is about the ring buffer itself, which the -race-less default pass covers. Make bcryptCost a var with an exported SetCostForTesting hook that also resets the dummy timing pad, and call it with bcrypt.MinCost from the api, auth, and admin TestMains. Password hashing at production cost 12 dominated those suites (~264 hashes): with the race detector the api package alone took ~860s; it now runs in ~33s. Nothing under test depends on hash strength, and no test asserts the cost. Hygiene in the same pass: migration 020 drops idx_sessions_token and idx_invites_code (exact duplicates of their UNIQUE auto-indexes, pure write overhead) with updated db_test assertions; remove the dead tar.TypeRegA comparison in the updater (stdlib normalises it to TypeReg since Go 1.11); gofmt storage/storage.go comment alignment. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude * perf(ws): route hot-path permission checks through the cached PermissionService (#1285) The ws package was the only major subsystem still doing live per-check permission queries (GetRoleForUser + GetChannelPermissions per check): a V2 voice join cost 9+ DB reads across its four gates, and every channel broadcast resolved one role query per connected client. Hub now holds svc.Permissions and the voice deps carry it (nil-safe: bare test fixtures fall back to the existing live path, fail-closed semantics preserved everywhere). Converted sites: the voice join and token-refresh permission gates, USE_VIDEO/SHARE_SCREEN controls, requireChannelAccess, channelReadAudience, and RefreshChannelVisibility. Caching these is revocation-correct: every permission-changing mutation already invalidates synchronously before hub fan-out (InvalidateUser on role change, InvalidateAll on override change), the 30s TTL is only a backstop, and the service's gen-counter guard prevents a populate that races an invalidation from caching stale data — the audience-resolution comments now document that invariant. The stale-voice sweeper's check deliberately stays live: it is the last-line backstop for revocations that might bypass an invalidation hook, runs once a minute for only in-voice clients, and its eviction test pins exactly that guarantee. requirePerm keeps its INTERNAL-vs-FORBIDDEN distinction by using the cache only for positive verdicts and falling through to the live path on denial. Adds perm_cache_test.go: role-change invalidation is immediate (no TTL wait), and a counting-store test proving the second check is served from cache. All pinning tests (authz, voice_perm_stale, channel visibility agreement, sweep eviction) pass unmodified. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude * perf + refactor: SQLite reader pool, async audits, real lazy-livekit, test splits, eslint 10 (#1286) * perf(db): batch audit writes through an async writer Audit inserts ran synchronously on the request path — including one INSERT per WebSocket connect — each an implicit transaction on the single SQLite connection. WriteAudit keeps its exact signature and D8 policy (never fail the caller, never silently discard): it now upgrades to an async path when the passed Auditor also implements AsyncAuditor. *DB implements that via an atomic pointer that main.go populates at server startup with an AuditWriter modeled on the event persister (bounded queue, batched single-transaction flush with per-row fallback, drain-on-stop, atomic counters, non-blocking enqueue that error-logs drops without leaking the detail field). The token CLI and tests never install a writer, so they keep today's synchronous behavior with zero call-site changes. The writer's Stop defer registers after database.Close's so the LIFO unwind drains the queue before the DB shuts. Adds audit_writer_test.go: batch flush, D8 drop logging, drain-on-stop, flush-failure accounting, poison-row fallback, concurrent enqueue, and seam tests pinning sync-without-writer vs async-with-writer behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(client): actually defer livekit-client; honor saved log level at startup The manualChunks split was cosmetic: index.html modulepreloaded the 531 kB livekit chunk and the entry statically imported it. All four import chains from startup are now cut — auth.store's logout leaveVoice and ptt's setMuted go through dynamic imports, applyStoredAppearance moved to lib/appearance.ts so main.ts and ConnectPage stop pulling the settings tree (whose overlay now loads on first open), and MainPage itself is a dynamic import in renderPage, guarded against the destroy-before-mount race by a navigation-generation helper and pre-warmed once the socket connects. Entry chunk drops 387 kB -> 114 kB (gzip 36 kB); index.html has no modulepreload links; livekit/MainPage/SettingsOverlay/livekitSession load as lazy chunks. The logger now honors the Logs tab's saved minimum level at startup (applyStoredLogLevel with the legacy-key migration moved into lib/preferences.ts) and re-applies it live on pref changes. Dead code: remove unreachable VoiceChannel.ts (superseded by ChannelSidebar's renderer) and its test, plus all knip-flagged unused re-exports in message-list/renderers.ts and ConnectPage's unused form types — knip is now clean apart from pre-existing config hints. Tests: +12 (navigation guard incl. stale-mount discard; logger startup pref, migration, and live re-apply); ptt/stored-appearance updated for dynamic-import plumbing only. Full suite 3593 passing; typecheck, lint, and production build clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(db): split SQLite into single-writer + multi-reader connection pools The entire server serialized on one SQLite connection: every read queued behind every other read and every write, throwing away WAL's concurrent-reader capability. File-backed databases now open two pools from a DSN that carries all seven PRAGMAs as per-connection _pragma parameters (an Exec'd PRAGMA only configures one arbitrary pooled connection — moving them into the DSN is what makes >1 connection safe, foreign_keys included): a single-connection writer with _txlock=immediate, and a reader pool sized max(4, NumCPU). In-memory databases keep the exact historical single-connection behavior, which preserves every :memory: test site and the connection-scoped PRAGMA-toggle tests untouched. Routing lives in a dbtx router implementing sqlc's DBTX: statements go to the reader only when provably read-only (leading SELECT/PRAGMA after skipping comments — necessary because sqlc routes INSERT/UPDATE/DELETE ... RETURNING through QueryRowContext/QueryContext, which must stay on the writer); Exec, transactions, migrations, ANALYZE, VACUUM INTO, and the SQLDb() escape hatch all pin to the writer. Every former sqlDB reference across the package was re-pointed deliberately. New pool_test.go pins the properties the split must preserve on a file-backed DB: foreign_keys=1 across many reader connections, WAL journal mode, FK enforcement through both write paths, 8x8 concurrent reader/writer hammering with exact row counts, and a read completing against the pre-tx snapshot while a write transaction is open — the property this change exists to unlock. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * test(client)+chore: split the two largest test files; eslint 10; audit clean Split tests/unit/ws.test.ts (3340 lines) into ws-cert / ws-reconnect / ws-messaging / ws-lifecycle plus a shared helpers/ws-mocks.ts module, and tests/unit/audio-pipeline.test.ts (2547 lines) into core / gain / vad-worklet / vad-fallback files. Test bodies moved verbatim; the suite count is unchanged at 3593 passing. Upgrade eslint 9 -> 10 (with @eslint/js 10; typescript-eslint's peer range already covers v10, flat config unchanged, zero new findings) and pin test-exclude ^8 via the existing overrides block so the coverage chain picks up patched glob/minimatch/brace-expansion. npm audit: 8 high -> 0 vulnerabilities. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(server): split remaining large files; dependency hygiene notes Split ws/coverage_boost_test.go (2856 lines) into coverage_helpers / chat / voice / voice_lifecycle / misc test files — bodies verbatim, 746 passing tests before and after. Split service/message.go (781) into message_crud / message_reactions / message_query / message_perms with types and the constructor staying put, and ws/serve.go (754) into serve / serve_pumps / serve_auth / serve_ready. Dependency findings (no changes needed): coraza-coreruleset's stale Feb-2024 pseudo-version is unreachable from our code — it enters the module graph only through coraza's own internal tests, and our WAF uses inline directives, never the CRS (fresher rules would require adopting the /v4 module and rewiring the WAF config — deliberate follow-up, not hygiene); gogo/protobuf is likewise graph-only via the livekit SDK and never built into our binaries. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * style: satisfy golangci-lint modernize/staticcheck in new pool and audit code CI's golangci-lint pass (not run locally until now) flagged the Phase 3/4 additions: range-over-int loops, interface{} -> any on the dbtx router, WaitGroup.Go in the pool tests, and a De Morgan simplification in isReadOnlySQL's identifier-boundary check. Pure style — verified against the same golangci-lint v2.11.3 binary CI uses (0 issues) and re-ran db/ws race + deadlock suites green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude * feat(waf) + fix(deps) + test(ws): OWASP CRS, Dependabot fixes, sleep-free ws tests (#1287) * fix(deps): clear quick-xml RUSTSEC advisories in Tauri lockfile cargo-audit identified the two Dependabot alerts on the default branch: quick-xml 0.37.5 and 0.38.4 both carry RUSTSEC-2026-0194 (quadratic runtime on duplicate-attribute checks) and RUSTSEC-2026-0195 (unbounded namespace allocation DoS), fixed in >=0.41. Both were transitive: plist 1.8.0 (via tauri) and tauri-winrt-notification 0.7.2 (via notify-rust). Semver-compatible updates fix both — plist 1.10.0 moves to quick-xml 0.41, and tauri-winrt-notification 0.7.3 drops quick-xml entirely. cargo-audit is now clean of vulnerabilities; the remaining 20 informational notices are the unmaintained GTK3-binding crates inherent to Tauri v2 on Linux. Verified plist compiles against quick-xml 0.41 (full Tauri build needs the GTK/WebKit system libs CI installs). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * feat(waf): layer the maintained OWASP Core Rule Set onto the WAF The WAF previously ran six inline directives only — the CRS never loaded (the old coreruleset dep was a stale graph-only pseudo-version). A second Coraza engine now loads the embedded CRS from coraza-coreruleset/v4 (v4.25.0), layered on top of the inline rules, which stay byte-identical and keep blocking exactly as before. CRS ships in a new server.waf_crs_mode knob (off|detect|block), defaulting to detect: chat traffic is CRS-false-positive-prone (a new test pins that block mode rejects benign SQL-ish chat prose at the default threshold), so operators get rule-match visibility via structured logs first and opt into blocking after tuning. Setup mirrors the official connector: Host/Transfer-Encoding restored to the transaction (else 920280 fires on everything), phase 2 always runs so query-string attacks are scored, PUT/PATCH/DELETE added to the CRS method policy for this REST API, body limits matched to the app's 1 MiB cap with uploads excluded from body access and the content-type policy. Also fixes a latent middleware bug: the body was previously swapped for the buffered reader even when nothing was buffered, which would have handed body-access-off routes an empty body; now pinned by a test across all modes. Adds waf_crs_test.go (load, mode wiring, XSS/traversal detection without blocking, block-mode blocking + benign passthrough, upload body preservation); waf_test.go passes unmodified. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * test(ws): replace fixed sleeps with condition-based waits The ws suite paced async hub effects with 537 fixed time.Sleep calls — slow at best, flaky under load at worst. They are now condition-based: a small waitFor/waitRegistered/waitClientCount/waitMsgOfType helper set (waitRegistered exploits the hub's in-order client-event processing), plus blocking decode-scans for the DM tests. The bulk deletion is grounded in verified production facts, unchanged by this commit: sendMsg is a synchronous buffered send (error replies are already buffered when the handler returns), the voice control / rollback / cleanup / sweep paths are synchronous, and serve.go registers the client before writing the ready frame. Absence assertions were deliberately NOT inverted into polling — they keep bounded windows, each commented. 20 sleeps remain, all justified in place: poll intervals inside condition loops, absence windows, clock-granularity pacing, and the event-pruner's inherently time-based no-prune-after-cancel assertion. Suite: 746 tests before and after; 62.6s -> 46.1s (30s of the remainder is GracefulStop's hard-coded production 5s drain, out of scope here); race flake check passes 3 consecutive iterations; deadlock pass and golangci-lint clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude * fix: audit-driven fixes — client leaks/lazy-load, WAF detect logging, audit shutdown race (#1288) * fix(waf,db): aggregate CRS detect-mode logging; make audit Stop await goroutine exit WAF detect mode wired logCRSMatch as the engine-level error callback, which fires one slog.Warn per matched rule on the request goroutine. In the default detect mode ordinary chat prose trips several CRS SQLi/XSS rules plus anomaly scoring, so each request logged a burst of Warn lines in the hot path. Aggregate per request from per-transaction state instead of the shared global callback: in the default detect path leave the engine error callback nil and, in the existing crsTx defer, emit at most one Warn per request that had matches (count + highest-severity rule), demoting the full rule-id list to Debug. Block mode keeps per-rule logging (blocked requests are rare and their detail is wanted), and a caller-supplied onCRSMatch callback keeps per-rule delivery so existing tests stay unmodified. Detection, interruption, and body handling are unchanged — only the detect-path logging shape. The audit writer's Stop selected between <-done and <-ctx.Done(); on a slow flush the 5s ctx could win, returning while run() was still flushing. main.go's LIFO defers then closed the DB pool under a live flusher, losing audits. Stop now always waits on done (the goroutine has stopped touching the store) while ctx bounds only the drain inside run() via a published stopCtxDone channel, so a slow store delays shutdown by at most one in-flight flush and the pool is never closed under a live writer. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * fix(client): plug listener leaks, guard lazy livekit load, honor saved log level Follow-up audit of the recently-landed lazy-livekit and session wiring found three real issues: - clearAuth unconditionally dynamic-imported livekitSession to call leaveVoice on every logout, pulling the ~531 kB livekit chunk into the logout path even when no voice session was ever active. Guard the import on an active voice session (currentChannelId set and status not idle) and add a .catch so a failed teardown import can't reject unhandled. - The onStateChange handler unsubscribed session listeners only on the ready transition, not on disconnected; user_update and ready listeners registered per session were never collected for cleanup. Collect them into a sessionUnsubs array cleaned up on both ready and disconnected, preventing duplicate handlers accumulating across reconnects. - The Logs tab min-level select ignored the persisted log level when no explicit dropdown preference was saved. Add logger.getLogLevel() and default the select to it so the UI reflects the level actually in effect. Also add .catch to the ptt setMuted dynamic import. New unit tests cover the clearAuth guard, getLogLevel, and the LogsTab default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude * fix(waf): load embedded OWASP CRS ruleset correctly on Windows (#1289) The CRS WAF engine failed to initialize on Windows, taking the whole api package's test suite red there. coraza's seclang parser resolves Include globs through path/filepath: for every match of `Include @owasp_crs/*.conf` it calls filepath.Join(currentDir, match), which on Windows rewrites the forward slashes to backslashes. It then feeds names like `@owasp_crs\REQUEST-901-INITIALIZATION.conf` back into the root fs.FS. That FS is the ruleset's embed.FS, which is always forward-slash and rejects a backslash name, so newCRSWAF returned "file does not exist" and no CRS rule under a subdirectory was ever loaded. Wrap coreruleset.FS in a small slash-normalizing fs.FS (Open/ReadFile/ReadDir/ Glob) that converts backslashes to forward slashes before delegating. This fixes CRS loading on Windows without patching coraza or the ruleset module and is a no-op where the separator is already "/". The Linux-only local verification for the CRS work missed this because coraza never emits backslashes there. The new test reproduces the failure mode on any OS by constructing the exact backslash name coraza produces on Windows: the raw ruleset FS fails to read it, the wrapper resolves it, and a forward-slash path still works. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude * fix(e2e): repair the Playwright suite so the CI job stops timing out (#1291) The Client E2E CI job never completed: every run hit its 25-minute cap and was cancelled. ~229 of the 255 web tests were failing, all cascading from the shared login helper, and 255 tests x 3 attempts x 20-45s of timeout burn on 1 worker deterministically exceeds the cap. Root cause: the e2e Tauri mock predates the Rust HTTP TOFU proxy. api.ts now awaits invoke("start_http_proxy") and builds REST URLs as http://127.0.0.1:{port}/api/v1/..., but the mock's invoke returned null for the unstubbed command, so every URL got a literal "null" port and Request construction threw before the mocked plugin:http transport was consulted. Login rejected, [data-testid='app-layout'] never mounted, and every logged-in test burned its full timeout. Stubbing start_http_proxy with any numeric port fixes the cascade because route matching is substring-based. The tail of failures after that fix were tests asserting behavior the app intentionally changed: - The ready payload can no longer pre-connect the local user to voice: the dispatcher treats "self in ready.voice_states while idle" as stale state from a reload and immediately leaves. MOCK_VOICE_STATE now seeds remote users only (2, 3), and widget tests join through the real click path via a new joinVoiceChannelByName helper. - The mock's voice_join reply no longer includes a voice_token: a token starts a real LiveKit session that deterministically self-destructs in the browser mock (E2EE key exchange timeout ~15s / connect-refused retries), tearing the widget down mid-test. These web tests validate the WS/UI layer only; real LiveKit is covered by the native suite. The reply also gained the full VoiceStatePayload shape — the sidebar renders user.username directly, and the omitted field broke the whole voice-user list render. - Message-load failure now renders an inline region error + Retry instead of a toast (UX spec 2), so the toast specs assert the inline UI and get their auto-dismiss vehicle from the delete-confirmation toast. CI hardening so a future systemic breakage can never burn the full cap again: maxFailures 20 and a 20-minute globalTimeout in CI (Playwright now self-terminates with a usable report instead of being SIGKILLed), with the workflow's timeout-minutes 25 as the outer backstop. The job stays continue-on-error until it has proven stably green across a few pushes; the ci.yml comment documents that flip trigger. Full suite: 255/255 passing locally (~7.5 min at 1 worker, ~4 min at 2). Unit tests (3598), typecheck, and prettier all clean. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude * feat(admin): first-run setup wizard with config.yaml write-back + LiveKit auto-download (#1290) * feat(admin): first-run setup wizard with config.yaml write-back Turn the single-screen owner-account setup into a guided multi-step wizard so non-technical operators never have to hand-edit YAML: - config: new comment-preserving config.Save (yaml.Node round-trip, atomic temp+rename write, verified loadable before replacing the file) plus a shared config.DefaultPath. Persists the runtime-generated LiveKit credentials so voice tokens survive restarts. - admin: POST /admin/api/setup accepts an optional "wizard" object (server name, MOTD, registration, port, TLS mode/domain, upload limit, voice quality). Values are validated before the account is created; DB settings and config.yaml are written after; failures downgrade to warnings so the created owner is never orphaned behind a 5xx. When a startup-only value changed the server restarts itself (reusing the backup/update restart machinery) and returns the new admin URL. - admin: GET /admin/api/setup/status now returns secret-free prefill defaults while setup is pending. - admin panel: six-step wizard UI (welcome, account, server basics, uploads & voice, access, review) with plain-language explanations, a restart/reconnect screen, and a "skip" path that keeps the legacy account-only flow byte-for-byte. - legacy payload {username,password} and all existing call sites keep working (SetupOptions is a trailing variadic parameter). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * fix(lint): satisfy modernize — any over interface{}, new(expr) over ptr helper Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * feat(voice): auto-download the LiveKit server binary Voice now works with zero manual setup: when voice.auto_download_livekit is enabled and no voice.livekit_binary is configured, the server fetches the pinned livekit-server release (v1.13.5, overridable via voice.livekit_version) from the official LiveKit GitHub releases in the background at startup, verifies it against the release's checksums.txt, extracts it into data/livekit/, and manages it as the existing companion process (crash recovery, health checks, graceful shutdown). - ws: new livekit_download.go — pinned version, per-platform asset mapping (linux/windows × amd64/arm64/armv7, matching LiveKit's goreleaser config), size-capped downloads, hash verification and extraction through one open handle (TOCTOU-safe), O_EXCL staging, atomic rename, stale-version cleanup. LiveKitProcess.Start resolves the binary asynchronously with retries so boot is never blocked. - config: voice.auto_download_livekit + voice.livekit_version; enabled in the generated default config so fresh installs get working voice out of the box, while the compiled-in default stays off for existing configs. config.Load now loads the default file it just wrote, so the first boot runs with exactly the configuration the file documents. - wizard: "Voice chat" toggle (on by default) in the Uploads & voice step; the choice is written to config.yaml and factored into the restart decision. - docs: livekit-setup, server-configuration, deployment, README. Verified end-to-end against the real v1.13.5 release: download, checksum match, extraction, and process spawn all succeed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * chore: remove stray server.log, ignore local run logs Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB --------- Co-authored-by: Claude * fix(voice): desktop-client origins on LiveKit proxy + client version bump to alpha.5 + release version guard (#1293) * fix(voice): accept the desktop client's webview origins on the LiveKit proxy The desktop client's chat connection goes through its Rust proxy, which sends no Origin header, so the safe-default empty allowed_origins never blocked it. The LiveKit JS SDK's signal requests and validate probes, however, are issued directly from the webview and carry its fixed origin (http(s)://tauri.localhost on WebView2, tauri://localhost on WKWebView/WebKitGTK). isOriginAllowed treated those as cross-origin and returned 403, so on every default install voice failed for any desktop client that wasn't on the server machine — chat worked, voice didn't, with /livekit/rtc/v1 403s in the server log. Treat these fixed first-party origins as always allowed. This is the same trust already extended to absent-Origin requests: web content can never present them (browsers resolve *.localhost to loopback and cannot reach the tauri:// scheme), so the CSRF surface is unchanged. Exact, case-insensitive matching only — lookalikes (tauri.localhost.evil.com, tauri.localhost:8080) still require an explicit allowlist entry. Operators no longer need to hand-add these origins to server.allowed_origins for voice to work; that list is now only for web/browser clients. Docs and the generated config comment updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * chore(client): bump version to 1.1.0-alpha.5 The v1.1.0-alpha.4 release shipped client artifacts still versioned 1.1.0-alpha.3 because the client manifests were never bumped — deployed desktop clients therefore consider themselves up to date and never auto-update. Bump package.json, package-lock.json, tauri.conf.json, Cargo.toml and Cargo.lock to 1.1.0-alpha.5 so the next release's clients update normally. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * ci(release): fail the release when client version does not match the tag Guards against the v1.1.0-alpha.4 mistake recurring: a new verify-versions job compares the pushed tag against tauri.conf.json, package.json and Cargo.toml and fails before any build starts; every build job now depends on it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB --------- Co-authored-by: Claude * fix(admin): allow API-token principals to use the SSE log stream (#1294) The log stream was session-only: POST /admin/api/logs/ticket required a *db.Session in the request context (deliberately nil for API-token principals), and the stream handler re-validated the ticket hash against the sessions table alone. API tokens could reach every other /admin/api/* route but not the log stream, breaking the mcp-introspect server_logs tool that docs/mcp-introspect.md documents as working. Bind tickets to the hash of whichever bearer credential authenticated the request, and resolve it in the stream handler via auth.ResolveTokenHash — the same session-first, API-token-fallback path the admin middleware uses. Ban, role demotion, and mid-stream revocation of either credential kind cut the stream exactly as before. Co-authored-by: Claude Fable 5 * fix(voice): treat the server's own origin as same-origin on the LiveKit proxy (#1295) A page served by the server itself (e.g. a browser client at https://:8443) chats fine but cannot join voice: browsers attach the page origin to every WebSocket handshake, and the LiveKit proxy's hand-rolled isOriginAllowed only recognized "no Origin" as same-origin, so the RTC upgrade 403'd while same-origin fetches (which omit Origin) succeeded — /livekit/rtc/v1 403s with validate flipping 403/200 in the server log. Allow an Origin whose host equals the request Host, mirroring websocket.Accept's default same-origin policy that the chat WS endpoint already applies — which is exactly why chat worked and voice didn't. Web content on another origin can never present this origin (the browser pins it), so the CSRF surface is unchanged. Same host on a different port remains cross-origin and denied. Also log rejected origins on the 403 path (origin, path, remote) — this failure was previously undiagnosable from the server log, which recorded the 403 but not the offending origin. Existing allowlist tests used origins colliding with httptest's default request host (example.com), which the new semantics correctly treat as same-origin; their fixtures now use distinct hosts so they keep exercising the allowlist path. Co-authored-by: Claude Fable 5 * fix(release): strip bundled libwayland from Linux AppImages (white screen on Arch) (#1297) linuxdeploy bundles the Ubuntu 22.04 runner's libwayland-{client,cursor, egl,server} into the AppImage, and AppRun forces them onto LD_LIBRARY_PATH. On hosts with newer Mesa (Arch, Fedora), EGL init dlopens libwayland-client, hits the stale bundled copy, and fails with "Could not create default EGL display: EGL_BAD_PARAMETER. Aborting..." - WebKit's web process dies and the window stays white. Reproduced in an Arch container with the published alpha.5 aarch64 AppImage (identical stderr to the field report); the same image renders normally on Ubuntu 24.04, and removing the four bundled libwayland libs makes it render on both. WEBKIT_DISABLE_COMPOSITING_MODE=1 does NOT help (tested). Add scripts/strip-appimage-bundled-libs.sh and run it in both Linux release jobs after the Tauri build: strip the libs, repack with appimagetool, regenerate the updater tar.gz, and re-sign both artifacts with the Tauri updater key. Every supported distro ships libwayland at or above the 1.20 the client links against, so the host copy is always the right one. Co-authored-by: Claude Fable 5 * fix(client): send the session bearer token when fetching attachments (#1298) Uploaded images rendered only as loading placeholders: the server's /api/v1/files/{id} endpoint requires a Bearer token (it enforces per-channel ACLs), but the client's attachment image fetch and file download never attached one, so every request came back 401 and the placeholder was never replaced. Server-hosted attachment fetches now go through fetchServerFile, which routes through the cert-pinned TOFU proxy with the session token from the auth store. The token is only ever sent to the configured server host — external image URLs keep a plain, credential-free fetch. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude * fix(client): enable microphone/camera detection on Linux (WebKitGTK) (#1299) On Linux no audio or video devices were ever detected: WebKitGTK ships with enable-media-stream and enable-webrtc off, and wry installs no permission-request handler on its webkitgtk backend (unlike macOS, where it auto-grants media capture), so WebKit's default denies every getUserMedia/enumerateDevices request. Add a Linux-only setup hook that turns both settings on for the main window's webview and grants WebKitUserMediaPermissionRequest and WebKitDeviceInfoPermissionRequest. All other permission request types still fall through to WebKit's default deny. The webkit2gtk crate becomes a direct dependency, pinned to the exact version wry already links (=2.0.2, v2_38 for enable-webrtc), so the binary's native library footprint is unchanged — the AppImage bundle set stays identical and the libwayland strip step from #1297 is unaffected. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude * feat(client): kick to login and reset call state on server shutdown (#1300) When the server shut down, connected clients stayed on the main page in an endless "Reconnecting..." loop, and a live call's webcam/screenshare toggles kept whatever state they had. The server already broadcasts server_restart with reason "shutdown" from hub.GracefulStop before closing connections — the client just ignored the reason. The dispatcher now treats reason "shutdown" as terminal: it signs the user out (clearAuth), which navigates back to the login screen, leaves the voice session — stopping any live camera/screenshare tracks — and resets all call settings (camera, screenshare, mute, deafen, channel) to their normal state. Other restart reasons (update, setup, backup_restore) keep the existing countdown-banner + auto-reconnect behavior. clearAuth gains a LogoutReason so the logout wiring can tell a server-initiated kick from a user logout or invalid-token path: on "server_shutdown" the saved credential is kept (the token is still valid), so profiles with auto-login reconnect on their own once the server comes back, instead of losing their stored login on every server restart. The main page also skips the restart countdown banner for shutdown notices since the page unmounts immediately. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude * fix(client): credential fallback store on every OS, not just Windows (#1301) Credential saves still failed outright on machines where the OS keychain does not round-trip — most commonly a Linux desktop with no Secret Service provider (no gnome-keyring / KWallet, e.g. a bare window manager) and a locked macOS Keychain. The verified-write fallback introduced for the 2026-07 keyring regression existed on Windows only; on macOS and Linux secret_store::set returned an error and nothing was persisted, so logins and the voice-E2EE identity key vanished on every restart. The fallback now engages on every desktop platform, under the same rule as before: only after a keychain write has provably failed to round-trip, with the OS credential store taking over again the moment it recovers. Windows keeps DPAPI. macOS/Linux entries are sealed with ChaCha20-Poly1305 (via ring, already in the tree) under a per-install random key file written owner-only (0600) to the app data dir; the account name is bound in as AEAD associated data, mirroring the DPAPI entropy, so a blob cannot be moved between entries. Secrets at rest are never plaintext, and a copied fallback store is useless without the key file beside it. The shared set/get fallback path is now platform-neutral with only the sealing primitive per-OS, Backend gains an EncryptedFile variant, and fallback_crypto ships round-trip, AAD-mismatch, tamper, nonce uniqueness, and key-file permission tests that run in CI. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude * fix(voice): keep stream audio playing when the user mutes/deafens (#1302) Muting yourself in a call (which the deafen control also engages — deafen forces mute) silenced the audio of any screen-share stream being watched: the deafen path unsubscribed every remote audio publication, including ScreenShareAudio tracks, and the subscribe-time guard blocked new stream-audio tracks the same way. Muting/deafening yourself gates voices, not the content someone is streaming. Both paths now exempt ScreenShareAudio: the stream's audio keeps playing while the user is muted or deafened, and remains controllable through its own per-tile mute button and volume slider. Microphone (voice) audio is still fully unsubscribed on deafen exactly as before. The mic-mute path itself never touched incoming stream audio (verified against livekit-client: setMicrophoneEnabled, RemoteParticipant.setVolume and the audio pipeline are all scoped to the Microphone source) — the coupling was only ever the deafen subscription sweep. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude * feat: Discord-parity quick wins (blocks UI, topics, role colors, profile popup, temp bans, archived filtering) (#1303) * feat: Discord-parity quick wins — blocks UI, topics, role colors, profile popup, temp bans, archived filtering Adds docs/plans/discord-parity.md (full gap analysis vs Discord free/Nitro, phased plan) and lands phase 1 — the six features where one side already existed and the other was never finished: - Block/unblock from the client: PUT/DELETE /blocks/{userId} were server-only; the member context menu now offers Block (with confirm) / Unblock to every user, admin actions stay role-gated. New setUserBlockedByMe store helper. - Channel topics end-to-end: topic now ships in the WS ready payload (protocol.md updated), renders live in the chat header, and is editable in the client's Edit Channel modal (PATCH already supported it). - Role colors from server data: member list groups and message username colors now use roles.color from ready (with theme-var fallbacks) instead of a hardcoded 4-name switch; custom roles render their own groups, and members with an unknown role render in a gray group instead of vanishing. - Profile popup mounted: left-clicking a member opens the existing UserProfilePopup (previously dead code); its Message button starts a DM. Action buttons without handlers are no longer rendered. - Temp bans: PATCH /admin/api/users/{id} accepts ban_duration_hours (1..8760) feeding the existing BanUser expiry plumbing; ban menu gains a duration selector (Forever/1h/1d/7d/30d). - Archived channels actually hide: VisibleChannelIDs now skips archived refs, so REST list, ready payload, and replay filtering all exclude them; archiving live-syncs connected clients via RefreshChannelVisibility. The admin panel still lists archived channels for unarchiving. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): rewrite visibility if-else chain as switch (gocritic ifElseChain) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude * feat: Discord-parity phases 2–6 (moderation, mentions, markdown, roles, social) (#1304) * feat: parity phase 2 — moderation depth (live permission bits, voice moderation, purge) - Admin perimeter now admits any role holding a moderation-capable bit (AdminPerimeter mask); each route group re-checks its own bit: channels/overrides -> MANAGE_CHANNELS, audit log -> VIEW_AUDIT_LOG, settings -> MANAGE_SERVER, force-logout -> KICK_MEMBERS. Ban and role assignment authorize inside ModerationService (BAN_MEMBERS / MANAGE_ROLES). New GET /admin/api/me lets the panel hide tabs and row actions the caller cannot use; the desktop member-list menu gates on permission bits from the ready role list instead of role names. - Hierarchy beyond ban: ChangeUserRole requires the actor to strictly outrank the target and refuses to assign a role at or above the actor's own position (closes "any admin can promote anyone to Owner"); ForceLogout enforces the same rule. - Voice moderation on MUTE_MEMBERS: voice_mod_mute/deafen/move/kick WS commands (bit + strict outrank, 5/s rate limit, audit-logged). voice_states gains server_muted/server_deafened, carried on voice_state; server mute is enforced at the SFU via LiveKit MutePublishedTrack and the target's own unmute attempts are refused with SERVER_MUTED/SERVER_DEAFENED. Move/kick run the hub voice-leave routine then send voice_moved (client rejoins through the normal join path) or voice_disconnected. Client voice-row menu grows a moderation section gated on the bit. - Bulk delete: POST /api/v1/channels/{id}/messages/purge {limit 1-100, before?} gated on READ|MANAGE_MESSAGES, soft-deletes preserving tombstones, one message_purge audit row, fans out a single chat_bulk_deleted broadcast. Channel context menu gains "Purge Messages…" for holders of MANAGE_MESSAGES. - Honest kick semantics: the session-revoking "Kick" action is renamed Force Logout in the client and admin panel (endpoint unchanged). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): use slices.Contains in voice moderation tests (modernize) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(test): widen the occupied pre_restore window in the abort-restore test The test blocked the safety backup by occupying pre_restore_.db names for the next 4 seconds; on slow Windows CI runners the request outlived the window and the restore succeeded, failing the 500 assertion. Occupy two minutes of candidates instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 3 — real mentions (server resolution, badges, notifications, autocomplete) - Mentions resolve server-side at send time: whole-word @username parsing (address-shaped text rejected), case-insensitive against unique usernames, 20-mention cap; stored in message_mentions in the same writer transaction as the message. chat_message/chat_edited and REST history/pinned/search carry mentions + mentions_everyone. - New MENTION_EVERYONE permission (bit 21, seeded to Owner/Admin/Moderator) gates @everyone/@here; never honored in DMs. @here skips offline users. Fan-out respects per-channel read permissions and skips users who blocked the author. - read_states.mention_count is live: incremented on insert (never on edit), zeroed by channel_focus, shipped per channel in ready. - Client: mentions highlight only when they resolve; mentioning the current user accents the whole row; #channel-name renders a navigating chip; channels show a red mention badge that outranks the unread badge; notifications say "X mentioned you in #channel" and the suppress-@everyone pref now suppresses only honored everyone-mentions; the composer gets an @-autocomplete popup (prefix-ranked, keyboard-driven, @everyone/@here offered only with the permission). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 4 — markdown rendering, message navigation, reactions/media/read-state polish - Discord-flavored markdown via a tokenizer (message-list/markdown.ts): bold/italic/underline/strike/spoiler with nesting, escaping and a word-boundary rule keeping snake_case literal; line-start quotes, headings, lists; masked links restricted to absolute http(s) + isSafeUrl (rejects render as literal source); language-tagged code fences with a hand-rolled highlighter (no new dependency); markdown is inert inside code. Renderer stays a strict DOM builder — no innerHTML. Composer gains Ctrl+B/I/U wrapping. - Message navigation: GET /channels/{id}/messages/around/{messageId} (half-before/half-after window, has-more flags via over-fetch); detached-window support in the messages store with a "Jump to Present" pill; search/pin jumps fetch the window when the target isn't loaded; reply previews are clickable; "Copy Message Link" + owncord://message/{channel}/{message} deep-link route; pasted message links render as jump chips. - Who-reacted: GET .../reactions/{emoji}/users (100 cap) + hover tooltip with per-message+emoji cache invalidated on reaction_update. - Inline media: video/audio attachments render native players from MIME allowlists (unknown containers keep the download chip); SVG stays out. - Read-state polish: NEW-messages divider, explicit Mark as Read / Mark All as Read, DM unread count badges (real counts shipped in ready instead of a dot; DM mention counts survive reconnect). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 5 — role CRUD, per-user overrides, override matrix, client channel management - Roles are real entities: /admin/api/roles CRUD + reorder behind MANAGE_ROLES, with all rules in a new RoleService measured against the actor's position (only strictly-below roles may be touched; never grant a bit your own role lacks; seeded Owner immutable; default role undeletable — deletion reassigns members, drops its overrides, and invalidates exactly the moved members' cached perms in one writer transaction). Case-insensitive unique names (migration 023), normalized colors, roles_update broadcast keeps clients current, and both admin surfaces stopped hardcoding the four seeded roles. A new ASCII guard test protects sqlc-generated SQL from a byte/rune offset bug that silently splices queries when comments contain non-ASCII. - Per-user channel overrides (migration 024): resolution is now base -> role override -> user override with one implementation (EffectiveChannelPerms); both layers load in two batch queries behind every visibility/permission site, per-role visibility memoization removed (two members of one role can now differ), and the @everyone fan-out honors user-layer allow and deny. Admin REST + full tri-state override matrix UI (role or user per channel) replace the single "Can access" checkbox; the visibility-agreement test grew a same-role different-overrides case. - Categories stopped being magic strings: any channel type under any free-text category (server + client validation removed), category editable everywhere with datalist suggestions, voice channels group under their real category. - Desktop channel management: Edit Channel gains slowmode presets, NSFW toggle, and voice user/video limits (bounds-checked server-side, broadcast on channel_create/update via one shared constructor); NSFW channels show a per-session age-gate overlay; VIEW_AUDIT_LOG holders get an Audit Log entry point opening the admin panel at #audit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 6 — custom emoji, profiles & presence, group DMs, DM calls, channel mutes - Custom emoji end-to-end: the dormant emoji table gains a mime column and real routes (list/upload/delete + authenticated image serving, MANAGE_SERVER-gated, 512KiB / 128px caps validated against sniffed bytes, SVG refused, 200-emoji cap, audited, emoji_update broadcast). :shortcode: renders inline (jumbo when emoji-only, never in code), the picker gains a Server category, the composer a :-autocomplete, reactions accept and render custom emoji, and the admin panel gets an Emoji section. - Profiles: avatar upload (sniffed, capped, served authenticated) with one shared client avatar helper replacing letter-initials everywhere; display_name (heading with @username handle preserved for mentions), about, and custom_status columns with sanitized bounds; user_update broadcast keeps clients current. - Presence: invisible is a real stored status collapsed to offline for every other viewer at every serialization site (owner sees truth); connect no longer force-stamps online (idle/dnd/invisible survive reconnect — the flash-online bug is gone); auto-idle after 10 minutes of inactivity that never overrides a manual status. The @here fan-out now collapses status first so invisible users are not pinged. - Group DMs: channels.is_group discriminator; create (2-8 others, bidirectional block checks), rename (participants only), leave (channel deleted with the last participant); per-viewer dm_channel_open payloads; stacked-avatar rows, multi-select member picker, participant headers; 1:1-only composer block gating. - DM calls: call_ring/call_decline signaling over existing DM voice (no new call state), Call button in DM headers, incoming-call banner with accept/decline/30s timeout and chime. - Per-channel mutes (client prefs): muted channels/DMs stay silent for non-mention noise (badge dims, mentions still notify), managed from context menus and the Notifications tab. The dead Friends nav item is removed as the plan prescribed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude * Pre-release review fixes + v1.2.0-alpha.1 prep (#1305) * fix(review): pre-release security & performance fixes for the parity work Security: - Channel-override endpoints (role + per-user) now enforce grantability: a MANAGE_CHANNELS holder can no longer grant itself or a user a permission bit its own role lacks, and the role-layer endpoint refuses targeting a role at or above the actor's position (Administrator bypasses). Closes a privilege-escalation path opened when the override routes were downgraded from ADMINISTRATOR-only. - DM voice events no longer leak: channelReadAudience resolves a DM channel's audience from its participants (intersected with connected clients) instead of the role scan, which passed every user with base READ_MESSAGES since DMs carry no overrides. A private DM call's voice_state/voice_leave now reaches only its participants. - Invisible users no longer flash online on connect: member_join carries a viewer-safe status (db.BroadcastStatus) and the client defaults a missing status to offline instead of hardcoding online. - Voice moderation can no longer reach a private DM call: voiceModTarget refuses a DM-channel target unless the actor is a participant, with the same shape as "not in voice" so nothing about the call leaks. Correctness: - Un-deafening a member now also clears the deafen-implied server mute, so the target regains the ability to unmute themselves instead of staying silenced at the SFU until a separate unmute. Performance: - IncrementMentionCounts batches its upserts into chunked multi-row statements instead of one exec per recipient, so an @everyone mention holds the SQLite writer for one exec per 500 readers instead of N. - applyMentionCounts resolves mentions against a set built once from the readers instead of a nested O(mentions x readers) scan. - The markdown parser's bracket/paren matching is computed once per line instead of rescanned at every opener, removing the O(n^2) worst case on pathological input. - Video/audio attachment blob URLs are now LRU-capped and revoked, and the attachment caches are cleared on logout, fixing an unbounded per-session Blob leak. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * chore(release): prep v1.2.0-alpha.1 Bump the client manifests (package.json, package-lock.json, tauri.conf.json, Cargo.toml, Cargo.lock) from 1.1.0-alpha.5 to 1.2.0-alpha.1 so the release workflow's verify-versions guard passes for tag v1.2.0-alpha.1. The server version is injected via ldflags at build time and needs no bump. Add a curated CHANGELOG section for v1.2.0-alpha.1 documenting the Discord-parity feature drop (mentions, markdown, custom emoji, message navigation, role management, per-user overrides, voice moderation, profiles, group DMs, DM calls, channel mutes) and the pre-release security/performance review, plus an operator note covering the nine new migrations and the new WebSocket message types. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * perf(mentions): apply mention counts off the send path SendMessage resolved every reader and wrote the mention/@everyone badge counts synchronously after the commit but before returning, so a mention in a large channel delayed delivering the message to everyone else by the full reader-resolution chain plus the batched increment. Move that bookkeeping onto a background goroutine via an injectable dispatcher field (bg, defaulting to `go fn()`). The write already ran on a cancellation-detached context and swallowed its errors, so detaching it from the request is safe; the count is advisory, so the tiny window where a reader's channel_focus clears it just before the increment lands is harmless (matching Discord's eventual consistency). Tests read the counts synchronously right after a send, so the shared mention fixture and the ws mentions test opt into an inline runner (RunBackgroundInlineForTest / the hub's RunMentionCountsInlineForTest seam); a new test exercises the real async path by polling. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * refactor(client): extract shared inline-autocomplete factory MentionAutocomplete and EmojiAutocomplete duplicated ~90 lines of identical listbox scaffolding (AbortController cleanup, suggestions/ activeIndex state, the root listbox + .ma-list, mousedown-to-choose rows, and a byte-identical arrow/Enter/Tab/Escape keydown switch), so a fix to one silently diverged from the other. Factor that into createInlineAutocomplete, parameterized by the four things that actually differ: the filter, the selected value, the per-row children, and the row/root test ids + class (emoji keeps the shared mention-autocomplete base class plus its own, and only mentions prime the list on create). Both components become thin adapters that keep their existing exports — createMention/EmojiAutocomplete, the pure filter functions, and the MIN/MAX constants — unchanged, so MessageInput and every test are untouched and still pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): drop now-unused appendChildren import in MentionAutocomplete The row rendering moved into the shared inline-autocomplete factory, so the import is no longer referenced; oxlint fails the Client Static Checks job on the unused identifier. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude * fix(review): full-project review — hierarchy, role positions, search, clarity (#1306) From a full-codebase review (Opus security + Sonnet server/client + Haiku consistency): - Per-user channel overrides now enforce the same role-hierarchy guard the role-layer endpoint already has: a non-admin MANAGE_CHANNELS holder can no longer write or clear a per-user override against a member ranked at or above their own. Without it, because the per-user layer is last in the resolution order, a Moderator could deny a higher-ranked member the channel access their role grants. Applied to both PUT and DELETE. - CreateRole no longer places two default-positioned roles at the same position: it steps to the highest free slot below the actor and rejects an explicit position that is already taken. Colliding positions read as equal rank in every hierarchy check, so two such roles could never manage each other's members. The rank guard still takes precedence over the collision message for an at/above-rank position. - Search overlay no longer silently drops a query that arrives inside the 500ms rate-limit window (which sits above the 300ms debounce): it reschedules the search for when the window opens instead of leaving the previous query's results on screen. - Corrected a misleading TODO on chat_send attachments: they are upload UUIDs resolved by ownership at link time, not URLs, so a javascript:/data: string is never stored or rendered — a scheme check would wrongly reject valid ids. The comment now states this and the loop variable/error name say "id". Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR Co-authored-by: Claude * Test hardening: fuzzing, contract/upgrade, load, e2e (#1307) * fix(image): reject zero-dimension images in header decode FuzzImageDimensions found two inputs the emoji/image size guard accepted as valid with a nil error despite having no real dimensions: - a GIF whose logical screen descriptor decodes to height=0 via Go's own image.DecodeConfig, and - a VP8 keyframe whose size field is all zeros (VP8, unlike VP8L/VP8X, stores the size directly, so 0x0 is a validly-shaped header). Both callers compare the returned size straight against their pixel cap, so a degenerate 0-dimension header slipped through as a "small" image. Reject non-positive dimensions centrally in imageDimensions and reject zero VP8 dimensions in webpDimensions, so the invariant holds even for a caller that forgets its own bounds check. The two crashers are checked in as the fuzz regression corpus. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(fuzz): add Go fuzzers and TS property tests for parsers/validators Adds coverage on the parsers and validators most exposed to hostile input, each with a tricky seed corpus and invariant assertions: Server (Go native fuzzing): - FuzzParseMentionTokens: never panics; resolved count within cap. - FuzzSanitizeFTSQuery: output never errors against real SQLite FTS5. - FuzzValidateShortcode: accepted shortcodes match the documented charset/length. - FuzzEffectivePerms / FuzzEffectiveChannelPerms: ADMINISTRATOR implies all bits, user-deny beats role-allow, result is a subset of AllPerms. Client (fast-check property tests): - markdown tokenizer never throws and emits no script/on*/javascript: sinks, bounded time on pathological input. - mention/emoji content parsing never throws. - filterMentionSuggestions/filterEmojiSuggestions never throw and respect the caps and the MIN_EMOJI_QUERY/permission gates. The image-header fuzzer that found the zero-dimension bug landed with its fix in the preceding commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(migration): add full-chain and upgrade round-trip tests Applies every embedded migration to a fresh DB and asserts the resulting schema is coherent, then applies the full chain on top of a pre-parity (migration 019) snapshot and asserts it upgrades without error and preserves seeded rows. Protects existing operators on the v1.2.0 upgrade (9 new migrations, 020 through 028). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(protocol): assert protocol schema matches generated Go constants Asserts every wire constant in docs/protocol-schema.json has a matching generated Go constant and vice-versa, with a small explicit exception list for intentionally-undocumented internal constants. Catches the chat_command-style drift the review flagged before it reaches the wire. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(load): add hub load/soak harness with goleak verification Adds a long test (skipped under -short, run under -race in CI) that concurrently registers and unregisters 200 WS clients across churn rounds while six broadcaster goroutines fan out to the hub, then asserts via go.uber.org/goleak that no goroutines leak and no deadlock or panic occurs. Exercises the client registry, broadcast audience resolution, and the background mention goroutine under contention -- the class of bug the race detector only reveals at scale. Adds a BroadcastVoiceEventForTest seam to export_test.go for the broadcaster loop. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(e2e): add blocking parity-feature Playwright specs Adds end-to-end coverage for the v1.2.0 parity features that had none, all tagged "@parity" and driven through the existing mocked-Tauri harness (tests/e2e/helpers.ts) — 15 tests across three files: - gating-badges.parity.spec.ts: NSFW age-gate mount/continue, mention red badge (ready-payload render + live incoming-mention bump), per-channel mute toggle + localStorage persistence. - social.parity.spec.ts: group-DM create via the member picker (asserts the POST /dms/group request), group render + leave (DELETE), and Change Role via the member context menu (asserts the PATCH /admin/api/users/{id}). - emoji-voicemod.parity.spec.ts: custom-emoji ":shortcode" autocomplete + message-list render, and the voice-moderation menu — both the admin-can path (asserts voice_mod_mute / voice_mod_kick ws_send) and the gated path (menu absent without MUTE_MEMBERS). The specs assert the exact outgoing HTTP/WS request where the flow is request-driven, not just DOM side effects. No product bugs were found. Adds a dedicated CI job "Client E2E (parity subset, blocking)" that runs only the @parity specs (playwright --grep "@parity") WITHOUT continue-on-error, so a regression in these features fails CI. The pre-existing full e2e job stays non-blocking, per the maintainer note that it needs a few green pushes before graduating. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude * More hardening: fuzz the input surface + fix mis-written tests (#1308) * fix(upload): keep sanitizeUploadFilename output a safe, valid basename FuzzSanitizeUploadFilename found two inputs the upload-filename sanitizer returned unchanged in violation of its own contract: - "/" survived verbatim: filepath.Base("/") returns "/" (root is its own basename), and the final reserved-name check only special-cased "", ".", and "..", so a path separator reached the served download name and the client's save-dialog prefill. - a name longer than the 255-byte cap was truncated with a byte slice (name[:max]), which can land mid-rune and yield invalid UTF-8 — which then misbehaves in JSON encoding, on disk, and in download-name handling. Now any residual '/' is dropped in the character filter, and truncation trims back to the last full rune so the result is always valid UTF-8. The two crashers are checked in as the fuzz regression corpus. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(fuzz): fuzz the file/path and content/identity input surface Adds Go native fuzzers on the untrusted-input parsers/validators the first fuzzing pass didn't reach, each with a tricky seed corpus and both a never-panics and a semantic/security invariant: - storage.sanitizeFilename + resolvedPath composition (a name that passes sanitize must resolve inside the storage dir — no traversal), and storage.ValidateFileType (error iff a blocked magic prefix matches, for any header length). - plugin.validateRelativePath (accepted paths are non-absolute, separator- and traversal-free). - service.sanitizeContent: output carries no surviving )"); + expect(el.querySelector("a")).toBeNull(); + expect(el.querySelector("script")).toBeNull(); + }); + + it("rejects relative URLs even though they resolve to http", () => { + const el = inline("[x](/settings)"); + expect(el.querySelector("a")).toBeNull(); + expect(el.textContent).toBe("[x](/settings)"); + }); + + it("renders markdown inside the link text", () => { + const el = inline("[**bold** link](https://example.com)"); + expect(el.querySelector("a strong")?.textContent).toBe("bold"); + }); + + it("balances parentheses inside the URL", () => { + const el = inline("[wiki](https://en.example.org/wiki/Foo_(bar))"); + expect(el.querySelector("a")?.getAttribute("href")).toBe( + "https://en.example.org/wiki/Foo_(bar)", + ); + }); + + it("produces no embed for a masked link", () => { + expect(extractUrls("[docs](https://example.com/a.png)")).toEqual([]); + expect(extractUrls("plain https://example.com/a.png")).toEqual(["https://example.com/a.png"]); + }); +}); + +// --------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------- + +describe("code fences", () => { + it("renders a language label and keeps the code intact", () => { + const el = message("```ts\nconst x = 1; // hi\n```"); + expect(el.querySelector(".msg-codeblock-lang")?.textContent).toBe("ts"); + expect(el.querySelector(".msg-codeblock")?.textContent).toBe("const x = 1; // hi"); + expect(el.querySelector(".msg-codeblock")?.getAttribute("data-lang")).toBe("typescript"); + }); + + it("highlights keywords, numbers, strings and comments", () => { + const el = message('```js\nconst s = "hi"; // note\n```'); + const block = el.querySelector(".msg-codeblock")!; + expect(block.querySelector(".tok-keyword")?.textContent).toBe("const"); + expect(block.querySelector(".tok-string")?.textContent).toBe('"hi"'); + expect(block.querySelector(".tok-comment")?.textContent).toBe("// note"); + expect(el.querySelector(".msg-codeblock")?.textContent).toBe('const s = "hi"; // note'); + }); + + it("falls back to plain text for an unknown language, keeping the label", () => { + const el = message("```brainfuck\n+++.\n```"); + expect(el.querySelector(".msg-codeblock-lang")?.textContent).toBe("brainfuck"); + expect(el.querySelector(".msg-codeblock .tok-keyword")).toBeNull(); + expect(el.querySelector(".msg-codeblock")?.textContent).toBe("+++."); + }); + + it("keeps the copy button and copies the raw code", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + const el = message("```go\nfmt.Println(1)\n```"); + const btn = el.querySelector(".msg-codeblock-copy") as HTMLElement; + btn.click(); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith("fmt.Println(1)")); + }); + + it("does not parse markdown inside a fence", () => { + const el = message("```\n**not bold** and *not italic* and ||no spoiler||\n```"); + expect(el.querySelector("strong")).toBeNull(); + expect(el.querySelector("em")).toBeNull(); + expect(el.querySelector(".msg-spoiler")).toBeNull(); + expect(el.querySelector(".msg-codeblock")?.textContent).toBe( + "**not bold** and *not italic* and ||no spoiler||", + ); + }); + + it("does not autolink URLs inside a fence", () => { + const el = message("```\nhttps://example.com\n```"); + expect(el.querySelector("a")).toBeNull(); + }); + + it("treats a fence with no newline as untagged code", () => { + const el = message("```ts is nice```"); + expect(el.querySelector(".msg-codeblock-lang")).toBeNull(); + expect(el.querySelector(".msg-codeblock")?.textContent).toBe("ts is nice"); + }); + + it("leaves an unterminated fence as prose", () => { + const el = message("```oops"); + expect(el.querySelector(".msg-codeblock")).toBeNull(); + expect(el.textContent).toBe("```oops"); + }); +}); + +describe("inline code", () => { + it("does not parse markdown inside a code span", () => { + const el = inline("`**x**`"); + expect(el.querySelector("strong")).toBeNull(); + expect(el.querySelector("code")?.textContent).toBe("**x**"); + }); + + it("does not autolink a URL inside a code span", () => { + const el = inline("`https://example.com`"); + expect(el.querySelector("a")).toBeNull(); + expect(el.querySelector("code")?.textContent).toBe("https://example.com"); + }); + + it("supports double-backtick spans containing a backtick", () => { + const el = inline("``a ` b``"); + expect(el.querySelector("code")?.textContent).toBe("a ` b"); + }); +}); + +// --------------------------------------------------------------------------- +// Safety +// --------------------------------------------------------------------------- + +describe("XSS safety", () => { + it("never builds elements from HTML in the message", () => { + const el = message(""); + expect(el.querySelector("script")).toBeNull(); + expect(el.querySelector("img")).toBeNull(); + expect(el.textContent).toContain(""); + }); + + it("keeps HTML inert inside styled spans", () => { + const el = message("****"); + expect(el.querySelector("img")).toBeNull(); + expect(el.querySelector("strong")?.textContent).toBe(""); + }); + + it("keeps HTML inert inside code fences", () => { + const el = message("```html\n\n```"); + expect(el.querySelector("script")).toBeNull(); + expect(el.querySelector(".msg-codeblock")?.textContent).toBe(""); + }); + + it("does not autolink a javascript: pseudo-URL", () => { + const el = message("javascript:alert(1)"); + expect(el.querySelector("a")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Markdown + mentions +// --------------------------------------------------------------------------- + +describe("markdown with mentions", () => { + it("renders a mention chip inside bold", () => { + const el = message("**hi @alice**"); + const mention = el.querySelector("strong .mention"); + expect(mention?.textContent).toBe("@alice"); + }); + + it("renders a channel chip inside a spoiler", () => { + const el = message("||go to #general||"); + expect(el.querySelector(".msg-spoiler .channel-mention")?.textContent).toBe("#general"); + }); + + it("autolinks a bare URL inside a quote", () => { + const el = message("> see https://example.com"); + expect(el.querySelector("blockquote a.msg-link")?.getAttribute("href")).toBe( + "https://example.com", + ); + }); + + it("does not italicise underscores inside a bare URL", () => { + const el = message("https://example.com/a_b_c"); + expect(el.querySelector("em")).toBeNull(); + expect(el.querySelector("a.msg-link")?.getAttribute("href")).toBe("https://example.com/a_b_c"); + }); + + it("still bolds around a URL", () => { + const el = message("**https://example.com/a_b**"); + const strong = el.querySelector("strong"); + expect(strong).not.toBeNull(); + expect(strong!.querySelector("a")?.getAttribute("href")).toBe("https://example.com/a_b"); + }); + + it("combines a heading, a list, a quote and a fence in one message", () => { + const el = message("# Title\n- a\n- b\n> note\n```js\nlet x = 1\n```"); + expect(el.querySelector("h1")).not.toBeNull(); + expect(el.querySelectorAll("ul.md-list li").length).toBe(2); + expect(el.querySelector("blockquote")).not.toBeNull(); + expect(el.querySelector(".msg-codeblock .tok-keyword")?.textContent).toBe("let"); + }); +}); + +// --------------------------------------------------------------------------- +// Tokenizer units +// --------------------------------------------------------------------------- + +describe("parseInline", () => { + it("returns a single text node for plain prose", () => { + expect(parseInline("just words")).toEqual([{ type: "text", value: "just words" }]); + }); + + it("keeps the raw source on a link node for the reject path", () => { + const nodes = parseInline("[a](javascript:x)"); + expect(nodes[0]).toMatchObject({ type: "link", url: "javascript:x", raw: "[a](javascript:x)" }); + }); + + it("stops nesting at the depth limit instead of recursing forever", () => { + const deep = "*".repeat(20) + "x" + "*".repeat(20); + expect(() => parseInline(deep)).not.toThrow(); + }); + + it("parses a long run of unmatched brackets to literal text quickly", () => { + // Pathological input for a naive per-opener rescan: every "[" would + // otherwise trigger its own O(n) scan of the remaining string, making + // this O(n^2). At the 4000-rune server cap that is ~16M ops; budget the + // test generously so it still fails loudly on a real regression. + const src = "[".repeat(4000); + const start = performance.now(); + const nodes = parseInline(src); + const elapsed = performance.now() - start; + expect(nodes).toEqual([{ type: "text", value: src }]); + expect(elapsed).toBeLessThan(500); + }); + + it("still renders a valid link after a long run of unmatched brackets", () => { + const src = "[".repeat(2000) + "[real](https://example.com)"; + const nodes = parseInline(src); + const last = nodes[nodes.length - 1]; + expect(last).toMatchObject({ type: "link", url: "https://example.com" }); + }); +}); + +describe("parseBlocks", () => { + it("splits blocks and keeps paragraph runs together", () => { + expect(parseBlocks("a\nb\n# h\nc")).toEqual([ + { type: "paragraph", text: "a\nb" }, + { type: "heading", level: 1, text: "h" }, + { type: "paragraph", text: "c" }, + ]); + }); + + it("marks indented list items as level 1", () => { + const blocks = parseBlocks("- a\n - b"); + expect(blocks[0]).toMatchObject({ + type: "list", + ordered: false, + items: [ + { text: "a", level: 0 }, + { text: "b", level: 1 }, + ], + }); + }); +}); + +describe("splitCodeFences", () => { + it("separates prose from fenced code", () => { + expect(splitCodeFences("a```x```b")).toEqual([ + { kind: "prose", text: "a", lang: null }, + { kind: "code", text: "x", lang: null }, + { kind: "prose", text: "b", lang: null }, + ]); + }); + + it("extracts the language tag", () => { + expect(splitCodeFences("```py\nprint(1)\n```")).toEqual([ + { kind: "code", text: "print(1)", lang: "py" }, + ]); + }); +}); + +describe("syntax highlighting", () => { + it("resolves language aliases and rejects unknown tags", () => { + expect(resolveLanguage("ts")).toBe("typescript"); + expect(resolveLanguage("golang")).toBe("go"); + expect(resolveLanguage("zsh")).toBe("bash"); + expect(resolveLanguage("nope")).toBeNull(); + expect(resolveLanguage(null)).toBeNull(); + }); + + it("returns one plain token for an unknown language", () => { + expect(highlightCode("anything", null)).toEqual([{ text: "anything", cls: null }]); + }); + + it("never drops or reorders characters", () => { + const samples: [string, string][] = [ + ["typescript", "export const a: number = 0x1f; // c\n/* b */ `t${a}`"], + ["go", 'package main\nfunc f() { s := "x" } // c'], + ["python", "def f(x):\n # c\n return 'a' if x else None"], + ["rust", 'fn main() { let v: Vec = vec![1]; println!("{}", 2); }'], + ["json", '{"a": [1, true, null], "b": "c"}'], + ["bash", 'if [ -n "$HOME" ]; then echo 1; fi # c'], + ["css", ".a { color: #fff; margin: 4px; } /* c */"], + ["html", 'y'], + ]; + for (const [lang, code] of samples) { + expect( + highlightCode(code, lang) + .map((t) => t.text) + .join(""), + ).toBe(code); + } + }); + + it("classifies python comments and strings", () => { + const tokens = highlightCode("# note\nx = 'hi'", "python"); + expect(tokens.some((t) => t.cls === "comment" && t.text === "# note")).toBe(true); + expect(tokens.some((t) => t.cls === "string" && t.text === "'hi'")).toBe(true); + }); + + it("classifies go keywords", () => { + const tokens = highlightCode("func main() {}", "go"); + expect(tokens[0]).toEqual({ text: "func", cls: "keyword" }); + }); + + it("does not treat a keyword-prefixed identifier as a keyword", () => { + const tokens = highlightCode("constant = 1", "javascript"); + expect(tokens[0]!.cls).toBeNull(); + expect(tokens[0]!.text).toBe("constant = "); + expect(tokens.some((t) => t.cls === "keyword")).toBe(false); + }); +}); diff --git a/Client/tauri-client/tests/unit/create-channel-modal.test.ts b/Client/tauri-client/tests/unit/create-channel-modal.test.ts index 8e079d0f..4fb698e9 100644 --- a/Client/tauri-client/tests/unit/create-channel-modal.test.ts +++ b/Client/tauri-client/tests/unit/create-channel-modal.test.ts @@ -1,52 +1,31 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { - isVoiceCategory, - allowedTypesForCategory, + CHANNEL_TYPES, + defaultTypeForCategory, createCreateChannelModal, } from "@components/CreateChannelModal"; import type { CreateChannelModalOptions } from "@components/CreateChannelModal"; +import { setChannels, UNCATEGORIZED_VOICE_CATEGORY } from "@stores/channels.store"; // --------------------------------------------------------------------------- // Pure function tests // --------------------------------------------------------------------------- -describe("isVoiceCategory", () => { - it("returns true for 'Voice Channels'", () => { - expect(isVoiceCategory("Voice Channels")).toBe(true); +describe("defaultTypeForCategory", () => { + it("defaults to voice only in the synthetic uncategorized-voice group", () => { + expect(defaultTypeForCategory(UNCATEGORIZED_VOICE_CATEGORY)).toBe("voice"); }); - 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); + it("defaults to text everywhere else, voice-sounding names included", () => { + for (const category of ["Voice Channels", "VOICE CHANNELS", "Text Channels", "Chat", ""]) { + expect(defaultTypeForCategory(category)).toBe("text"); + } }); }); -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"]); +describe("CHANNEL_TYPES", () => { + it("offers every channel type, regardless of category", () => { + expect([...CHANNEL_TYPES]).toEqual(["text", "voice", "announcement"]); }); }); @@ -86,32 +65,76 @@ describe("CreateChannelModal", () => { modal.destroy?.(); }); - it("shows only text and announcement types for text categories", () => { + it("offers every channel type under a text-sounding category", () => { 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"); + expect(Array.from(select.options).map((o) => o.value)).toEqual([ + "text", + "voice", + "announcement", + ]); modal.destroy?.(); }); - it("shows only voice type for voice categories", () => { + it("offers every channel type under a voice-sounding category too", () => { 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"); + expect(Array.from(select.options).map((o) => o.value)).toEqual([ + "text", + "voice", + "announcement", + ]); 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"); + it("pre-fills the category as editable text", () => { + const { modal } = makeModal("Gaming"); + const input = container.querySelector( + "[data-testid='channel-category-input']", + ) as HTMLInputElement; + expect(input.value).toBe("Gaming"); + expect(input.hasAttribute("disabled")).toBe(false); + modal.destroy?.(); + }); + + it("suggests the categories already in use via a datalist", () => { + setChannels([ + { id: 1, name: "general", type: "text", category: "Chat", position: 0 }, + { id: 2, name: "lounge", type: "voice", category: "Gaming", position: 1 }, + { id: 3, name: "loose", type: "text", category: null, position: 2 }, + ]); + const { modal } = makeModal("Chat"); + const list = container.querySelector("#create-channel-categories"); + const values = Array.from(list?.querySelectorAll("option") ?? []).map((o) => + o.getAttribute("value"), + ); + expect(values).toEqual(["Chat", "Gaming"]); + modal.destroy?.(); + }); + + it("submits an edited category rather than the one it opened on", async () => { + const onCreate = vi.fn(async () => {}); + const { modal } = makeModal("Chat", { onCreate }); + + (container.querySelector("[data-testid='channel-name-input']") as HTMLInputElement).value = + "lounge"; + (container.querySelector("[data-testid='channel-category-input']") as HTMLInputElement).value = + " Gaming "; + (container.querySelector("[data-testid='channel-type-select']") as HTMLSelectElement).value = + "voice"; + (container.querySelector("[data-testid='channel-create-submit']") as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(onCreate).toHaveBeenCalledWith({ + name: "lounge", + type: "voice", + category: "Gaming", + }); + }); modal.destroy?.(); }); diff --git a/Client/tauri-client/tests/unit/custom-emoji.test.ts b/Client/tauri-client/tests/unit/custom-emoji.test.ts new file mode 100644 index 00000000..0d8c0b68 --- /dev/null +++ b/Client/tauri-client/tests/unit/custom-emoji.test.ts @@ -0,0 +1,321 @@ +/** + * Custom-emoji end-to-end on the client: the store, `:shortcode:` rendering in + * message content (including where it must NOT apply), the jumbo rule, and + * reaction-pill resolution. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("@lib/livekitSession", () => ({ + leaveVoice: vi.fn(), + switchInputDevice: vi.fn(), + switchOutputDevice: vi.fn(), + setVoiceSensitivity: vi.fn(), + setInputVolume: vi.fn(), + setOutputVolume: vi.fn(), + getSessionDebugInfo: vi.fn().mockReturnValue({}), +})); + +// The emoji image is behind the session token, so buildCustomEmojiImage goes +// through the same authenticated fetch attachments use. Stub just that call — +// everything else in the module (isSafeUrl, resolveServerUrl) is real. +const { fetchImageAsDataUrlMock } = vi.hoisted(() => ({ + fetchImageAsDataUrlMock: vi.fn(() => Promise.resolve("data:image/png;base64,AAAA")), +})); +vi.mock("../../src/components/message-list/attachments", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, fetchImageAsDataUrl: fetchImageAsDataUrlMock }; +}); + +import { + EMOJI_TOKEN_REGEX, + MAX_JUMBO_EMOJI, + buildCustomEmojiNode, + isEmojiOnlyMessage, +} from "../../src/components/message-list/custom-emoji"; +import { renderMessageContent } from "../../src/components/message-list/content-parser"; +import { renderReactions } from "../../src/components/message-list/reactions"; +import { + emojiStore, + setCustomEmoji, + clearCustomEmoji, + resolveEmoji, + listCustomEmoji, +} from "../../src/stores/emoji.store"; +import type { Message } from "../../src/stores/messages.store"; +import type { MessageListOptions } from "../../src/components/MessageList"; + +const EMOJI = [ + { id: 1, shortcode: "wave", url: "/api/v1/emoji/1/image" }, + { id: 2, shortcode: "party_blob", url: "/api/v1/emoji/2/image" }, +]; + +beforeEach(() => { + clearCustomEmoji(); + emojiStore.flush(); + setCustomEmoji(EMOJI); + emojiStore.flush(); + fetchImageAsDataUrlMock.mockClear(); +}); + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +describe("emoji store", () => { + it("indexes by shortcode and keeps server order", () => { + expect(listCustomEmoji().map((e) => e.shortcode)).toEqual(["wave", "party_blob"]); + expect(resolveEmoji("wave")?.id).toBe(1); + expect(resolveEmoji("party_blob")?.url).toBe("/api/v1/emoji/2/image"); + }); + + it("resolves case-insensitively and with or without colons", () => { + expect(resolveEmoji("WAVE")?.id).toBe(1); + expect(resolveEmoji(":wave:")?.id).toBe(1); + expect(resolveEmoji(":WaVe:")?.id).toBe(1); + }); + + it("returns null for unknown or malformed tokens", () => { + expect(resolveEmoji("nosuch")).toBeNull(); + expect(resolveEmoji("")).toBeNull(); + expect(resolveEmoji("::")).toBeNull(); + }); + + it("replaces the set wholesale so a deleted emoji stops resolving", () => { + setCustomEmoji([{ id: 2, shortcode: "party_blob", url: "/api/v1/emoji/2/image" }]); + emojiStore.flush(); + expect(resolveEmoji("wave")).toBeNull(); + expect(resolveEmoji("party_blob")).not.toBeNull(); + }); + + it("drops entries whose shortcode the server could not have stored", () => { + setCustomEmoji([ + { id: 1, shortcode: "ok_one", url: "/a" }, + { id: 2, shortcode: "has space", url: "/b" }, + { id: 3, shortcode: "x", url: "/c" }, + { id: 4, shortcode: "dash-es", url: "/d" }, + ]); + emojiStore.flush(); + expect(listCustomEmoji().map((e) => e.shortcode)).toEqual(["ok_one"]); + }); + + it("lowercases shortcodes on the way in", () => { + setCustomEmoji([{ id: 9, shortcode: "SHOUT", url: "/a" }]); + emojiStore.flush(); + expect(listCustomEmoji()[0]?.shortcode).toBe("shout"); + expect(resolveEmoji("shout")?.id).toBe(9); + }); + + it("clearCustomEmoji empties the set", () => { + clearCustomEmoji(); + emojiStore.flush(); + expect(listCustomEmoji()).toEqual([]); + expect(resolveEmoji("wave")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Token regex + node builder +// --------------------------------------------------------------------------- + +describe("emoji tokens", () => { + it("matches shortcode-shaped tokens only", () => { + const found = [...":wave: :party_blob: :a: :has space: plain".matchAll(EMOJI_TOKEN_REGEX)].map( + (m) => m[1], + ); + expect(found).toContain("wave"); + expect(found).toContain("party_blob"); + expect(found).not.toContain("a"); + }); + + it("builds an image for a known shortcode and null for an unknown one", () => { + const img = buildCustomEmojiNode("wave"); + expect(img).not.toBeNull(); + expect(img?.tagName).toBe("IMG"); + expect(img?.className).toBe("custom-emoji"); + expect(img?.alt).toBe(":wave:"); + expect(img?.getAttribute("data-shortcode")).toBe("wave"); + expect(buildCustomEmojiNode("nosuch")).toBeNull(); + }); + + it("fetches the image through the authenticated path, not img.src", () => { + buildCustomEmojiNode("wave"); + expect(fetchImageAsDataUrlMock).toHaveBeenCalledTimes(1); + // resolveServerUrl leaves the path relative when no host has been set. + const [url] = fetchImageAsDataUrlMock.mock.calls[0] as unknown as [string]; + expect(url).toContain("/api/v1/emoji/1/image"); + }); +}); + +// --------------------------------------------------------------------------- +// Message rendering +// --------------------------------------------------------------------------- + +function render(content: string): HTMLDivElement { + const host = document.createElement("div"); + host.appendChild(renderMessageContent(content)); + return host; +} + +describe("custom emoji in message content", () => { + it("renders a known shortcode as an inline image", () => { + const host = render("hello :wave: there"); + const imgs = host.querySelectorAll("img.custom-emoji"); + expect(imgs.length).toBe(1); + expect(imgs[0]?.getAttribute("data-shortcode")).toBe("wave"); + expect(host.textContent).toContain("hello"); + expect(host.textContent).toContain("there"); + }); + + it("leaves an unknown shortcode as literal text", () => { + const host = render("hello :nosuch: there"); + expect(host.querySelectorAll("img.custom-emoji").length).toBe(0); + expect(host.textContent).toContain(":nosuch:"); + }); + + it("renders several emoji in one message", () => { + const host = render(":wave: and :party_blob:"); + expect(host.querySelectorAll("img.custom-emoji").length).toBe(2); + }); + + it("never renders inside an inline code span", () => { + const host = render("type `:wave:` to wave"); + expect(host.querySelectorAll("img.custom-emoji").length).toBe(0); + expect(host.querySelector("code")?.textContent).toBe(":wave:"); + }); + + it("never renders inside a fenced code block", () => { + const host = render("```\n:wave:\n```"); + expect(host.querySelectorAll("img.custom-emoji").length).toBe(0); + expect(host.querySelector(".msg-codeblock")?.textContent).toContain(":wave:"); + }); + + it("never renders inside a tagged fence", () => { + const host = render("```ts\nconst a = ':wave:';\n```"); + expect(host.querySelectorAll("img.custom-emoji").length).toBe(0); + }); + + it("renders around a fence but not inside it", () => { + const host = render(":wave: before\n```\n:party_blob:\n```\n:wave: after"); + const imgs = host.querySelectorAll("img.custom-emoji"); + expect(imgs.length).toBe(2); + expect([...imgs].every((i) => i.getAttribute("data-shortcode") === "wave")).toBe(true); + }); + + it("renders inside markdown emphasis", () => { + const host = render("**:wave:**"); + expect(host.querySelector("strong img.custom-emoji")).not.toBeNull(); + }); + + it("keeps working next to a URL", () => { + const host = render("see https://example.com :wave:"); + expect(host.querySelectorAll("img.custom-emoji").length).toBe(1); + expect(host.querySelector("a.msg-link")?.getAttribute("href")).toBe("https://example.com"); + }); +}); + +// --------------------------------------------------------------------------- +// Jumbo +// --------------------------------------------------------------------------- + +describe("jumbo emoji", () => { + it("treats an emoji-only message as jumbo", () => { + expect(isEmojiOnlyMessage(":wave:")).toBe(true); + expect(isEmojiOnlyMessage(":wave: :party_blob:")).toBe(true); + expect(isEmojiOnlyMessage("🔥")).toBe(true); + expect(isEmojiOnlyMessage("🔥 :wave: 🎉")).toBe(true); + expect(isEmojiOnlyMessage("👍🏽")).toBe(true); + expect(isEmojiOnlyMessage("👨‍👩‍👧")).toBe(true); + }); + + it("does not jumbo a message with any other content", () => { + expect(isEmojiOnlyMessage("hi :wave:")).toBe(false); + expect(isEmojiOnlyMessage(":wave: !")).toBe(false); + expect(isEmojiOnlyMessage("")).toBe(false); + expect(isEmojiOnlyMessage(" ")).toBe(false); + expect(isEmojiOnlyMessage("```\n🔥\n```")).toBe(false); + }); + + it("does not jumbo an unresolved shortcode — it is plain text", () => { + expect(isEmojiOnlyMessage(":nosuch:")).toBe(false); + }); + + it("stops jumboing past the cap", () => { + expect(isEmojiOnlyMessage("🔥".repeat(MAX_JUMBO_EMOJI))).toBe(true); + expect(isEmojiOnlyMessage("🔥".repeat(MAX_JUMBO_EMOJI + 1))).toBe(false); + }); + + it("marks the rendered text block with the jumbo class", () => { + expect(render(":wave:").querySelector(".msg-text-jumbo")).not.toBeNull(); + expect(render("🔥🔥").querySelector(".msg-text-jumbo")).not.toBeNull(); + expect(render("hi :wave:").querySelector(".msg-text-jumbo")).toBeNull(); + expect(render("hi :wave:").querySelector(".msg-text")).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Reaction pills +// --------------------------------------------------------------------------- + +function makeMessage(emoji: string): Message { + return { + id: 1, + channelId: 1, + userId: 10, + username: "alice", + avatar: null, + content: "hi", + timestamp: "2026-01-01T00:00:00Z", + editedAt: null, + replyTo: null, + attachments: [], + reactions: [{ emoji, count: 2, me: false }], + pending: false, + failed: false, + pinned: false, + } as unknown as Message; +} + +function reactionOptions(): MessageListOptions { + return { onReactionClick: vi.fn() } as unknown as MessageListOptions; +} + +describe("reaction pills", () => { + it("renders the image for a custom-emoji reaction", () => { + const el = renderReactions( + makeMessage(":wave:"), + reactionOptions(), + new AbortController().signal, + ); + const chip = el.querySelector(".reaction-chip"); + expect(chip?.querySelector("img.custom-emoji")).not.toBeNull(); + expect(chip?.getAttribute("data-emoji")).toBe(":wave:"); + expect(chip?.querySelector(".rc-count")?.textContent).toBe("2"); + }); + + it("falls back to the literal text when the emoji is gone", () => { + const el = renderReactions( + makeMessage(":deleted_one:"), + reactionOptions(), + new AbortController().signal, + ); + const chip = el.querySelector(".reaction-chip"); + expect(chip?.querySelector("img.custom-emoji")).toBeNull(); + expect(chip?.textContent).toContain(":deleted_one:"); + }); + + it("leaves a unicode reaction as text", () => { + const el = renderReactions(makeMessage("🔥"), reactionOptions(), new AbortController().signal); + const chip = el.querySelector(".reaction-chip"); + expect(chip?.querySelector("img.custom-emoji")).toBeNull(); + expect(chip?.textContent).toContain("🔥"); + }); + + it("still toggles the reaction when it renders as an image", () => { + const opts = reactionOptions(); + const el = renderReactions(makeMessage(":wave:"), opts, new AbortController().signal); + (el.querySelector(".reaction-chip") as HTMLElement).click(); + expect(opts.onReactionClick).toHaveBeenCalledWith(1, ":wave:"); + }); +}); diff --git a/Client/tauri-client/tests/unit/deep-link-init.test.ts b/Client/tauri-client/tests/unit/deep-link-init.test.ts index 0db1d2b4..7fd5a261 100644 --- a/Client/tauri-client/tests/unit/deep-link-init.test.ts +++ b/Client/tauri-client/tests/unit/deep-link-init.test.ts @@ -113,6 +113,56 @@ describe("initDeepLinks", () => { expect(onInvite).toHaveBeenCalledWith("WARM", "h.example"); }); + it("routes a message permalink to onMessage, not onInvite", async () => { + getCurrent.mockResolvedValue(["owncord://message/5/42"]); + const onInvite = vi.fn(); + const onMessage = vi.fn(); + + await initDeepLinks(onInvite, onMessage); + + expect(onMessage).toHaveBeenCalledWith(5, 42); + expect(onInvite).not.toHaveBeenCalled(); + }); + + it("dispatches a warm-launch message permalink", async () => { + const onMessage = vi.fn(); + await initDeepLinks(vi.fn(), onMessage); + + const handler = onOpenUrl.mock.calls[0]?.[0] as (urls: readonly string[]) => void; + handler(["owncord://message/9/7"]); + + expect(onMessage).toHaveBeenCalledWith(9, 7); + }); + + it("ignores a message permalink when no onMessage handler was supplied", async () => { + // The old two-argument-free call site must not start feeding permalinks + // into the invite flow, and must not throw. + getCurrent.mockResolvedValue(["owncord://message/5/42"]); + const onInvite = vi.fn(); + + await expect(initDeepLinks(onInvite)).resolves.toBeUndefined(); + + expect(onInvite).not.toHaveBeenCalled(); + }); + + it("mixes invites and permalinks in one batch", async () => { + getCurrent.mockResolvedValue([ + "owncord://message/1/2", + "owncord://invite/CODE", + "owncord://message/3/4", + ]); + const onInvite = vi.fn(); + const onMessage = vi.fn(); + + await initDeepLinks(onInvite, onMessage); + + expect(onInvite).toHaveBeenCalledTimes(1); + expect(onInvite).toHaveBeenCalledWith("CODE", undefined); + expect(onMessage).toHaveBeenCalledTimes(2); + expect(onMessage).toHaveBeenNthCalledWith(1, 1, 2); + expect(onMessage).toHaveBeenNthCalledWith(2, 3, 4); + }); + it("swallows a getCurrent rejection without wiring a listener", async () => { getCurrent.mockRejectedValue(new Error("ipc down")); const onInvite = vi.fn(); diff --git a/Client/tauri-client/tests/unit/deep-link.test.ts b/Client/tauri-client/tests/unit/deep-link.test.ts index 471b822e..66de36fc 100644 --- a/Client/tauri-client/tests/unit/deep-link.test.ts +++ b/Client/tauri-client/tests/unit/deep-link.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseInviteLink } from "@lib/deep-link"; +import { parseInviteLink, parseMessageLink, formatMessageLink } from "@lib/deep-link"; describe("parseInviteLink", () => { it("parses owncord://invite/", () => { @@ -33,4 +33,63 @@ describe("parseInviteLink", () => { expect(parseInviteLink("owncord://invite/")).toBeNull(); expect(parseInviteLink("owncord://")).toBeNull(); }); + + it("rejects a message permalink — 'message' is a route, not an invite code", () => { + // The bare-code form (owncord://) would otherwise swallow the + // message route and try to register an account with the code "message". + expect(parseInviteLink("owncord://message/5/42")).toBeNull(); + expect(parseInviteLink("owncord://message")).toBeNull(); + }); +}); + +describe("parseMessageLink", () => { + it("parses owncord://message//", () => { + expect(parseMessageLink("owncord://message/5/42")).toEqual({ channelId: 5, messageId: 42 }); + }); + + it("tolerates a trailing slash", () => { + expect(parseMessageLink("owncord://message/5/42/")).toEqual({ channelId: 5, messageId: 42 }); + }); + + it("rejects a non-owncord scheme", () => { + expect(parseMessageLink("https://example.com/message/5/42")).toBeNull(); + }); + + it("rejects the invite route", () => { + expect(parseMessageLink("owncord://invite/ABC")).toBeNull(); + expect(parseMessageLink("owncord://ABC")).toBeNull(); + }); + + it("rejects missing or non-numeric ids", () => { + expect(parseMessageLink("owncord://message/5")).toBeNull(); + expect(parseMessageLink("owncord://message")).toBeNull(); + expect(parseMessageLink("owncord://message/abc/42")).toBeNull(); + expect(parseMessageLink("owncord://message/5/abc")).toBeNull(); + expect(parseMessageLink("owncord://message/5.5/42")).toBeNull(); + }); + + it("rejects zero and negative ids", () => { + expect(parseMessageLink("owncord://message/0/42")).toBeNull(); + expect(parseMessageLink("owncord://message/5/0")).toBeNull(); + expect(parseMessageLink("owncord://message/-5/42")).toBeNull(); + }); + + it("rejects ids beyond safe-integer precision", () => { + // Past 2^53 the parsed number is not the number in the link, so a jump + // would silently target a different message. + expect(parseMessageLink("owncord://message/5/99999999999999999999")).toBeNull(); + }); + + it("ignores extra path segments after the message id", () => { + expect(parseMessageLink("owncord://message/5/42/extra")).toEqual({ + channelId: 5, + messageId: 42, + }); + }); + + it("round-trips with formatMessageLink", () => { + const url = formatMessageLink(7, 1234); + expect(url).toBe("owncord://message/7/1234"); + expect(parseMessageLink(url)).toEqual({ channelId: 7, messageId: 1234 }); + }); }); diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index d9ef05c4..37f5df38 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { wireDispatcher, wireConnectionStatus } from "../../src/lib/dispatcher"; import { createMockWsClient } from "../helpers/mock-ws"; import { authStore, clearAuth } from "../../src/stores/auth.store"; -import { channelsStore } from "../../src/stores/channels.store"; +import { channelsStore, setRoles, getRoleIdByName } from "../../src/stores/channels.store"; import { messagesStore, addOptimisticMessage, @@ -12,7 +12,20 @@ import { membersStore } from "../../src/stores/members.store"; import { voiceStore } from "../../src/stores/voice.store"; import { dmStore } from "../../src/stores/dm.store"; import { blocksStore } from "../../src/stores/blocks.store"; +import { + emojiStore, + setCustomEmoji, + clearCustomEmoji, + listCustomEmoji, + resolveEmoji, +} from "../../src/stores/emoji.store"; import { uiStore } from "../../src/stores/ui.store"; +import { + clearReactionUsersCache, + getCachedReactionUsers, + loadReactionUsers, + setReactionUsersFetcher, +} from "../../src/components/message-list/reaction-tooltip"; import type { WsClient, WsListener } from "../../src/lib/ws"; import type { ServerMessage } from "../../src/lib/types"; @@ -29,9 +42,16 @@ vi.mock("@lib/livekitSession", () => ({ leaveVoice: vi.fn(), cleanupAll: vi.fn(), isVoiceConnected: vi.fn(() => false), + setMuted: vi.fn(), + setDeafened: vi.fn(), })); // F3: the ready handler publishes our identity key. Mock the orchestrator so // the wiring is asserted without real keygen/keyring. +const mockShowToast = vi.fn(); +vi.mock("@lib/toast", () => ({ + showToast: (...args: unknown[]) => mockShowToast(...args), +})); + vi.mock("@lib/identity", () => ({ ensureIdentityKeyPublished: vi.fn(async () => true), })); @@ -39,6 +59,8 @@ vi.mock("@lib/identity", () => ({ import { ensureIdentityKeyPublished as _ensureIdentityKeyPublished } from "../../src/lib/identity"; const mockEnsurePublished = vi.mocked(_ensureIdentityKeyPublished); +import { setMuted as mockSetMuted, setDeafened as mockSetDeafened } from "@lib/livekitSession"; + // Suppress console output vi.spyOn(console, "info").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -122,6 +144,7 @@ describe("WS Dispatcher", () => { loadedChannels: new Set(), hasMore: new Map(), historyLoadState: new Map(), + detachedChannels: new Set(), })); membersStore.setState(() => ({ members: new Map(), @@ -142,6 +165,8 @@ describe("WS Dispatcher", () => { dmStore.setState(() => ({ channels: [] })); blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set() })); uiStore.setState((prev) => ({ ...prev, transientError: null })); + clearCustomEmoji(); + emojiStore.flush(); mock = createMockWs(); cleanup = wireDispatcher(mock.ws); @@ -213,9 +238,14 @@ describe("WS Dispatcher", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: ch, activeChannelId: 1 }; // active is channel 1 }); @@ -234,6 +264,76 @@ describe("WS Dispatcher", () => { expect(ch?.unreadCount).toBe(1); }); + describe("mention counts", () => { + function seedChannel(): void { + 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, + mentionCount: 0, + lastMessageId: null, + canSend: true, + topic: "", + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }); + return { ...prev, channels: ch, activeChannelId: 1 }; + }); + authStore.setState((prev) => ({ + ...prev, + user: { id: 1, username: "alex", avatar: null, role: "member" }, + })); + } + + function incoming(extra: Record): void { + mock.dispatch("chat_message", { + id: 200, + channel_id: 5, + user: { id: 2, username: "bob", avatar: null }, + content: "ping", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + ...extra, + }); + } + + it("increments when the message names the current user", () => { + seedChannel(); + incoming({ content: "ping @alex", mentions: [1] }); + const ch = channelsStore.getState().channels.get(5); + expect(ch?.mentionCount).toBe(1); + expect(ch?.unreadCount).toBe(1); + }); + + it("increments for an honoured @everyone", () => { + seedChannel(); + incoming({ content: "@everyone", mentions_everyone: true }); + expect(channelsStore.getState().channels.get(5)?.mentionCount).toBe(1); + }); + + it("does not increment for someone else's mention", () => { + seedChannel(); + incoming({ content: "ping @bob", mentions: [2] }); + const ch = channelsStore.getState().channels.get(5); + expect(ch?.mentionCount).toBe(0); + expect(ch?.unreadCount).toBe(1); + }); + + it("does not increment for an @everyone the sender could not send", () => { + seedChannel(); + incoming({ content: "@everyone", mentions_everyone: false }); + expect(channelsStore.getState().channels.get(5)?.mentionCount).toBe(0); + }); + }); + it("wires presence to members store", () => { // Add a member first membersStore.setState((prev) => { @@ -246,6 +346,44 @@ describe("WS Dispatcher", () => { expect(membersStore.getState().members.get(1)?.status).toBe("idle"); }); + it("carries a custom status on presence, and leaves it alone when omitted", () => { + 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", custom_status: "afk" }); + expect(membersStore.getState().members.get(1)?.customStatus).toBe("afk"); + + // A bare status flip (what the auto-idle timer sends) must not blank it. + mock.dispatch("presence", { user_id: 1, status: "online" }); + expect(membersStore.getState().members.get(1)?.customStatus).toBe("afk"); + + // An explicit null clears it. + mock.dispatch("presence", { user_id: 1, status: "online", custom_status: null }); + expect(membersStore.getState().members.get(1)?.customStatus).toBeNull(); + }); + + it("wires user_update display_name into the member store", () => { + 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("user_update", { + user_id: 1, + username: "alex", + avatar: "/api/v1/files/abc", + display_name: "Alex A.", + about: "hi", + }); + const member = membersStore.getState().members.get(1); + expect(member?.displayName).toBe("Alex A."); + expect(member?.avatar).toBe("/api/v1/files/abc"); + }); + it("wires typing to members store", () => { mock.dispatch("typing", { channel_id: 1, user_id: 42, username: "bob" }); const typing = membersStore.getState().typingUsers.get(1); @@ -274,9 +412,14 @@ describe("WS Dispatcher", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: ch }; }); @@ -285,11 +428,32 @@ describe("WS Dispatcher", () => { expect(channelsStore.getState().channels.has(10)).toBe(false); }); - it("wires member_join to members store", () => { + it("wires member_join to members store, using the payload's status", () => { mock.dispatch("member_join", { user: { id: 99, username: "newuser", avatar: null, role: "member" }, + status: "online", }); - expect(membersStore.getState().members.has(99)).toBe(true); + expect(membersStore.getState().members.get(99)).toMatchObject({ status: "online" }); + }); + + it("renders an invisible member_join as offline, not online", () => { + // The server broadcasts an invisible connector's join as "offline" (the + // viewer-safe collapse) — the client must render exactly that, not + // assume a join always means online. + mock.dispatch("member_join", { + user: { id: 100, username: "ghost", avatar: null, role: "member" }, + status: "offline", + }); + expect(membersStore.getState().members.get(100)).toMatchObject({ status: "offline" }); + }); + + it("defaults a member_join with no status field to offline, not online", () => { + // An older server that has not shipped the status field yet must fail + // safe — omission must never be read as "online". + mock.dispatch("member_join", { + user: { id: 101, username: "legacy-server-user", avatar: null, role: "member" }, + }); + expect(membersStore.getState().members.get(101)).toMatchObject({ status: "offline" }); }); it("wires chat_send_ok to confirmSend in messages store", () => { @@ -481,6 +645,51 @@ describe("WS Dispatcher", () => { expect(found?.deleted).toBe(true); }); + it("wires chat_bulk_deleted to messages store", () => { + for (const id of [100, 101, 102]) { + mock.dispatch("chat_message", { + id, + channel_id: 1, + user: { id: 1, username: "alex", avatar: null }, + content: `spam ${id}`, + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + } + + mock.dispatch("chat_bulk_deleted", { channel_id: 1, ids: [102, 101] }); + + const msgs = messagesStore.getState().messagesByChannel.get(1); + expect(msgs?.find((m) => m.id === 102)?.deleted).toBe(true); + expect(msgs?.find((m) => m.id === 101)?.deleted).toBe(true); + // Tombstones, not removals: the rows and their content survive. + expect(msgs).toHaveLength(3); + expect(msgs?.find((m) => m.id === 102)?.content).toBe("spam 102"); + // An id outside the purge is untouched. + expect(msgs?.find((m) => m.id === 100)?.deleted).toBe(false); + }); + + it("ignores chat_bulk_deleted for an unloaded channel and an empty id list", () => { + mock.dispatch("chat_message", { + id: 200, + channel_id: 1, + user: { id: 1, username: "alex", avatar: null }, + content: "keep", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + const before = messagesStore.getState().messagesByChannel; + + mock.dispatch("chat_bulk_deleted", { channel_id: 99, ids: [1, 2, 3] }); + mock.dispatch("chat_bulk_deleted", { channel_id: 1, ids: [] }); + + // No-op dispatches must not churn the map identity (re-render trigger). + expect(messagesStore.getState().messagesByChannel).toBe(before); + expect(messagesStore.getState().messagesByChannel.get(1)?.[0]?.deleted).toBe(false); + }); + it("wires chat_send_ok without id does not crash", () => { expect(() => { mock.dispatch("chat_send_ok", { message_id: 500, timestamp: "2026-03-15T10:00:00Z" }); @@ -518,6 +727,31 @@ describe("WS Dispatcher", () => { expect(msgs).toBeDefined(); }); + // The who-reacted tooltip caches reactor lists per message+emoji; a + // reaction_update on that message makes every one of them stale. + it("invalidates the who-reacted cache for the message a reaction_update names", async () => { + const fetcher = vi.fn().mockResolvedValue([{ id: 1, username: "alice", avatar: "" }]); + setReactionUsersFetcher(fetcher as never); + clearReactionUsersCache(); + + await loadReactionUsers(1, 200, "👍"); + await loadReactionUsers(1, 201, "👍"); + expect(getCachedReactionUsers(200, "👍")).toHaveLength(1); + + mock.dispatch("reaction_update", { + message_id: 200, + channel_id: 1, + emoji: "👍", + user_id: 2, + action: "add", + }); + + expect(getCachedReactionUsers(200, "👍")).toBeUndefined(); + // Other messages' caches are untouched. + expect(getCachedReactionUsers(201, "👍")).toHaveLength(1); + setReactionUsersFetcher(null); + }); + it("wires channel_update to channels store", () => { channelsStore.setState((prev) => { const ch = new Map(prev.channels); @@ -528,9 +762,14 @@ describe("WS Dispatcher", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: ch }; }); @@ -557,9 +796,14 @@ describe("WS Dispatcher", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); ch.set(20, { id: 20, @@ -568,9 +812,14 @@ describe("WS Dispatcher", () => { category: null, position: 1, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: ch, activeChannelId: 10 }; }); @@ -591,9 +840,14 @@ describe("WS Dispatcher", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: ch, activeChannelId: 10 }; }); @@ -620,6 +874,90 @@ describe("WS Dispatcher", () => { expect(membersStore.getState().members.get(42)?.role).toBe("admin"); }); + it("wires roles_update to replace the role list", () => { + channelsStore.setState((prev) => ({ + ...prev, + roles: [ + { id: 1, name: "Owner", color: "#E74C3C", permissions: 0x40000000, position: 100 }, + { id: 9, name: "Contractor", color: "#123456", permissions: 0x3, position: 30 }, + ], + })); + + // A role was deleted and another recolored server-side. Replacing rather + // than merging is the point: the deleted role must not survive. + mock.dispatch("roles_update", { + roles: [ + { id: 1, name: "Owner", color: "#FF0000", permissions: 0x40000000, position: 100 }, + { id: 4, name: "Member", color: null, permissions: 0x3, position: 40, is_default: true }, + ], + }); + + const roles = channelsStore.getState().roles; + expect(roles.map((r) => r.id)).toEqual([1, 4]); + expect(roles[0]?.color).toBe("#FF0000"); + expect(roles.some((r) => r.name === "Contractor")).toBe(false); + }); + + it("makes a role created by roles_update immediately assignable", () => { + // The Change Role menu resolves a role by name, so a role created in the + // admin panel has to be resolvable from the broadcast alone — without this + // assigning a freshly created role needed a reconnect. + setRoles([{ id: 4, name: "Member", color: null, permissions: 0x3, position: 40 }]); + expect(getRoleIdByName("contractor")).toBeUndefined(); + + mock.dispatch("roles_update", { + roles: [ + { id: 4, name: "Member", color: null, permissions: 0x3, position: 40, is_default: true }, + { id: 9, name: "Contractor", color: "#123456", permissions: 0x3, position: 30 }, + ], + }); + + expect(getRoleIdByName("contractor")).toBe(9); + }); + + it("treats a roles_update with no roles field as an empty list", () => { + channelsStore.setState((prev) => ({ + ...prev, + roles: [{ id: 1, name: "Owner", color: null, permissions: 0 }], + })); + + mock.dispatch("roles_update", {} as { roles: [] }); + + expect(channelsStore.getState().roles).toEqual([]); + }); + + it("wires emoji_update to replace the custom-emoji set", () => { + setCustomEmoji([ + { id: 1, shortcode: "wave", url: "/api/v1/emoji/1/image" }, + { id: 2, shortcode: "gone", url: "/api/v1/emoji/2/image" }, + ]); + emojiStore.flush(); + + // The deleted emoji must not survive the replace — the whole point of + // sending the set rather than a delta. + mock.dispatch("emoji_update", { + emoji: [ + { id: 1, shortcode: "wave", url: "/api/v1/emoji/1/image" }, + { id: 3, shortcode: "party", url: "/api/v1/emoji/3/image" }, + ], + }); + emojiStore.flush(); + + expect(listCustomEmoji().map((e) => e.shortcode)).toEqual(["wave", "party"]); + expect(resolveEmoji("gone")).toBeNull(); + expect(resolveEmoji("party")?.id).toBe(3); + }); + + it("treats an emoji_update with no emoji field as an empty set", () => { + setCustomEmoji([{ id: 1, shortcode: "wave", url: "/api/v1/emoji/1/image" }]); + emojiStore.flush(); + + mock.dispatch("emoji_update", {} as { emoji: [] }); + emojiStore.flush(); + + expect(listCustomEmoji()).toEqual([]); + }); + it("wires voice_state and auto-joins if current user", () => { authStore.setState((prev) => ({ ...prev, @@ -699,6 +1037,86 @@ describe("WS Dispatcher", () => { expect(voiceStore.getState().currentChannelId).toBe(3); }); + it("mirrors a moderator mute/deafen into the local flags and honors it", async () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + + mock.dispatch("voice_state", { + channel_id: 3, + user_id: 5, + username: "me", + muted: true, + deafened: true, + speaking: false, + camera: false, + screenshare: false, + server_muted: true, + server_deafened: true, + }); + + const state = voiceStore.getState(); + expect(state.localServerMuted).toBe(true); + expect(state.localServerDeafened).toBe(true); + + // Deafen is client-enforced: the session must stop playing remote audio. + await vi.runAllTimersAsync(); + expect(vi.mocked(mockSetDeafened)).toHaveBeenCalledWith(true); + expect(vi.mocked(mockSetMuted)).toHaveBeenCalledWith(true); + }); + + it("does not set the local moderator flags from another user's voice_state", () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + + mock.dispatch("voice_state", { + channel_id: 3, + user_id: 99, + username: "other", + muted: true, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + server_muted: true, + }); + + expect(voiceStore.getState().localServerMuted).not.toBe(true); + expect(voiceStore.getState().voiceUsers.get(3)?.get(99)?.serverMuted).toBe(true); + }); + + it("wires voice_moved to a leave + re-join of the destination channel", async () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 })); + + mock.dispatch("voice_moved", { to_channel_id: 7 }); + await vi.runAllTimersAsync(); + + expect(voiceStore.getState().currentChannelId).toBe(7); + expect(mock.ws.send).toHaveBeenCalledWith( + expect.objectContaining({ type: "voice_join", payload: { channel_id: 7 } }), + ); + }); + + it("wires voice_disconnected to clearing the local voice session", async () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 })); + + mock.dispatch("voice_disconnected", { channel_id: 3, reason: "kicked" }); + await vi.runAllTimersAsync(); + + expect(voiceStore.getState().currentChannelId).toBeNull(); + }); + it("wires voice_config to voice store", () => { mock.dispatch("voice_config", { channel_id: 3, @@ -710,13 +1128,35 @@ describe("WS Dispatcher", () => { }); it("wires voice_speakers to voice store", () => { + voiceStore.setState((prev) => { + const users = new Map( + [1, 2, 4].map((userId) => [ + userId, + { + userId, + username: `user${userId}`, + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }, + ]), + ); + const voiceUsers = new Map(prev.voiceUsers); + voiceUsers.set(3, users); + return { ...prev, voiceUsers }; + }); + mock.dispatch("voice_speakers", { channel_id: 3, speakers: [1, 2, 3], }); - // Verify it runs without error - expect(true).toBe(true); + const users = voiceStore.getState().voiceUsers.get(3); + expect(users?.get(1)?.speaking).toBe(true); + expect(users?.get(2)?.speaking).toBe(true); + expect(users?.get(4)?.speaking).toBe(false); }); it("wires voice_token to handleVoiceToken", async () => { @@ -763,6 +1203,49 @@ describe("WS Dispatcher", () => { expect(error).toContain("maintenance"); }); + it("wires server_restart shutdown to sign-out and call-state reset", () => { + authStore.setState((prev) => ({ + ...prev, + isAuthenticated: true, + user: { id: 1, username: "call-user", avatar: null, role: "member" }, + })); + // Simulate a live call with webcam and screenshare on. + voiceStore.setState((prev) => ({ + ...prev, + currentChannelId: 42, + voiceStatus: "connected", + localCamera: true, + localScreenshare: true, + })); + + mock.dispatch("server_restart", { reason: "shutdown", delay_seconds: 5 }); + + // Kicked back to login: auth cleared, reason preserved so the logout + // wiring keeps the saved credential. + expect(authStore.getState().isAuthenticated).toBe(false); + expect(authStore.getState().logoutReason).toBe("server_shutdown"); + expect(uiStore.getState().transientError).toContain("shut down"); + + // Call settings reset to their normal state. + const voice = voiceStore.getState(); + expect(voice.currentChannelId).toBeNull(); + expect(voice.voiceStatus).toBe("idle"); + expect(voice.localCamera).toBe(false); + expect(voice.localScreenshare).toBe(false); + }); + + it("keeps the session for non-shutdown server_restart reasons", () => { + authStore.setState((prev) => ({ + ...prev, + isAuthenticated: true, + user: { id: 1, username: "stay-user", avatar: null, role: "member" }, + })); + + mock.dispatch("server_restart", { reason: "update", delay_seconds: 5 }); + + expect(authStore.getState().isAuthenticated).toBe(true); + }); + it("wires error BANNED to clear auth and show error", () => { authStore.setState((prev) => ({ ...prev, @@ -831,6 +1314,7 @@ describe("WS Dispatcher", () => { loadedChannels: new Set(), hasMore: new Map(), historyLoadState: new Map(), + detachedChannels: new Set(), })); uiStore.setState((prev) => ({ ...prev, transientError: null })); @@ -859,10 +1343,14 @@ describe("WS Dispatcher", () => { { channelId: 7, recipient: { id: 5, username: "alice", avatar: "", status: "online" }, + participants: [], + name: "", + isGroup: false, lastMessageId: null, lastMessage: "", lastMessageAt: "", unreadCount: 0, + mentionCount: 0, }, ], })); @@ -947,6 +1435,36 @@ describe("WS Dispatcher", () => { expect(updateProfile).toHaveBeenCalledWith({ identity_public_key: "k" }); }); + it("on ready loads the custom-emoji set from the REST list", async () => { + cleanup(); + const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] }); + const listEmoji = vi + .fn() + .mockResolvedValue([{ id: 4, shortcode: "wave", url: "/api/v1/emoji/4/image" }]); + cleanup = wireDispatcher(mock.ws, { listBlocks, listEmoji }); + + mock.dispatch("ready", { channels: [], members: [], voice_states: [], roles: [] }); + + expect(listEmoji).toHaveBeenCalled(); + await Promise.resolve(); + await Promise.resolve(); + emojiStore.flush(); + expect(resolveEmoji("wave")?.id).toBe(4); + }); + + it("survives a failed emoji load — shortcodes just stay plain text", async () => { + cleanup(); + const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [] }); + const listEmoji = vi.fn().mockRejectedValue(new Error("offline")); + cleanup = wireDispatcher(mock.ws, { listBlocks, listEmoji }); + + mock.dispatch("ready", { channels: [], members: [], voice_states: [], roles: [] }); + await Promise.resolve(); + await Promise.resolve(); + emojiStore.flush(); + expect(listCustomEmoji()).toEqual([]); + }); + it("on ready clears being-blocked state and refreshes blocked-by-me via api", async () => { cleanup(); // tear down the no-api dispatcher wired in beforeEach const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [11, 22] }); @@ -1010,9 +1528,14 @@ describe("WS Dispatcher", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: ch, activeChannelId: 1 }; }); @@ -1042,9 +1565,14 @@ describe("WS Dispatcher", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: ch, activeChannelId: 1 }; }); @@ -1068,10 +1596,14 @@ describe("WS Dispatcher", () => { const dmChannel = { channelId: 50, recipient: { id: 10, username: "bob", avatar: "", status: "online" as const }, + participants: [], + name: "", + isGroup: false, lastMessageId: null, lastMessage: "", lastMessageAt: "", unreadCount: 0, + mentionCount: 0, }; beforeEach(() => { @@ -1194,6 +1726,52 @@ describe("WS Dispatcher", () => { expect(channels[0]!.recipient.username).toBe("bob"); }); + // A pre-group server sends only `recipient`, which for it IS the whole + // membership — so the fallback has to be a one-element list, not an empty + // one, or every group-aware call site breaks against an old server. + it("treats a recipient-only payload as a one-person participant list", () => { + mock.dispatch("dm_channel_open", { + channel_id: 50, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + last_message_id: null, + last_message: "", + last_message_at: "", + unread_count: 0, + }); + + const dm = dmStore.getState().channels[0]!; + expect(dm.participants).toHaveLength(1); + expect(dm.participants[0]!.id).toBe(10); + expect(dm.isGroup).toBe(false); + expect(dm.name).toBe(""); + }); + + it("maps a group dm_channel_open with its full participant list", () => { + mock.dispatch("dm_channel_open", { + channel_id: 51, + recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + recipients: [ + { id: 10, username: "bob", avatar: "", status: "online", display_name: "Bobby" }, + { id: 11, username: "cat", avatar: "", status: "idle" }, + ], + name: "Crew", + is_group: true, + last_message_id: null, + last_message: "", + last_message_at: "", + unread_count: 0, + }); + + const dm = dmStore.getState().channels.find((c) => c.channelId === 51)!; + expect(dm.isGroup).toBe(true); + expect(dm.name).toBe("Crew"); + expect(dm.participants.map((p) => p.id)).toEqual([10, 11]); + expect(dm.participants[0]!.displayName).toBe("Bobby"); + // The compat recipient is the first of the list, so an older render path + // still shows somebody rather than nothing. + expect(dm.recipient.id).toBe(10); + }); + it("should call removeDmChannel on dm_channel_close", () => { // Seed a DM channel first dmStore.setState(() => ({ @@ -1201,10 +1779,14 @@ describe("WS Dispatcher", () => { { channelId: 50, recipient: { id: 10, username: "bob", avatar: "", status: "online" }, + participants: [], + name: "", + isGroup: false, lastMessageId: null, lastMessage: "", lastMessageAt: "", unreadCount: 0, + mentionCount: 0, }, ], })); @@ -1389,6 +1971,46 @@ describe("WS Dispatcher", () => { expect(messagesStore.getState().messagesByChannel.get(1)).toBeUndefined(); }); + + // ─── Voice capacity refusals ──────────────────────────────────────────── + // + // The server owns voice_max_users / voice_max_video and answers an over-limit + // join with CHANNEL_FULL (or an over-limit camera with VIDEO_LIMIT). The + // client deliberately does not pre-block the click — its participant list can + // lag, and a refusal it invented would be uncorrectable — so the only thing + // standing between the user and a silent no-op is this toast. + + describe("voice capacity errors", () => { + beforeEach(() => { + mockShowToast.mockClear(); + }); + + it("surfaces CHANNEL_FULL as a toast", () => { + mock.dispatch("error", { code: "CHANNEL_FULL", message: "voice channel is full" }); + expect(mockShowToast).toHaveBeenCalledWith("voice channel is full", "error"); + }); + + it("falls back to a readable message when the server sends none", () => { + mock.dispatch("error", { code: "CHANNEL_FULL", message: "" }); + expect(mockShowToast).toHaveBeenCalledWith("That voice channel is full", "error"); + }); + + it("surfaces VIDEO_LIMIT as a toast", () => { + mock.dispatch("error", { code: "VIDEO_LIMIT", message: "" }); + expect(mockShowToast).toHaveBeenCalledWith( + "That voice channel has reached its video limit", + "error", + ); + }); + + // A capacity refusal is about the voice channel, not about the composer, + // so it must not also land in the login screen's transient-error slot. + it("does not set the transient error", () => { + uiStore.setState((prev) => ({ ...prev, transientError: null })); + mock.dispatch("error", { code: "CHANNEL_FULL", message: "full" }); + expect(uiStore.getState().transientError).toBeNull(); + }); + }); }); describe("wireConnectionStatus", () => { diff --git a/Client/tauri-client/tests/unit/dm-groups.test.ts b/Client/tauri-client/tests/unit/dm-groups.test.ts new file mode 100644 index 00000000..36d1e5bc --- /dev/null +++ b/Client/tauri-client/tests/unit/dm-groups.test.ts @@ -0,0 +1,396 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createDmSidebar } from "@components/DmSidebar"; +import type { DmConversation } from "@components/DmSidebar"; +import { createIncomingCallBanner } from "@components/IncomingCallBanner"; +import { dmDisplayName } from "@stores/dm.store"; +import type { DmChannel, DmUser } from "@stores/dm.store"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const user = (id: number, username: string, displayName = ""): DmUser => ({ + id, + username, + avatar: "", + status: "online", + displayName, +}); + +function makeDm(overrides: Partial = {}): DmChannel { + return { + channelId: 1, + recipient: user(2, "bob"), + participants: [user(2, "bob")], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: 0, + mentionCount: 0, + ...overrides, + }; +} + +const convo = (overrides: Partial = {}): DmConversation => ({ + channelId: 1, + userId: 2, + username: "bob", + avatar: null, + status: "online", + lastMessage: "", + timestamp: "", + unread: false, + ...overrides, +}); + +// --------------------------------------------------------------------------- +// dmDisplayName +// --------------------------------------------------------------------------- + +describe("dmDisplayName", () => { + it("names a 1:1 DM by the other person", () => { + expect(dmDisplayName(makeDm())).toBe("bob"); + }); + + it("prefers a display name over the username", () => { + expect(dmDisplayName(makeDm({ participants: [user(2, "bob", "Bobby")] }))).toBe("Bobby"); + }); + + it("uses a group's name when it has one", () => { + expect( + dmDisplayName( + makeDm({ + isGroup: true, + name: "Lunch crew", + participants: [user(2, "bob"), user(3, "cat")], + }), + ), + ).toBe("Lunch crew"); + }); + + it("joins the members of an unnamed group", () => { + expect( + dmDisplayName(makeDm({ isGroup: true, participants: [user(2, "bob"), user(3, "cat")] })), + ).toBe("bob, cat"); + }); + + // Without a cap the label grows without bound and pushes the badges out of + // the row; three plus a count is the same shape Discord settles on. + it("caps an unnamed group at three names plus a count", () => { + const many = [user(2, "a"), user(3, "b"), user(4, "c"), user(5, "d"), user(6, "e")]; + expect(dmDisplayName(makeDm({ isGroup: true, participants: many }))).toBe("a, b, c and 2 more"); + }); + + it("falls back to the recipient when the participant list is empty", () => { + expect(dmDisplayName(makeDm({ participants: [] }))).toBe("bob"); + }); +}); + +// --------------------------------------------------------------------------- +// DmSidebar — group rendering +// --------------------------------------------------------------------------- + +describe("DmSidebar — group rows", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.querySelectorAll(".context-menu").forEach((el) => el.remove()); + }); + + function mount(conversations: DmConversation[], opts: Record = {}) { + const sidebar = createDmSidebar({ + conversations, + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + ...opts, + }); + sidebar.mount(container); + return sidebar; + } + + it("draws stacked avatars for a group", () => { + const sidebar = mount([ + convo({ + channelId: 7, + isGroup: true, + participants: [ + { id: 2, username: "bob", avatar: null }, + { id: 3, username: "cat", avatar: null }, + ], + }), + ]); + + const stack = container.querySelector('[data-testid="dm-avatar-stack-7"]'); + expect(stack).not.toBeNull(); + expect(stack!.querySelectorAll(".dm-avatar-face")).toHaveLength(2); + // A group has no presence of its own, so no status dot is drawn. + expect(stack!.querySelector(".dm-status")).toBeNull(); + + sidebar.destroy?.(); + }); + + it("shows only the first two faces for a larger group", () => { + const sidebar = mount([ + convo({ + channelId: 7, + isGroup: true, + participants: [ + { id: 2, username: "a", avatar: null }, + { id: 3, username: "b", avatar: null }, + { id: 4, username: "c", avatar: null }, + ], + }), + ]); + + expect( + container + .querySelector('[data-testid="dm-avatar-stack-7"]')! + .querySelectorAll(".dm-avatar-face"), + ).toHaveLength(2); + + sidebar.destroy?.(); + }); + + it("renders the participant count including the current user", () => { + const sidebar = mount([ + convo({ + channelId: 7, + isGroup: true, + participants: [ + { id: 2, username: "bob", avatar: null }, + { id: 3, username: "cat", avatar: null }, + ], + }), + ]); + + expect(container.querySelector('[data-testid="dm-members-7"]')!.textContent).toBe("3"); + + sidebar.destroy?.(); + }); + + it("keeps a single avatar with a status dot for a 1:1 DM", () => { + const sidebar = mount([convo({ channelId: 7 })]); + expect(container.querySelector('[data-testid="dm-avatar-stack-7"]')).toBeNull(); + expect(container.querySelector(".dm-status")).not.toBeNull(); + expect(container.querySelector('[data-testid="dm-members-7"]')).toBeNull(); + sidebar.destroy?.(); + }); + + it("selects by channel id, not by user id", () => { + const onSelectConversation = vi.fn(); + const sidebar = mount([convo({ channelId: 77, userId: 2 })], { onSelectConversation }); + + (container.querySelector(".dm-item") as HTMLElement).click(); + expect(onSelectConversation).toHaveBeenCalledWith(77); + + sidebar.destroy?.(); + }); + + it("labels the close affordance as Leave for a group", () => { + const sidebar = mount([convo({ channelId: 7, isGroup: true, participants: [] })]); + expect(container.querySelector(".dm-close")!.getAttribute("title")).toBe("Leave group"); + sidebar.destroy?.(); + }); +}); + +// --------------------------------------------------------------------------- +// DmSidebar — mute rendering +// --------------------------------------------------------------------------- + +describe("DmSidebar — muted rows", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.querySelectorAll(".context-menu").forEach((el) => el.remove()); + }); + + function mount(conversations: DmConversation[], opts: Record = {}) { + const sidebar = createDmSidebar({ + conversations, + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + ...opts, + }); + sidebar.mount(container); + return sidebar; + } + + it("dims the unread badge but keeps the count", () => { + const sidebar = mount([convo({ channelId: 7, muted: true, unread: true, unreadCount: 4 })]); + const badge = container.querySelector('[data-testid="dm-unread-7"]')!; + expect(badge.textContent).toBe("4"); + expect(badge.classList.contains("muted")).toBe(true); + expect(container.querySelector(".dm-item")!.classList.contains("muted")).toBe(true); + sidebar.destroy?.(); + }); + + // The load-bearing rule: a mute silences chatter, not things addressed to + // the reader, so the mention badge is never dimmed. + it("leaves the mention badge undimmed in a muted conversation", () => { + const sidebar = mount([ + convo({ channelId: 7, muted: true, unread: true, unreadCount: 4, mentionCount: 2 }), + ]); + const badge = container.querySelector('[data-testid="dm-mentions-7"]')!; + expect(badge.textContent).toBe("2"); + expect(badge.classList.contains("muted")).toBe(false); + sidebar.destroy?.(); + }); + + it("offers Mute in the context menu and reports the channel id", () => { + const onToggleMute = vi.fn(); + const sidebar = mount([convo({ channelId: 7 })], { onToggleMute }); + + const item = container.querySelector(".dm-item") as HTMLElement; + item.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, clientX: 5, clientY: 5 })); + + const entry = document.querySelector('[data-testid="dm-mute-7"]') as HTMLElement; + expect(entry.textContent).toBe("Mute Conversation"); + entry.click(); + expect(onToggleMute).toHaveBeenCalledWith(7); + + sidebar.destroy?.(); + }); + + it("says Unmute when the conversation is already muted", () => { + const sidebar = mount([convo({ channelId: 7, muted: true })], { onToggleMute: vi.fn() }); + (container.querySelector(".dm-item") as HTMLElement).dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, clientX: 5, clientY: 5 }), + ); + expect(document.querySelector('[data-testid="dm-mute-7"]')!.textContent).toBe( + "Unmute Conversation", + ); + sidebar.destroy?.(); + }); + + it("offers Rename only for a group", () => { + const sidebar = mount([convo({ channelId: 7 })], { + onRenameGroup: vi.fn(), + onToggleMute: vi.fn(), + }); + (container.querySelector(".dm-item") as HTMLElement).dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, clientX: 5, clientY: 5 }), + ); + expect(document.querySelector('[data-testid="dm-rename-7"]')).toBeNull(); + sidebar.destroy?.(); + }); + + it("offers Rename and Leave Group for a group", () => { + const onRenameGroup = vi.fn(); + const onCloseDm = vi.fn(); + const sidebar = mount([convo({ channelId: 7, isGroup: true, participants: [] })], { + onRenameGroup, + onCloseDm, + onToggleMute: vi.fn(), + }); + (container.querySelector(".dm-item") as HTMLElement).dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, clientX: 5, clientY: 5 }), + ); + + (document.querySelector('[data-testid="dm-rename-7"]') as HTMLElement).click(); + expect(onRenameGroup).toHaveBeenCalledWith(7); + + (container.querySelector(".dm-item") as HTMLElement).dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, clientX: 5, clientY: 5 }), + ); + const leave = document.querySelector('[data-testid="dm-close-7"]') as HTMLElement; + expect(leave.textContent).toBe("Leave Group"); + leave.click(); + expect(onCloseDm).toHaveBeenCalledWith(7); + + sidebar.destroy?.(); + }); +}); + +// --------------------------------------------------------------------------- +// IncomingCallBanner +// --------------------------------------------------------------------------- + +describe("IncomingCallBanner", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("stays hidden until a ring arrives", () => { + const banner = createIncomingCallBanner({ onAccept: vi.fn(), onDecline: vi.fn() }); + banner.mount(container); + const el = container.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement; + expect(el.style.display).toBe("none"); + banner.destroy?.(); + }); + + it("shows the caller's name when ringing", () => { + const banner = createIncomingCallBanner({ onAccept: vi.fn(), onDecline: vi.fn() }); + banner.mount(container); + banner.setRing({ channelId: 3, fromUserId: 9, fromUsername: "alice" }); + + const el = container.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement; + expect(el.style.display).toBe(""); + expect(container.querySelector('[data-testid="incoming-call-title"]')!.textContent).toBe( + "alice is calling", + ); + banner.destroy?.(); + }); + + // The username is user-controlled; it must land as text, never as markup. + it("renders a hostile username as text", () => { + const banner = createIncomingCallBanner({ onAccept: vi.fn(), onDecline: vi.fn() }); + banner.mount(container); + banner.setRing({ + channelId: 3, + fromUserId: 9, + fromUsername: "", + }); + + const title = container.querySelector('[data-testid="incoming-call-title"]')!; + expect(title.querySelector("img")).toBeNull(); + expect(title.textContent).toContain(""); + banner.destroy?.(); + }); + + it("hides again when the ring clears", () => { + const banner = createIncomingCallBanner({ onAccept: vi.fn(), onDecline: vi.fn() }); + banner.mount(container); + banner.setRing({ channelId: 3, fromUserId: 9, fromUsername: "alice" }); + banner.setRing(null); + + const el = container.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement; + expect(el.style.display).toBe("none"); + banner.destroy?.(); + }); + + it("reports accept and decline", () => { + const onAccept = vi.fn(); + const onDecline = vi.fn(); + const banner = createIncomingCallBanner({ onAccept, onDecline }); + banner.mount(container); + banner.setRing({ channelId: 3, fromUserId: 9, fromUsername: "alice" }); + + (container.querySelector('[data-testid="incoming-call-accept"]') as HTMLElement).click(); + expect(onAccept).toHaveBeenCalledOnce(); + (container.querySelector('[data-testid="incoming-call-decline"]') as HTMLElement).click(); + expect(onDecline).toHaveBeenCalledOnce(); + + banner.destroy?.(); + }); +}); diff --git a/Client/tauri-client/tests/unit/dm-sidebar.test.ts b/Client/tauri-client/tests/unit/dm-sidebar.test.ts index fc4ffb2b..70f9c246 100644 --- a/Client/tauri-client/tests/unit/dm-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/dm-sidebar.test.ts @@ -3,6 +3,7 @@ import { createDmSidebar } from "../../src/components/DmSidebar"; import type { DmConversation } from "../../src/components/DmSidebar"; const makeConvo = (overrides: Partial = {}): DmConversation => ({ + channelId: 100, userId: 1, username: "Alice", avatar: null, @@ -40,7 +41,10 @@ describe("DmSidebar", () => { sidebar.destroy?.(); }); - it("renders Friends nav item", () => { + // The Friends nav item was removed in phase 6: it was a dead entry whose + // callback was never wired, and the plan's stated option was to delete it + // rather than build a friends list. This pins the deletion. + it("does not render a Friends nav item", () => { const sidebar = createDmSidebar({ conversations: [], onSelectConversation: vi.fn(), @@ -48,24 +52,8 @@ describe("DmSidebar", () => { }); 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); + expect(container.querySelector(".dm-nav-item")).toBeNull(); + expect(container.textContent).not.toContain("Friends"); sidebar.destroy?.(); }); @@ -127,7 +115,7 @@ describe("DmSidebar", () => { it("calls onSelectConversation when a DM item is clicked", () => { const onSelectConversation = vi.fn(); const sidebar = createDmSidebar({ - conversations: [makeConvo({ userId: 42 })], + conversations: [makeConvo({ channelId: 42, userId: 42 })], onSelectConversation, onNewDm: vi.fn(), }); @@ -143,7 +131,7 @@ describe("DmSidebar", () => { it("calls onCloseDm when close button is clicked", () => { const onCloseDm = vi.fn(); const sidebar = createDmSidebar({ - conversations: [makeConvo({ userId: 42 })], + conversations: [makeConvo({ channelId: 42, userId: 42 })], onSelectConversation: vi.fn(), onNewDm: vi.fn(), onCloseDm, @@ -280,23 +268,6 @@ describe("DmSidebar", () => { sidebar.destroy?.(); }); - it("calls onFriendsClick when Friends nav item is clicked", () => { - const onFriendsClick = vi.fn(); - const sidebar = createDmSidebar({ - conversations: [], - onSelectConversation: vi.fn(), - onNewDm: vi.fn(), - onFriendsClick, - }); - sidebar.mount(container); - - const friendsNav = container.querySelector(".dm-nav-item") as HTMLDivElement; - friendsNav.click(); - expect(onFriendsClick).toHaveBeenCalledOnce(); - - sidebar.destroy?.(); - }); - it("applies correct status color to DM status dot", () => { const sidebar = createDmSidebar({ conversations: [ @@ -325,7 +296,7 @@ describe("DmSidebar", () => { const onSelectConversation = vi.fn(); const onCloseDm = vi.fn(); const sidebar = createDmSidebar({ - conversations: [makeConvo({ userId: 42 })], + conversations: [makeConvo({ channelId: 42, userId: 42 })], onSelectConversation, onNewDm: vi.fn(), onCloseDm, @@ -438,3 +409,71 @@ describe("DmSidebar", () => { sidebar.destroy?.(); }); }); + +// ── Unread / mention badges ──────────────────────────────────────────────── +// +// A DM used to show only a dot, which said "something happened" but not how +// much — and never distinguished a mention from ordinary traffic. + +describe("DmSidebar — unread and mention badges", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function mountWith(convo: DmConversation): void { + const sidebar = createDmSidebar({ + conversations: [convo], + onSelectConversation: vi.fn(), + onNewDm: vi.fn(), + }); + sidebar.mount(container); + } + + it("renders the unread count, not a bare dot", () => { + mountWith(makeConvo({ channelId: 7, userId: 7, unread: true, unreadCount: 4 })); + + const badge = container.querySelector('[data-testid="dm-unread-7"]'); + expect(badge?.textContent).toBe("4"); + expect(container.querySelector(".dm-unread")).toBeNull(); + }); + + it("renders a mention badge instead of the unread badge", () => { + mountWith( + makeConvo({ channelId: 7, userId: 7, unread: true, unreadCount: 5, mentionCount: 2 }), + ); + + expect(container.querySelector('[data-testid="dm-mentions-7"]')?.textContent).toBe("2"); + expect(container.querySelector('[data-testid="dm-unread-7"]')).toBeNull(); + }); + + it("pluralises the badge tooltips", () => { + mountWith(makeConvo({ channelId: 7, userId: 7, unread: true, unreadCount: 1 })); + expect((container.querySelector('[data-testid="dm-unread-7"]') as HTMLElement).title).toBe( + "1 unread message", + ); + }); + + it("falls back to the dot when the payload carries no counts", () => { + mountWith(makeConvo({ channelId: 7, userId: 7, unread: true })); + + expect(container.querySelector(".dm-unread")).not.toBeNull(); + expect(container.querySelector('[data-testid="dm-unread-7"]')).toBeNull(); + }); + + it("renders no badge and no dot for a read conversation", () => { + mountWith( + makeConvo({ channelId: 7, userId: 7, unread: false, unreadCount: 0, mentionCount: 0 }), + ); + + expect(container.querySelector(".dm-unread")).toBeNull(); + expect(container.querySelector('[data-testid="dm-unread-7"]')).toBeNull(); + expect(container.querySelector('[data-testid="dm-mentions-7"]')).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/dm-store.test.ts b/Client/tauri-client/tests/unit/dm-store.test.ts index 88160427..735362cf 100644 --- a/Client/tauri-client/tests/unit/dm-store.test.ts +++ b/Client/tauri-client/tests/unit/dm-store.test.ts @@ -14,10 +14,14 @@ function makeDm(overrides: Partial = {}): DmChannel { return { channelId: 100, recipient: { id: 1, username: "alice", avatar: "", status: "online" }, + participants: [{ id: 1, username: "alice", avatar: "", status: "online" }], + name: "", + isGroup: false, lastMessageId: null, lastMessage: "", lastMessageAt: "", unreadCount: 0, + mentionCount: 0, ...overrides, }; } diff --git a/Client/tauri-client/tests/unit/drag-reorder.test.ts b/Client/tauri-client/tests/unit/drag-reorder.test.ts index 558d6939..1779f0cb 100644 --- a/Client/tauri-client/tests/unit/drag-reorder.test.ts +++ b/Client/tauri-client/tests/unit/drag-reorder.test.ts @@ -45,9 +45,14 @@ function makeCh(id: number, position: number, name = `ch-${id}`): Channel { category: null, position, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }; } diff --git a/Client/tauri-client/tests/unit/edit-channel-modal.test.ts b/Client/tauri-client/tests/unit/edit-channel-modal.test.ts index 4b201c5f..f11dde7f 100644 --- a/Client/tauri-client/tests/unit/edit-channel-modal.test.ts +++ b/Client/tauri-client/tests/unit/edit-channel-modal.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createEditChannelModal } from "@components/EditChannelModal"; -import type { EditChannelModalOptions } from "@components/EditChannelModal"; +import { + createEditChannelModal, + clampVoiceLimit, + formatSlowMode, + MAX_SLOW_MODE_SECONDS, + MAX_VOICE_LIMIT, +} from "@components/EditChannelModal"; +import type { EditChannelModalOptions, EditChannelData } from "@components/EditChannelModal"; +import { setChannels } from "@stores/channels.store"; describe("EditChannelModal", () => { let container: HTMLDivElement; @@ -20,8 +27,15 @@ describe("EditChannelModal", () => { channelId: 1, channelName: "general", channelType: "text", + channelTopic: overrides?.channelTopic, + channelCategory: overrides?.channelCategory, + channelSlowMode: overrides?.channelSlowMode, + channelNsfw: overrides?.channelNsfw, + channelVoiceMaxUsers: overrides?.channelVoiceMaxUsers, + channelVoiceMaxVideo: overrides?.channelVoiceMaxVideo, onSave: overrides?.onSave ?? vi.fn(async () => {}), onClose: overrides?.onClose ?? vi.fn(), + ...(overrides?.channelType !== undefined ? { channelType: overrides.channelType } : {}), }; const modal = createEditChannelModal(options); modal.mount(container); @@ -83,7 +97,98 @@ describe("EditChannelModal", () => { saveBtn.click(); await vi.waitFor(() => { - expect(onSave).toHaveBeenCalledWith({ name: "renamed-channel" }); + expect(onSave).toHaveBeenCalledWith({ + name: "renamed-channel", + topic: "", + category: "", + slow_mode: 0, + nsfw: false, + }); + }); + modal.destroy?.(); + }); + + it("pre-fills the topic input and includes the edited topic in onSave", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave, channelTopic: "old topic" }); + const topicInput = container.querySelector( + "[data-testid='edit-channel-topic-input']", + ) as HTMLInputElement; + expect(topicInput.value).toBe("old topic"); + topicInput.value = " new topic "; + + const saveBtn = container.querySelector( + "[data-testid='edit-channel-submit']", + ) as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith({ + name: "general", + topic: "new topic", + category: "", + slow_mode: 0, + nsfw: false, + }); + }); + modal.destroy?.(); + }); + + it("pre-fills the category input and includes the edited category in onSave", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave, channelCategory: "Chat" }); + const categoryInput = container.querySelector( + "[data-testid='edit-channel-category-input']", + ) as HTMLInputElement; + expect(categoryInput.value).toBe("Chat"); + categoryInput.value = " Gaming "; + + (container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith({ + name: "general", + topic: "", + category: "Gaming", + slow_mode: 0, + nsfw: false, + }); + }); + modal.destroy?.(); + }); + + it("suggests the categories already in use via a datalist", () => { + setChannels([ + { id: 1, name: "general", type: "text", category: "Chat", position: 0 }, + { id: 2, name: "lounge", type: "voice", category: "Gaming", position: 1 }, + ]); + const { modal } = makeModal({ channelCategory: "Chat" }); + const values = Array.from( + container.querySelector("#edit-channel-categories")?.querySelectorAll("option") ?? [], + ).map((o) => o.getAttribute("value")); + expect(values).toEqual(["Chat", "Gaming"]); + modal.destroy?.(); + }); + + // Blanking the category is how a channel becomes uncategorized — an empty + // string must reach onSave rather than being dropped as "unchanged". + it("submits an emptied category", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave, channelCategory: "Chat" }); + ( + container.querySelector("[data-testid='edit-channel-category-input']") as HTMLInputElement + ).value = ""; + + (container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith({ + name: "general", + topic: "", + category: "", + slow_mode: 0, + nsfw: false, + }); }); modal.destroy?.(); }); @@ -218,4 +323,264 @@ describe("EditChannelModal", () => { expect(input.classList.contains("error")).toBe(true); modal.destroy?.(); }); + // ─── Slow mode ───────────────────────────────────────────────────────────── + + describe("slow mode", () => { + function slowSelect(): HTMLSelectElement { + return container.querySelector( + "[data-testid='edit-channel-slowmode-select']", + ) as HTMLSelectElement; + } + + it("offers friendly presets from Off to the server's 6-hour ceiling", () => { + const { modal } = makeModal(); + const options = Array.from(slowSelect().options); + expect(options[0]?.textContent).toBe("Off"); + expect(options[0]?.value).toBe("0"); + expect(options.at(-1)?.value).toBe(String(MAX_SLOW_MODE_SECONDS)); + // Nothing may exceed what the server accepts, or saving 400s. + for (const opt of options) { + expect(Number(opt.value)).toBeLessThanOrEqual(MAX_SLOW_MODE_SECONDS); + expect(Number(opt.value)).toBeGreaterThanOrEqual(0); + } + modal.destroy?.(); + }); + + it("pre-selects the channel's stored cooldown", () => { + const { modal } = makeModal({ channelSlowMode: 300 }); + expect(slowSelect().value).toBe("300"); + modal.destroy?.(); + }); + + it("keeps an off-preset stored value as its own selected option", () => { + // The admin panel offers a free number field, so a channel can legally + // carry a value the presets do not name. Rounding it to a neighbour + // would change the channel just by opening the modal. + const { modal } = makeModal({ channelSlowMode: 47 }); + expect(slowSelect().value).toBe("47"); + expect(Array.from(slowSelect().options).some((o) => o.value === "47")).toBe(true); + modal.destroy?.(); + }); + + it("sends the selected cooldown to onSave", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave, channelSlowMode: 0 }); + slowSelect().value = "600"; + + (container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ slow_mode: 600 })); + }); + modal.destroy?.(); + }); + + it("clamps a stored value above the ceiling onto a legal option", () => { + const { modal } = makeModal({ channelSlowMode: 99999 }); + expect(Number(slowSelect().value)).toBe(MAX_SLOW_MODE_SECONDS); + modal.destroy?.(); + }); + }); + + // ─── NSFW flag ───────────────────────────────────────────────────────────── + + describe("NSFW flag", () => { + function nsfwBox(): HTMLInputElement { + return container.querySelector( + "[data-testid='edit-channel-nsfw-checkbox']", + ) as HTMLInputElement; + } + + it("is unchecked for an unflagged channel", () => { + const { modal } = makeModal(); + expect(nsfwBox().checked).toBe(false); + modal.destroy?.(); + }); + + it("pre-fills from the channel's stored flag", () => { + const { modal } = makeModal({ channelNsfw: true }); + expect(nsfwBox().checked).toBe(true); + modal.destroy?.(); + }); + + it("sends the flag to onSave when set", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave }); + nsfwBox().checked = true; + + (container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ nsfw: true })); + }); + modal.destroy?.(); + }); + + // Clearing the flag must be as expressible as setting it: the PATCH keeps + // any field the body omits, so `false` has to be sent explicitly. + it("sends false when a flagged channel is unflagged", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave, channelNsfw: true }); + nsfwBox().checked = false; + + (container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ nsfw: false })); + }); + modal.destroy?.(); + }); + }); + + // ─── Voice limits ────────────────────────────────────────────────────────── + + describe("voice limits", () => { + it("are absent for a text channel", () => { + const { modal } = makeModal(); + expect(container.querySelector("[data-testid='edit-channel-voice-section']")).toBeNull(); + expect(container.querySelector("[data-testid='edit-channel-max-users-input']")).toBeNull(); + modal.destroy?.(); + }); + + it("are shown for a voice channel", () => { + const { modal } = makeModal({ channelType: "voice" }); + expect(container.querySelector("[data-testid='edit-channel-voice-section']")).not.toBeNull(); + modal.destroy?.(); + }); + + it("pre-fill from the channel's stored limits", () => { + const { modal } = makeModal({ + channelType: "voice", + channelVoiceMaxUsers: 8, + channelVoiceMaxVideo: 3, + }); + expect( + ( + container.querySelector( + "[data-testid='edit-channel-max-users-input']", + ) as HTMLInputElement + ).value, + ).toBe("8"); + expect( + ( + container.querySelector( + "[data-testid='edit-channel-max-video-input']", + ) as HTMLInputElement + ).value, + ).toBe("3"); + modal.destroy?.(); + }); + + it("round-trip through onSave", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ + onSave, + channelType: "voice", + channelVoiceMaxUsers: 0, + channelVoiceMaxVideo: 0, + }); + ( + container.querySelector("[data-testid='edit-channel-max-users-input']") as HTMLInputElement + ).value = "12"; + ( + container.querySelector("[data-testid='edit-channel-max-video-input']") as HTMLInputElement + ).value = "4"; + + (container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ voice_max_users: 12, voice_max_video: 4 }), + ); + }); + modal.destroy?.(); + }); + + // A text channel's PATCH must not carry the keys at all — sending 0 would + // wipe limits the row happens to hold, since the server keeps only what the + // body omits. + it("are omitted from a text channel's payload rather than sent as 0", async () => { + let payload: Record | null = null; + const onSave = vi.fn(async (data: EditChannelData) => { + payload = data as unknown as Record; + }); + const { modal } = makeModal({ onSave }); + + (container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(payload).not.toBeNull(); + }); + expect("voice_max_users" in payload!).toBe(false); + expect("voice_max_video" in payload!).toBe(false); + modal.destroy?.(); + }); + + // The max attribute is advisory — paste and keyboard both get past it — so + // the value is clamped before it reaches the API, which would 400. + it("clamp a typed value above the server's ceiling", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave, channelType: "voice" }); + ( + container.querySelector("[data-testid='edit-channel-max-users-input']") as HTMLInputElement + ).value = "5000"; + + (container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ voice_max_users: MAX_VOICE_LIMIT }), + ); + }); + modal.destroy?.(); + }); + + it("treat an emptied field as unlimited rather than NaN", async () => { + const onSave = vi.fn(async () => {}); + const { modal } = makeModal({ onSave, channelType: "voice", channelVoiceMaxUsers: 5 }); + ( + container.querySelector("[data-testid='edit-channel-max-users-input']") as HTMLInputElement + ).value = ""; + + (container.querySelector("[data-testid='edit-channel-submit']") as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ voice_max_users: 0 })); + }); + modal.destroy?.(); + }); + }); + + // ─── Pure helpers ────────────────────────────────────────────────────────── + + describe("clampVoiceLimit", () => { + it("keeps a legal value", () => { + expect(clampVoiceLimit(7)).toBe(7); + }); + it("floors negatives to 0", () => { + expect(clampVoiceLimit(-3)).toBe(0); + }); + it("caps at the server's ceiling", () => { + expect(clampVoiceLimit(1000)).toBe(MAX_VOICE_LIMIT); + }); + it("treats NaN as unlimited", () => { + expect(clampVoiceLimit(Number.NaN)).toBe(0); + }); + it("truncates a fractional value", () => { + expect(clampVoiceLimit(4.9)).toBe(4); + }); + }); + + describe("formatSlowMode", () => { + it("names a preset", () => { + expect(formatSlowMode(0)).toBe("Off"); + expect(formatSlowMode(3600)).toBe("1 hour"); + }); + it("describes an off-preset whole-minute value", () => { + expect(formatSlowMode(180)).toBe("3 minutes"); + }); + it("falls back to seconds", () => { + expect(formatSlowMode(47)).toBe("47 seconds"); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/emoji-autocomplete.test.ts b/Client/tauri-client/tests/unit/emoji-autocomplete.test.ts new file mode 100644 index 00000000..f35e27a1 --- /dev/null +++ b/Client/tauri-client/tests/unit/emoji-autocomplete.test.ts @@ -0,0 +1,386 @@ +/** + * EmojiAutocomplete — filtering across the custom and unicode sources, the + * minimum-query rule, keyboard navigation, and composer integration (including + * how it shares the composer with the @-mention popup). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("@lib/livekitSession", () => ({ + leaveVoice: vi.fn(), + switchInputDevice: vi.fn(), + switchOutputDevice: vi.fn(), + setVoiceSensitivity: vi.fn(), + setInputVolume: vi.fn(), + setOutputVolume: vi.fn(), + getSessionDebugInfo: vi.fn().mockReturnValue({}), +})); + +const { fetchImageAsDataUrlMock } = vi.hoisted(() => ({ + fetchImageAsDataUrlMock: vi.fn(() => Promise.resolve("data:image/png;base64,AAAA")), +})); +vi.mock("../../src/components/message-list/attachments", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, fetchImageAsDataUrl: fetchImageAsDataUrlMock }; +}); + +import { + createEmojiAutocomplete, + filterEmojiSuggestions, + MAX_EMOJI_SUGGESTIONS, + MIN_EMOJI_QUERY, +} from "../../src/components/EmojiAutocomplete"; +import { createMessageInput } from "../../src/components/MessageInput"; +import { emojiStore, setCustomEmoji, clearCustomEmoji } from "../../src/stores/emoji.store"; +import { membersStore } from "../../src/stores/members.store"; + +const EMOJI = [ + { id: 1, shortcode: "wave", url: "/api/v1/emoji/1/image" }, + { id: 2, shortcode: "waffle", url: "/api/v1/emoji/2/image" }, + { id: 3, shortcode: "blob_wave", url: "/api/v1/emoji/3/image" }, +]; + +beforeEach(() => { + clearCustomEmoji(); + emojiStore.flush(); + setCustomEmoji(EMOJI); + emojiStore.flush(); + membersStore.setState(() => ({ members: new Map(), typingUsers: new Map() })); +}); + +// --------------------------------------------------------------------------- +// Filtering +// --------------------------------------------------------------------------- + +describe("filterEmojiSuggestions", () => { + it("returns nothing below the minimum query length", () => { + expect(filterEmojiSuggestions("")).toEqual([]); + expect(filterEmojiSuggestions("w")).toEqual([]); + expect(MIN_EMOJI_QUERY).toBe(2); + }); + + it("offers custom emoji before unicode ones", () => { + const out = filterEmojiSuggestions("wa"); + expect(out.length).toBeGreaterThan(0); + const firstUnicode = out.findIndex((s) => s.kind === "unicode"); + const lastCustom = out.map((s) => s.kind).lastIndexOf("custom"); + expect(lastCustom).toBeGreaterThanOrEqual(0); + if (firstUnicode !== -1) expect(lastCustom).toBeLessThan(firstUnicode); + }); + + it("ranks a shortcode prefix above a shortcode substring", () => { + const labels = filterEmojiSuggestions("wa") + .filter((s) => s.kind === "custom") + .map((s) => s.label); + // waffle and wave start with "wa"; blob_wave only contains it. + expect(labels.indexOf("blob_wave")).toBeGreaterThan(labels.indexOf("wave")); + expect(labels.indexOf("blob_wave")).toBeGreaterThan(labels.indexOf("waffle")); + }); + + it("inserts :shortcode: for a custom emoji and the character for a unicode one", () => { + const custom = filterEmojiSuggestions("wave").find((s) => s.kind === "custom"); + expect(custom?.label).toBe("wave"); + expect(custom?.insert).toBe(":wave:"); + expect(custom?.char).toBeNull(); + expect(custom?.emoji?.id).toBe(1); + + const fire = filterEmojiSuggestions("fire").find((s) => s.kind === "unicode"); + expect(fire?.insert).toBe("🔥"); + expect(fire?.emoji).toBeNull(); + }); + + it("searches the unicode keyword list, not just primary names", () => { + const out = filterEmojiSuggestions("flame"); + expect(out.some((s) => s.insert === "🔥")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(filterEmojiSuggestions("WAVE").some((s) => s.insert === ":wave:")).toBe(true); + }); + + it("caps the number of rows", () => { + // "a" appears in most keyword strings; the two-character "ar" still matches + // far more than the cap. + expect(filterEmojiSuggestions("ar").length).toBeLessThanOrEqual(MAX_EMOJI_SUGGESTIONS); + }); + + it("returns nothing for a query nothing matches", () => { + expect(filterEmojiSuggestions("zzzzqqq")).toEqual([]); + }); + + it("offers no custom emoji when the server has none", () => { + clearCustomEmoji(); + emojiStore.flush(); + expect(filterEmojiSuggestions("wave").every((s) => s.kind === "unicode")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +describe("createEmojiAutocomplete", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function mount(onSelect = vi.fn(), onClose = vi.fn()) { + const ac = createEmojiAutocomplete({ onSelect, onClose }); + container.appendChild(ac.element); + return { ac, onSelect, onClose }; + } + + it("renders one row per suggestion with a preview", () => { + const { ac } = mount(); + expect(ac.setQuery("wave")).toBe(true); + const rows = ac.element.querySelectorAll(".ma-item"); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]?.querySelector(".ea-preview")).not.toBeNull(); + expect(rows[0]?.querySelector(".ea-preview img.custom-emoji")).not.toBeNull(); + expect(rows[0]?.querySelector(".ma-name")?.textContent).toBe(":wave:"); + expect(rows[0]?.querySelector(".ma-detail")?.textContent).toBe("Server emoji"); + ac.destroy(); + }); + + it("shows the character itself as the preview for unicode rows", () => { + const { ac } = mount(); + ac.setQuery("flame"); + const row = [...ac.element.querySelectorAll(".ma-item")].find( + (r) => r.querySelector(".ea-preview")?.textContent === "🔥", + ); + expect(row).toBeDefined(); + ac.destroy(); + }); + + it("setQuery returns false and renders nothing when nothing matches", () => { + const { ac } = mount(); + expect(ac.setQuery("zzzzqqq")).toBe(false); + expect(ac.element.querySelectorAll(".ma-item").length).toBe(0); + ac.destroy(); + }); + + it("selects the active row on Enter", () => { + const { ac, onSelect } = mount(); + ac.setQuery("wave"); + const ev = new KeyboardEvent("keydown", { key: "Enter", cancelable: true }); + expect(ac.handleKeydown(ev)).toBe(true); + expect(onSelect).toHaveBeenCalledWith(":wave:"); + ac.destroy(); + }); + + it("moves the active row with the arrow keys", () => { + const { ac, onSelect } = mount(); + ac.setQuery("wa"); + // Custom rows sort alphabetically, so ":waffle:" leads and ":wave:" follows. + ac.handleKeydown(new KeyboardEvent("keydown", { key: "ArrowDown", cancelable: true })); + ac.handleKeydown(new KeyboardEvent("keydown", { key: "Enter", cancelable: true })); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(":wave:"); + ac.destroy(); + }); + + it("wraps around at the ends", () => { + const { ac, onSelect } = mount(); + ac.setQuery("wa"); + const count = ac.element.querySelectorAll(".ma-item").length; + // A full lap of ArrowDown lands back on the first row. + for (let i = 0; i < count; i++) { + ac.handleKeydown(new KeyboardEvent("keydown", { key: "ArrowDown", cancelable: true })); + } + ac.handleKeydown(new KeyboardEvent("keydown", { key: "Enter", cancelable: true })); + expect(onSelect).toHaveBeenCalledWith(":waffle:"); + ac.destroy(); + }); + + it("ArrowUp from the first row wraps to the last", () => { + const { ac, onSelect } = mount(); + ac.setQuery("wa"); + const labels = [...ac.element.querySelectorAll(".ma-item .ma-name")].map((n) => n.textContent); + ac.handleKeydown(new KeyboardEvent("keydown", { key: "ArrowUp", cancelable: true })); + ac.handleKeydown(new KeyboardEvent("keydown", { key: "Enter", cancelable: true })); + const last = labels[labels.length - 1]; + expect(onSelect).toHaveBeenCalledTimes(1); + // Custom rows render as ":name:", which is also what they insert. + if (last?.startsWith(":")) expect(onSelect).toHaveBeenCalledWith(last); + ac.destroy(); + }); + + it("closes on Escape", () => { + const { ac, onClose } = mount(); + ac.setQuery("wave"); + expect( + ac.handleKeydown(new KeyboardEvent("keydown", { key: "Escape", cancelable: true })), + ).toBe(true); + expect(onClose).toHaveBeenCalledOnce(); + ac.destroy(); + }); + + it("consumes no keys while empty", () => { + const { ac } = mount(); + ac.setQuery("zzzzqqq"); + expect(ac.handleKeydown(new KeyboardEvent("keydown", { key: "Enter", cancelable: true }))).toBe( + false, + ); + ac.destroy(); + }); + + it("selects on mousedown so the textarea keeps focus", () => { + const { ac, onSelect } = mount(); + ac.setQuery("wave"); + const row = ac.element.querySelector(".ma-item") as HTMLElement; + const ev = new MouseEvent("mousedown", { bubbles: true, cancelable: true }); + row.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + expect(onSelect).toHaveBeenCalledWith(":wave:"); + ac.destroy(); + }); + + it("destroy detaches the listeners", () => { + const { ac, onSelect } = mount(); + ac.setQuery("wave"); + const row = ac.element.querySelector(".ma-item") as HTMLElement; + ac.destroy(); + row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Composer integration +// --------------------------------------------------------------------------- + +describe("composer :shortcode integration", () => { + let container: HTMLDivElement; + let input: ReturnType; + let onSend: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + onSend = vi.fn(); + input = createMessageInput({ + channelId: 1, + channelName: "general", + onSend, + onTyping: vi.fn(), + onEditMessage: vi.fn(), + }); + input.mount(container); + }); + + afterEach(() => { + input.destroy?.(); + container.remove(); + }); + + function textarea(): HTMLTextAreaElement { + return container.querySelector("textarea")!; + } + + function type(value: string): void { + const ta = textarea(); + ta.value = value; + ta.selectionStart = value.length; + ta.selectionEnd = value.length; + ta.dispatchEvent(new Event("input", { bubbles: true })); + } + + function popupEl(): HTMLElement | null { + return container.querySelector(".emoji-autocomplete"); + } + + function press(k: string): void { + textarea().dispatchEvent( + new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }), + ); + } + + it("opens after a colon plus two characters", () => { + type("hello :w"); + expect(popupEl()).toBeNull(); + type("hello :wa"); + expect(popupEl()).not.toBeNull(); + }); + + it("does not open for a colon inside a word", () => { + type("note:wa"); + expect(popupEl()).toBeNull(); + }); + + it("does not open for a URL scheme", () => { + type("https://ex"); + expect(popupEl()).toBeNull(); + }); + + it("closes once the query matches nothing", () => { + type(":wa"); + expect(popupEl()).not.toBeNull(); + type(":wazzzqqq"); + expect(popupEl()).toBeNull(); + }); + + it("closes when the caret leaves the token", () => { + type(":wa"); + type(":wa hello"); + expect(popupEl()).toBeNull(); + }); + + it("inserts the shortcode and a trailing space instead of sending", () => { + type("hey :wave"); + press("Enter"); + expect(textarea().value).toBe("hey :wave: "); + expect(onSend).not.toHaveBeenCalled(); + expect(popupEl()).toBeNull(); + }); + + it("inserts the unicode character for a unicode row", () => { + type("hey :flame"); + press("Enter"); + expect(textarea().value).toBe("hey 🔥 "); + }); + + it("Escape closes the popup without sending", () => { + type(":wave"); + press("Escape"); + expect(popupEl()).toBeNull(); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("does not open while the composer is disabled", () => { + input.setDisabled("read-only"); + type(":wave"); + expect(popupEl()).toBeNull(); + }); + + it("closes on blur", () => { + type(":wave"); + expect(popupEl()).not.toBeNull(); + textarea().dispatchEvent(new FocusEvent("blur")); + expect(popupEl()).toBeNull(); + }); + + it("yields to the @-mention popup rather than stacking on it", () => { + membersStore.setState(() => ({ + members: new Map([ + [ + 1, + { id: 1, username: "wave_guy", avatar: null, role: "member", status: "online" as const }, + ], + ]), + typingUsers: new Map(), + })); + type("@wa"); + expect( + container.querySelector(".mention-autocomplete:not(.emoji-autocomplete)"), + ).not.toBeNull(); + expect(popupEl()).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/emoji-picker.test.ts b/Client/tauri-client/tests/unit/emoji-picker.test.ts index 53a78747..a57060a2 100644 --- a/Client/tauri-client/tests/unit/emoji-picker.test.ts +++ b/Client/tauri-client/tests/unit/emoji-picker.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createEmojiPicker } from "@components/EmojiPicker"; import type { EmojiPickerOptions } from "@components/EmojiPicker"; +import { emojiStore, setCustomEmoji, clearCustomEmoji } from "@stores/emoji.store"; describe("EmojiPicker", () => { let container: HTMLDivElement; @@ -9,6 +10,8 @@ describe("EmojiPicker", () => { container = document.createElement("div"); document.body.appendChild(container); localStorage.clear(); + clearCustomEmoji(); + emojiStore.flush(); }); afterEach(() => { @@ -116,14 +119,67 @@ describe("EmojiPicker", () => { picker.destroy(); }); - it("renders custom emoji when provided", () => { + it("renders custom emoji under a Server category", () => { const { picker } = makePicker({ - customEmoji: [{ shortcode: "test_emoji", url: "https://example.com/emoji.png" }], + customEmoji: [{ shortcode: "test_emoji", url: "/api/v1/emoji/1/image" }], }); const labels = picker.element.querySelectorAll(".ep-category-label"); const labelTexts = Array.from(labels).map((l) => l.textContent); - expect(labelTexts).toContain("Custom"); + expect(labelTexts).toContain("Server"); + picker.destroy(); + }); + + it("shows no Server category when the server has no custom emoji", () => { + const { picker } = makePicker(); + const labelTexts = Array.from(picker.element.querySelectorAll(".ep-category-label")).map( + (l) => l.textContent, + ); + expect(labelTexts).not.toContain("Server"); + picker.destroy(); + }); + + it("renders a resolvable custom emoji as an image, not as its token text", () => { + setCustomEmoji([{ id: 1, shortcode: "test_emoji", url: "/api/v1/emoji/1/image" }]); + emojiStore.flush(); + const { picker } = makePicker({ + customEmoji: [{ shortcode: "test_emoji", url: "/api/v1/emoji/1/image" }], + }); + + const cell = picker.element.querySelector(".ep-emoji-custom"); + expect(cell).not.toBeNull(); + expect(cell?.querySelector("img.custom-emoji")?.getAttribute("data-shortcode")).toBe( + "test_emoji", + ); + expect(cell?.textContent).toBe(""); + picker.destroy(); + }); + + it("selecting a custom emoji inserts its :shortcode: token", () => { + setCustomEmoji([{ id: 1, shortcode: "test_emoji", url: "/api/v1/emoji/1/image" }]); + emojiStore.flush(); + const onSelect = vi.fn(); + const { picker } = makePicker({ + onSelect, + customEmoji: [{ shortcode: "test_emoji", url: "/api/v1/emoji/1/image" }], + }); + + (picker.element.querySelector(".ep-emoji-custom") as HTMLElement).click(); + expect(onSelect).toHaveBeenCalledWith(":test_emoji:"); + picker.destroy(); + }); + + it("falls back to the token text when the shortcode does not resolve", () => { + clearCustomEmoji(); + emojiStore.flush(); + const { picker } = makePicker({ + customEmoji: [{ shortcode: "ghost_emoji", url: "/api/v1/emoji/9/image" }], + }); + + const cells = Array.from(picker.element.querySelectorAll(".ep-emoji")); + const ghost = cells.find((c) => c.textContent === ":ghost_emoji:"); + expect(ghost).toBeDefined(); + expect(ghost?.querySelector("img")).toBeNull(); picker.destroy(); }); diff --git a/Client/tauri-client/tests/unit/keybinds-tab.test.ts b/Client/tauri-client/tests/unit/keybinds-tab.test.ts index d3d93844..10034ff6 100644 --- a/Client/tauri-client/tests/unit/keybinds-tab.test.ts +++ b/Client/tauri-client/tests/unit/keybinds-tab.test.ts @@ -42,8 +42,8 @@ describe("KeybindsTab", () => { it("renders Push to Talk keybind row", () => { const el = buildKeybindsTab(new AbortController().signal); const rows = el.querySelectorAll(".keybind-row"); - // 1 PTT + 3 Navigation + 3 Communication + 2 Messages = 9 - expect(rows.length).toBe(9); + // 1 PTT + 3 Navigation + 3 Communication + 5 Messages = 12 + expect(rows.length).toBe(12); const pttLabel = rows[0]!.querySelector(".setting-label"); expect(pttLabel!.textContent).toBe("Push to Talk"); }); @@ -224,6 +224,15 @@ describe("KeybindsTab", () => { expect(labels).toContain("Edit Last Message"); }); + it("renders the composer formatting keybinds and explains the Ctrl+U overlap", () => { + const el = buildKeybindsTab(new AbortController().signal); + const labels = Array.from(el.querySelectorAll(".setting-label")).map((l) => l.textContent); + expect(labels).toContain("Bold"); + expect(labels).toContain("Italic"); + expect(labels).toContain("Underline"); + expect(el.textContent).toContain("Formatting shortcuts wrap the selected text"); + }); + it("renders Toggle Mute, Toggle Deafen, Toggle Camera keybinds", () => { const el = buildKeybindsTab(new AbortController().signal); const labels = Array.from(el.querySelectorAll(".setting-label")).map((l) => l.textContent); diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index 2087f631..50e03140 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -448,12 +448,14 @@ describe("LiveKitSession", () => { it("does nothing when no active room", async () => { // Should not throw await session.switchInputDevice("device-1"); + expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled(); }); }); describe("switchOutputDevice", () => { it("does nothing when no active room", async () => { await session.switchOutputDevice("device-1"); + expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled(); }); }); @@ -762,8 +764,21 @@ describe("LiveKitSession", () => { describe("handleVoiceTokenRefresh", () => { it("stores the token and restarts the timer", () => { + (session as any)._state = { + type: "connected", + room: mockRoom, + channelId: 7, + latestToken: "old-token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + }; + + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); session.handleVoiceTokenRefresh("new-token"); - // No throw — timer is started internally + + expect((session as any)._state.latestToken).toBe("new-token"); + // Timer restarted: the 23h refresh timer is re-armed on every refresh. + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 23 * 60 * 60 * 1000); }); it("handles undefined token", () => { diff --git a/Client/tauri-client/tests/unit/log-persistence.test.ts b/Client/tauri-client/tests/unit/log-persistence.test.ts index 9793785d..29a68a59 100644 --- a/Client/tauri-client/tests/unit/log-persistence.test.ts +++ b/Client/tauri-client/tests/unit/log-persistence.test.ts @@ -14,6 +14,8 @@ const { mockReadTextFile, mockAddLogListener, mockGetLogBuffer, + mockLoggerError, + mockLoggerWarn, } = vi.hoisted(() => ({ mockAppLogDir: vi.fn().mockResolvedValue("/mock/logs"), mockJoin: vi.fn((...parts: string[]) => parts.join("/")), @@ -25,6 +27,10 @@ const { mockReadTextFile: vi.fn().mockResolvedValue(""), mockAddLogListener: vi.fn(), mockGetLogBuffer: vi.fn(() => [] as unknown[]), + // Shared across freshImport() calls so tests can spy on the errors/warnings + // the module's internal logger reports, not just its side effects. + mockLoggerError: vi.fn(), + mockLoggerWarn: vi.fn(), })); vi.mock("@tauri-apps/api/path", () => ({ @@ -47,8 +53,8 @@ vi.mock("@lib/logger", () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), + warn: mockLoggerWarn, + error: mockLoggerError, }), })); @@ -106,6 +112,8 @@ describe("log persistence", () => { mockReadTextFile.mockReset().mockResolvedValue(""); mockAddLogListener.mockReset(); mockGetLogBuffer.mockReset().mockReturnValue([]); + mockLoggerError.mockReset(); + mockLoggerWarn.mockReset(); }); afterEach(() => { @@ -642,15 +650,42 @@ describe("log persistence", () => { getListener()!(makeEntry()); await vi.advanceTimersByTimeAsync(2000); + expect(mockWriteTextFile).toHaveBeenCalledTimes(1); - // After flush completes, activeFlush should be null. - // clearPendingPersistedLogs should resolve immediately. - await clearPendingPersistedLogs(); - // No hanging — test completes + // If activeFlush still referenced the first (already-settled) flush, + // a brand-new in-flight flush would never be tracked and + // clearPendingPersistedLogs would resolve too early instead of + // waiting on it. Force a second, slow flush and confirm it's the one + // actually awaited — proving activeFlush was cleared after the first + // flush and re-armed for the second. + let resolveSecondWrite: (() => void) | null = null; + mockWriteTextFile.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondWrite = resolve; + }), + ); + + getListener()!(makeEntry({ message: "second" })); + await vi.advanceTimersByTimeAsync(2000); + expect(mockWriteTextFile).toHaveBeenCalledTimes(2); + + let settled = false; + const clearPromise = clearPendingPersistedLogs().then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + resolveSecondWrite!(); + await clearPromise; + expect(settled).toBe(true); }); it("clears activeFlush after failed flush", async () => { - mockWriteTextFile.mockRejectedValueOnce(new Error("write error")); + const firstError = new Error("write error"); + mockWriteTextFile.mockRejectedValueOnce(firstError); const { getListener } = captureListener(); const { initLogPersistence, clearPendingPersistedLogs } = await freshImport(); await initLogPersistence(); @@ -658,8 +693,33 @@ describe("log persistence", () => { getListener()!(makeEntry()); await vi.advanceTimersByTimeAsync(2000); - // Even after failure, activeFlush should be cleared - await clearPendingPersistedLogs(); + // The failure was caught and logged internally, not left dangling. + expect(mockLoggerError).toHaveBeenCalledWith("flush failed", firstError); + + // Same proof as the success case: a fresh in-flight flush must be + // tracked (not swallowed by a stale reference to the failed one). + let resolveSecondWrite: (() => void) | null = null; + mockWriteTextFile.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondWrite = resolve; + }), + ); + + getListener()!(makeEntry({ message: "second" })); + await vi.advanceTimersByTimeAsync(2000); + + let settled = false; + const clearPromise = clearPendingPersistedLogs().then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + resolveSecondWrite!(); + await clearPromise; + expect(settled).toBe(true); }); }); @@ -743,13 +803,18 @@ describe("log persistence", () => { // Add an entry, then make writeTextFile fail getListener()!(makeEntry()); - mockWriteTextFile.mockRejectedValueOnce(new Error("final write failed")); + const forcedError = new Error("final write failed"); + mockWriteTextFile.mockRejectedValueOnce(forcedError); // Cleanup should not throw even if flushBuffer fails cleanup(); // Let any pending microtasks settle await vi.advanceTimersByTimeAsync(0); + + // The forced write failure must have been caught and logged, not + // swallowed silently or left to crash the app during teardown. + expect(mockLoggerError).toHaveBeenCalledWith("flush failed", forcedError); }); }); }); diff --git a/Client/tauri-client/tests/unit/markdown-parser.property.test.ts b/Client/tauri-client/tests/unit/markdown-parser.property.test.ts new file mode 100644 index 00000000..4b9bf4bb --- /dev/null +++ b/Client/tauri-client/tests/unit/markdown-parser.property.test.ts @@ -0,0 +1,251 @@ +/** + * Property tests for the markdown tokenizer (markdown.ts) and the + * XSS-safe DOM renderer built on top of it (content-parser.ts). + * + * These are fuzz-style invariants, not example-based checks: + * - the parsers never throw on arbitrary input + * - the DOM they build never contains a ", + "[click](javascript:alert(1))", + "[click](JaVaScRiPt:alert(1))", + "[click](data:text/html,)", + "[click](vbscript:msgbox(1))", + "", + 'x', + "javascript://alert(1)", + "****", + "[xss]( javascript:alert(1) )", + ); + fc.assert( + fc.property(xssShapes, (s) => { + const host = document.createElement("div"); + expect(() => host.appendChild(renderInlineContent(s))).not.toThrow(); + assertDomIsSafe(host); + }), + { numRuns: 10 }, + ); + }); +}); + +describe("bounded time on pathological input", () => { + const BUDGET_MS = 3000; + + it("a long run of unmatched '[' stays within budget", () => { + const input = "[".repeat(5000); + const start = Date.now(); + expect(() => renderMessageContent(input)).not.toThrow(); + expect(Date.now() - start).toBeLessThan(BUDGET_MS); + }); + + it("a long run of unmatched '*' stays within budget", () => { + const input = "*".repeat(5000); + const start = Date.now(); + expect(() => renderMessageContent(input)).not.toThrow(); + expect(Date.now() - start).toBeLessThan(BUDGET_MS); + }); + + it("a long run of unmatched '(' stays within budget", () => { + const input = "[x](".repeat(5000); + const start = Date.now(); + expect(() => renderMessageContent(input)).not.toThrow(); + expect(Date.now() - start).toBeLessThan(BUDGET_MS); + }); + + it("deeply nested emphasis stays within budget (MAX_DEPTH guards recursion)", () => { + const input = "*".repeat(2000) + "x" + "*".repeat(2000); + const start = Date.now(); + expect(() => renderMessageContent(input)).not.toThrow(); + expect(Date.now() - start).toBeLessThan(BUDGET_MS); + }); + + it("a long mix of brackets and parens stays within budget", () => { + const input = "[".repeat(2500) + "(".repeat(2500); + const start = Date.now(); + expect(() => renderMessageContent(input)).not.toThrow(); + expect(Date.now() - start).toBeLessThan(BUDGET_MS); + }); +}); diff --git a/Client/tauri-client/tests/unit/member-list.test.ts b/Client/tauri-client/tests/unit/member-list.test.ts index 969fc912..28a01415 100644 --- a/Client/tauri-client/tests/unit/member-list.test.ts +++ b/Client/tauri-client/tests/unit/member-list.test.ts @@ -5,13 +5,16 @@ import { membersStore, updatePresence, updateMemberRole } from "@stores/members. import type { Member } from "@stores/members.store"; import { authStore } from "@stores/auth.store"; import { channelsStore, setRoles } from "@stores/channels.store"; -import type { UserStatus } from "../../src/lib/types"; +import { Permission, type UserStatus } from "../../src/lib/types"; function resetStore(): void { membersStore.setState(() => ({ members: new Map(), typingUsers: new Map(), })); + // Role list drives member-list grouping/colors — reset so tests that seed + // roles don't leak into the ones asserting the fallback groups. + setRoles([]); } function makeMember(overrides: Partial & { id: number; username: string }): Member { @@ -46,6 +49,7 @@ function defaultOpts(): MemberListOptions { onKick: vi.fn().mockResolvedValue(undefined), onBan: vi.fn().mockResolvedValue(undefined), onChangeRole: vi.fn().mockResolvedValue(undefined), + onToggleBlock: vi.fn().mockResolvedValue(undefined), }; } @@ -253,13 +257,77 @@ describe("MemberList", () => { document.body.querySelector(".context-menu")?.remove(); }); - it("context menu does not appear for non-admin/non-owner roles", () => { + it("re-renders groups when a roles_update replaces the role list", () => { + // Role management makes the list mutable mid-session. Before this the list + // only re-rendered on a member change, so a rename/recolor/delete sat + // invisible until unrelated traffic arrived. + setRoles([ + { id: 1, name: "Owner", color: "#E74C3C", permissions: 0 }, + { id: 2, name: "Staff", color: "#00FF00", permissions: 0 }, + ]); + setTestMembers([ + makeMember({ id: 2, username: "Stan", role: "staff", status: "online" as UserStatus }), + ]); + memberList.mount(container); + expect( + Array.from(container.querySelectorAll(".member-role-group")).map((h) => h.textContent), + ).toContainEqual(expect.stringContaining("STAFF")); + + setRoles([ + { id: 1, name: "Owner", color: "#E74C3C", permissions: 0, position: 100 }, + { id: 2, name: "Staff", color: "#0000FF", permissions: 0, position: 50 }, + ]); + // Store notifications are batched onto a microtask. + channelsStore.flush(); + + const stanName = container.querySelector('[data-testid="member-2"] .mi-name'); + expect((stanName as HTMLSpanElement).style.color).toBe("rgb(0, 0, 255)"); + }); + + it("renders groups for custom server roles, colored by the server's role color", () => { + setRoles([ + { id: 1, name: "Owner", color: "#E74C3C", permissions: 0 }, + { id: 2, name: "Staff", color: "#00FF00", permissions: 0 }, + ]); + setTestMembers([ + makeMember({ id: 1, username: "Alice", role: "owner", status: "online" as UserStatus }), + makeMember({ id: 2, username: "Stan", role: "staff", status: "online" as UserStatus }), + ]); + memberList.mount(container); + + const headers = Array.from(container.querySelectorAll(".member-role-group")).map( + (h) => h.textContent, + ); + expect(headers[0]).toContain("OWNER"); + expect(headers[1]).toContain("STAFF"); + + const stanName = container.querySelector('[data-testid="member-2"] .mi-name'); + expect((stanName as HTMLSpanElement).style.color).toBe("rgb(0, 255, 0)"); + }); + + it("members with a role missing from the server list still render", () => { + setRoles([{ id: 1, name: "Owner", color: null, permissions: 0 }]); + setTestMembers([ + makeMember({ id: 1, username: "Alice", role: "owner", status: "online" as UserStatus }), + makeMember({ id: 2, username: "Ghost", role: "phantom", status: "online" as UserStatus }), + ]); + memberList.mount(container); + + expect(container.querySelector('[data-testid="member-2"]')).not.toBeNull(); + const headers = Array.from(container.querySelectorAll(".member-role-group")).map( + (h) => h.textContent, + ); + expect(headers.some((h) => h?.includes("PHANTOM"))).toBe(true); + }); + + it("non-admin context menu shows only Block, no admin actions", () => { setTestMembers(testMembers); const opts: MemberListOptions = { currentUserRole: "member", onKick: vi.fn().mockResolvedValue(undefined), onBan: vi.fn().mockResolvedValue(undefined), onChangeRole: vi.fn().mockResolvedValue(undefined), + onToggleBlock: vi.fn().mockResolvedValue(undefined), }; memberList.destroy?.(); memberList = createMemberList(opts); @@ -268,9 +336,72 @@ describe("MemberList", () => { const memberItem = container.querySelector('[data-testid="member-3"]') as HTMLDivElement; memberItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); - // No context menu should be appended to body - const contextMenu = document.body.querySelector(".admin-context-menu, .context-menu"); - expect(contextMenu).toBeNull(); + const contextMenu = document.body.querySelector(".context-menu"); + expect(contextMenu).not.toBeNull(); + const labels = Array.from(contextMenu!.querySelectorAll(".context-menu__item")).map( + (i) => i.textContent, + ); + expect(labels).toEqual(["Block"]); + + document.body.querySelector(".context-menu")?.remove(); + }); + + // The menu used to gate on the role NAME (owner/admin), so the seeded + // Moderator role — which holds KICK_MEMBERS and BAN_MEMBERS — got nothing, + // and a custom role holding those bits got nothing either. + describe("permission-driven moderation items", () => { + /** Mirrors the seeded Moderator mask (0x000FFFFF): MANAGE_MESSAGES, + * MANAGE_CHANNELS, KICK_MEMBERS, BAN_MEMBERS — no MANAGE_ROLES. */ + const MODERATOR_MASK = 0x000fffff; + + function openMenuAs(roleName: string, permissions: number): string[] { + setRoles([{ id: 7, name: roleName, color: null, permissions }]); + setTestMembers(testMembers); + const opts: MemberListOptions = { ...defaultOpts(), currentUserRole: roleName }; + memberList.destroy?.(); + memberList = createMemberList(opts); + memberList.mount(container); + + const memberItem = container.querySelector('[data-testid="member-3"]') as HTMLDivElement; + memberItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + const menu = document.body.querySelector(".context-menu"); + expect(menu).not.toBeNull(); + // Submenu role options share the item class; only top-level items count. + return Array.from(menu!.children) + .filter((el) => el.classList.contains("context-menu__item")) + .map((el) => el.firstChild?.textContent ?? ""); + } + + afterEach(() => { + document.body.querySelector(".context-menu")?.remove(); + }); + + it("shows Force Logout and Ban but not Change Role for a moderator mask", () => { + const labels = openMenuAs("moderator", MODERATOR_MASK); + expect(labels).toContain("Force Logout"); + expect(labels).toContain("Ban"); + expect(labels).not.toContain("Change Role"); + expect(labels).toContain("Block"); + }); + + it("shows Change Role only when MANAGE_ROLES is held", () => { + const labels = openMenuAs("staff", Permission.MANAGE_ROLES | Permission.KICK_MEMBERS); + expect(labels).toContain("Change Role"); + expect(labels).toContain("Force Logout"); + expect(labels).not.toContain("Ban"); + }); + + it("shows every moderation item for the ADMINISTRATOR bit", () => { + const labels = openMenuAs("admin", Permission.ADMINISTRATOR); + expect(labels).toEqual( + expect.arrayContaining(["Change Role", "Force Logout", "Ban", "Block"]), + ); + }); + + it("shows only Block for a role whose mask holds no moderation bits", () => { + const labels = openMenuAs("member", Permission.SEND_MESSAGES | Permission.READ_MESSAGES); + expect(labels).toEqual(["Block"]); + }); }); it("context menu does not appear when right-clicking yourself", () => { @@ -289,6 +420,7 @@ describe("MemberList", () => { onKick: vi.fn().mockResolvedValue(undefined), onBan: vi.fn().mockResolvedValue(undefined), onChangeRole: vi.fn().mockResolvedValue(undefined), + onToggleBlock: vi.fn().mockResolvedValue(undefined), }; memberList.destroy?.(); memberList = createMemberList(opts); @@ -420,3 +552,85 @@ describe("MemberList", () => { expect(container.querySelector(".mi-name")?.textContent).toBe("Solo"); }); }); + +// ─── Phase 6: display names, custom status, invisible ──────────────────────── + +describe("MemberList profile fields", () => { + let container: HTMLDivElement; + let list: ReturnType | null = null; + + const opts: MemberListOptions = { + currentUserRole: "member", + onKick: vi.fn(), + onBan: vi.fn(), + onChangeRole: vi.fn(), + onToggleBlock: vi.fn(), + }; + + beforeEach(() => { + resetStore(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + list?.destroy?.(); + list = null; + container.remove(); + resetStore(); + }); + + it("renders the display name and falls back to the username", () => { + setTestMembers([ + makeMember({ id: 1, username: "alice", displayName: "Alice A." }), + makeMember({ id: 2, username: "bob", displayName: null }), + ]); + list = createMemberList(opts); + list.mount(container); + + const names = Array.from(container.querySelectorAll(".mi-name")).map((el) => el.textContent); + expect(names).toContain("Alice A."); + expect(names).toContain("bob"); + // The username is not shown twice — the display name replaces it here. + expect(names).not.toContain("alice"); + }); + + it("shows a custom status under the name, and omits the line without one", () => { + setTestMembers([ + makeMember({ id: 1, username: "alice", customStatus: "shipping phase 6" }), + makeMember({ id: 2, username: "bob" }), + ]); + list = createMemberList(opts); + list.mount(container); + + const withStatus = container.querySelector('[data-testid="member-custom-status-1"]'); + expect(withStatus?.textContent).toBe("shipping phase 6"); + expect(container.querySelector('[data-testid="member-custom-status-2"]')).toBeNull(); + }); + + it("renders an invisible member the way it renders an offline one", () => { + // Only ever the signed-in user's own row — everyone else is mapped to + // offline server-side — but it has to look like what others see. + setTestMembers([makeMember({ id: 1, username: "ghost", status: "invisible" as UserStatus })]); + list = createMemberList(opts); + list.mount(container); + + const row = container.querySelector('[data-testid="member-1"]'); + expect(row?.classList.contains("offline")).toBe(true); + }); + + it("re-renders (not just recolors) when a custom status changes", () => { + setTestMembers([makeMember({ id: 1, username: "alice" })]); + list = createMemberList(opts); + list.mount(container); + expect(container.querySelector('[data-testid="member-custom-status-1"]')).toBeNull(); + + updatePresence(1, "online", "back in 5"); + membersStore.flush(); + // A custom status is its own line, so it needs a structural render — the + // presence-only fast path would have left the row without it. + expect(container.querySelector('[data-testid="member-custom-status-1"]')?.textContent).toBe( + "back in 5", + ); + }); +}); diff --git a/Client/tauri-client/tests/unit/member-picker-group.test.ts b/Client/tauri-client/tests/unit/member-picker-group.test.ts new file mode 100644 index 00000000..e422c566 --- /dev/null +++ b/Client/tauri-client/tests/unit/member-picker-group.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createMemberPickerModal } from "../../src/pages/main-page/MemberPickerModal"; +import { membersStore } from "@stores/members.store"; +import { authStore } from "@stores/auth.store"; +import { MAX_GROUP_DM_PARTICIPANTS } from "@lib/constants"; + +function seedMembers(count: number): void { + const members = new Map>(); + members.set(1, member(1, "me")); + for (let i = 2; i <= count + 1; i++) { + members.set(i, member(i, `user${i}`)); + } + membersStore.setState((prev) => ({ ...prev, members })); +} + +function member(id: number, username: string) { + return { + id, + username, + avatar: null, + role: "member", + status: "online" as const, + displayName: null, + }; +} + +let container: HTMLDivElement; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + authStore.setState((prev) => ({ + ...prev, + user: { id: 1, username: "me", avatar: null, role: "member" } as never, + })); + seedMembers(4); +}); + +afterEach(() => { + container.remove(); + document.querySelectorAll(".modal-overlay").forEach((el) => el.remove()); +}); + +function open(opts: Partial[0]> = {}) { + const picker = createMemberPickerModal({ + onSelect: vi.fn(), + onSelectGroup: vi.fn(), + onClose: vi.fn(), + ...opts, + }); + picker.mount(container); + return picker; +} + +const rows = (): HTMLElement[] => [ + ...document.querySelectorAll(".dm-member-picker-item"), +]; +const confirm = (): HTMLElement => + document.querySelector('[data-testid="dm-picker-create"]') as HTMLElement; +const nameInput = (): HTMLInputElement => + document.querySelector('[data-testid="dm-group-name"]') as HTMLInputElement; + +describe("member picker — multi-select", () => { + it("excludes the current user from the list", () => { + open(); + expect(rows()).toHaveLength(4); + expect(document.querySelector('[data-testid="dm-picker-member-1"]')).toBeNull(); + }); + + it("hides the confirm button until something is selected", () => { + open(); + expect(confirm().style.display).toBe("none"); + }); + + it("offers a plain DM for a single selection", () => { + const onSelect = vi.fn(); + open({ onSelect }); + + rows()[0]!.click(); + expect(confirm().style.display).toBe(""); + expect(confirm().textContent).toBe("Create DM"); + // A one-person selection is not a group, so it is not offered a name. + expect(nameInput().style.display).toBe("none"); + + confirm().click(); + expect(onSelect).toHaveBeenCalledWith(2); + }); + + it("offers a group DM for two or more selections", () => { + const onSelectGroup = vi.fn(); + open({ onSelectGroup }); + + rows()[0]!.click(); + rows()[1]!.click(); + expect(confirm().textContent).toBe("Create Group DM (3)"); + expect(nameInput().style.display).toBe(""); + + nameInput().value = " Lunch crew "; + confirm().click(); + expect(onSelectGroup).toHaveBeenCalledWith([2, 3], "Lunch crew"); + }); + + it("toggles a selection off on a second click", () => { + open(); + rows()[0]!.click(); + expect(rows()[0]!.classList.contains("selected")).toBe(true); + rows()[0]!.click(); + expect(rows()[0]!.classList.contains("selected")).toBe(false); + expect(confirm().style.display).toBe("none"); + }); + + // The server's cap counts the creator, so the picker allows one fewer. + it("refuses selections past the participant cap", () => { + seedMembers(MAX_GROUP_DM_PARTICIPANTS + 3); + const picker = open(); + + const all = rows(); + for (const row of all) row.click(); + + const selected = all.filter((r) => r.classList.contains("selected")); + expect(selected).toHaveLength(MAX_GROUP_DM_PARTICIPANTS - 1); + expect(confirm().textContent).toBe(`Create Group DM (${MAX_GROUP_DM_PARTICIPANTS})`); + + picker.destroy?.(); + }); + + it("stays single-select when no group callback is supplied", () => { + const onSelect = vi.fn(); + createMemberPickerModal({ onSelect, onClose: vi.fn() }).mount(container); + + rows()[0]!.click(); + expect(onSelect).toHaveBeenCalledWith(2); + // No confirm step: one click is the whole interaction. + expect(document.querySelector(".modal-overlay.visible")).toBeNull(); + }); + + it("closes the modal on confirm", () => { + open(); + rows()[0]!.click(); + confirm().click(); + expect(document.querySelector(".modal-overlay.visible")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/member-profile-fields.test.ts b/Client/tauri-client/tests/unit/member-profile-fields.test.ts new file mode 100644 index 00000000..4b70a1a5 --- /dev/null +++ b/Client/tauri-client/tests/unit/member-profile-fields.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + membersStore, + memberDisplayName, + setMembers, + updateMemberProfile, + updatePresence, +} from "@stores/members.store"; +import type { ReadyMember } from "@lib/types"; + +/** + * Display names and custom statuses through the member store. Two rules carry + * the weight: the display name falls back to the username everywhere, and a + * partial update must not blank a field it did not mention (the auto-idle + * timer sends a bare status flip several times an hour). + */ + +const alice: ReadyMember = { + id: 1, + username: "alice", + avatar: null, + role: "member", + status: "online", + display_name: "Alice A.", + custom_status: "shipping phase 6", +}; + +const bob: ReadyMember = { + id: 2, + username: "bob", + avatar: null, + role: "member", + status: "idle", +}; + +describe("memberDisplayName", () => { + it("prefers the display name and falls back to the username", () => { + expect(memberDisplayName({ username: "alice", displayName: "Alice A." })).toBe("Alice A."); + expect(memberDisplayName({ username: "bob", displayName: null })).toBe("bob"); + expect(memberDisplayName({ username: "bob" })).toBe("bob"); + expect(memberDisplayName({ username: "bob", displayName: " " })).toBe("bob"); + }); +}); + +describe("members store profile fields", () => { + beforeEach(() => { + setMembers([]); + }); + + it("carries display name and custom status from ready", () => { + setMembers([alice, bob]); + const a = membersStore.getState().members.get(1)!; + expect(a.displayName).toBe("Alice A."); + expect(a.customStatus).toBe("shipping phase 6"); + + // A member the server sent neither field for is explicitly null, not + // undefined — the store normalises so renderers only handle one absence. + const b = membersStore.getState().members.get(2)!; + expect(b.displayName).toBeNull(); + expect(b.customStatus).toBeNull(); + }); + + it("a bare presence update leaves the custom status alone", () => { + setMembers([alice]); + updatePresence(1, "idle"); + const a = membersStore.getState().members.get(1)!; + expect(a.status).toBe("idle"); + expect(a.customStatus).toBe("shipping phase 6"); + }); + + it("a presence update carrying the field replaces it, null included", () => { + setMembers([alice]); + updatePresence(1, "online", "back"); + expect(membersStore.getState().members.get(1)!.customStatus).toBe("back"); + + updatePresence(1, "online", null); + expect(membersStore.getState().members.get(1)!.customStatus).toBeNull(); + }); + + it("accepts invisible as a status (the signed-in user's own)", () => { + setMembers([alice]); + updatePresence(1, "invisible"); + expect(membersStore.getState().members.get(1)!.status).toBe("invisible"); + }); + + it("user_update replaces the display name, and omitting it preserves it", () => { + setMembers([alice]); + + updateMemberProfile(1, { username: "alice", avatar: null, displayName: "Ada" }); + expect(membersStore.getState().members.get(1)!.displayName).toBe("Ada"); + + // An older server's user_update has no display_name; it must not wipe one. + updateMemberProfile(1, { username: "alice", avatar: null }); + expect(membersStore.getState().members.get(1)!.displayName).toBe("Ada"); + + // An explicit null is a clear. + updateMemberProfile(1, { username: "alice", avatar: null, displayName: null }); + expect(membersStore.getState().members.get(1)!.displayName).toBeNull(); + }); + + it("user_update does not clobber a pinned identity key when omitted", () => { + setMembers([{ ...alice, identity_public_key: "KEY" }]); + updateMemberProfile(1, { username: "alice", avatar: "/api/v1/files/x" }); + const a = membersStore.getState().members.get(1)!; + expect(a.identityPublicKey).toBe("KEY"); + expect(a.avatar).toBe("/api/v1/files/x"); + }); + + it("keeps state immutable across updates", () => { + setMembers([alice]); + const before = membersStore.getState(); + updatePresence(1, "dnd"); + const after = membersStore.getState(); + expect(after).not.toBe(before); + expect(after.members).not.toBe(before.members); + expect(before.members.get(1)!.status).toBe("online"); + }); +}); diff --git a/Client/tauri-client/tests/unit/members.store.test.ts b/Client/tauri-client/tests/unit/members.store.test.ts index 222cd3a3..0d09288c 100644 --- a/Client/tauri-client/tests/unit/members.store.test.ts +++ b/Client/tauri-client/tests/unit/members.store.test.ts @@ -76,6 +76,8 @@ describe("members store", () => { avatar: "alice.png", role: "admin", status: "online", + displayName: null, + customStatus: null, identityPublicKey: null, }); }); @@ -98,9 +100,10 @@ describe("members store", () => { }); describe("addMember", () => { - it("adds a new member from member_join payload", () => { + it("adds a new member from member_join payload, using the payload's status", () => { const payload: MemberJoinPayload = { user: { id: 10, username: "newuser", avatar: null, role: "member" }, + status: "online", }; addMember(payload); const member = membersStore.getState().members.get(10); @@ -110,14 +113,37 @@ describe("members store", () => { avatar: null, role: "member", status: "online", + displayName: null, + customStatus: null, identityPublicKey: null, }); }); + it("renders an invisible join as offline", () => { + // The server collapses an invisible connector's status to "offline" + // before broadcasting member_join; the store must pass that through + // rather than assuming a join always means online. + addMember({ + user: { id: 11, username: "ghost", avatar: null, role: "member" }, + status: "offline", + }); + expect(membersStore.getState().members.get(11)?.status).toBe("offline"); + }); + + it("defaults to offline when the payload omits status (older server)", () => { + // Fail safe: a server that has not shipped the status field yet must + // not cause a hidden user to render as online. + addMember({ + user: { id: 12, username: "legacy-server-user", avatar: null, role: "member" }, + }); + expect(membersStore.getState().members.get(12)?.status).toBe("offline"); + }); + it("does not remove existing members", () => { setMembers([MEMBER_ALICE]); addMember({ user: { id: 10, username: "newuser", avatar: null, role: "member" }, + status: "online", }); expect(membersStore.getState().members.size).toBe(2); expect(membersStore.getState().members.has(1)).toBe(true); diff --git a/Client/tauri-client/tests/unit/mention-autocomplete.test.ts b/Client/tauri-client/tests/unit/mention-autocomplete.test.ts new file mode 100644 index 00000000..0f2d11e2 --- /dev/null +++ b/Client/tauri-client/tests/unit/mention-autocomplete.test.ts @@ -0,0 +1,337 @@ +/** + * MentionAutocomplete — filtering, the MENTION_EVERYONE gate on the broadcast + * entries, keyboard navigation, and composer integration. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("@lib/livekitSession", () => ({ + leaveVoice: vi.fn(), + switchInputDevice: vi.fn(), + switchOutputDevice: vi.fn(), + setVoiceSensitivity: vi.fn(), + setInputVolume: vi.fn(), + setOutputVolume: vi.fn(), + getSessionDebugInfo: vi.fn().mockReturnValue({}), +})); + +import { + createMentionAutocomplete, + filterMentionSuggestions, + MAX_MENTION_SUGGESTIONS, +} from "../../src/components/MentionAutocomplete"; +import { createMessageInput } from "../../src/components/MessageInput"; +import { membersStore } from "../../src/stores/members.store"; +import { authStore } from "../../src/stores/auth.store"; +import { channelsStore, setRoles } from "../../src/stores/channels.store"; +import { Permission } from "../../src/lib/types"; + +const NAMES = ["alice", "Alan", "Bob", "carol"]; + +function seedMembers(names: readonly string[] = NAMES): void { + membersStore.setState(() => ({ + members: new Map( + names.map((n, i) => [ + i + 1, + { + id: i + 1, + username: n, + avatar: null, + role: "member" as const, + status: "online" as const, + }, + ]), + ), + typingUsers: new Map(), + })); +} + +/** Sign in as a role, and register that role's permission mask. */ +function signInAs(role: string, permissions: number): void { + authStore.setState(() => ({ + token: "t", + user: { id: 99, username: "me", avatar: null, role }, + serverName: null, + motd: null, + isAuthenticated: true, + })); + channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] })); + setRoles([{ id: 1, name: role, color: null, permissions }]); + channelsStore.flush(); +} + +beforeEach(() => { + seedMembers(); + signInAs("member", Permission.SEND_MESSAGES); +}); + +describe("filterMentionSuggestions", () => { + it("lists every member for an empty query", () => { + expect(filterMentionSuggestions("").map((s) => s.token)).toEqual([ + "Alan", + "alice", + "Bob", + "carol", + ]); + }); + + it("filters case-insensitively", () => { + expect(filterMentionSuggestions("AL").map((s) => s.token)).toEqual(["Alan", "alice"]); + }); + + it("ranks prefix matches above substring matches", () => { + seedMembers(["bob", "abbot"]); + expect(filterMentionSuggestions("b").map((s) => s.token)).toEqual(["bob", "abbot"]); + }); + + it("returns nothing when no member matches", () => { + expect(filterMentionSuggestions("zzz")).toEqual([]); + }); + + it("caps the list", () => { + seedMembers(Array.from({ length: 40 }, (_, i) => `user${String(i).padStart(2, "0")}`)); + expect(filterMentionSuggestions("user").length).toBe(MAX_MENTION_SUGGESTIONS); + }); + + it("omits @everyone/@here without MENTION_EVERYONE", () => { + const tokens = filterMentionSuggestions("").map((s) => s.token); + expect(tokens).not.toContain("everyone"); + expect(tokens).not.toContain("here"); + }); + + it("offers @everyone/@here first when the role holds MENTION_EVERYONE", () => { + signInAs("mod", Permission.SEND_MESSAGES | Permission.MENTION_EVERYONE); + const tokens = filterMentionSuggestions("").map((s) => s.token); + expect(tokens.slice(0, 2)).toEqual(["everyone", "here"]); + }); + + it("filters the broadcast entries by prefix too", () => { + signInAs("mod", Permission.MENTION_EVERYONE); + expect(filterMentionSuggestions("her").map((s) => s.token)).toEqual(["here"]); + expect(filterMentionSuggestions("every").map((s) => s.token)).toEqual(["everyone"]); + }); + + it("offers broadcasts to an administrator implicitly", () => { + signInAs("owner", Permission.ADMINISTRATOR); + expect(filterMentionSuggestions("every").map((s) => s.token)).toEqual(["everyone"]); + }); +}); + +describe("createMentionAutocomplete", () => { + let onSelect: ReturnType; + let onClose: ReturnType; + let popup: ReturnType; + + beforeEach(() => { + onSelect = vi.fn(); + onClose = vi.fn(); + popup = createMentionAutocomplete({ onSelect, onClose }); + document.body.appendChild(popup.element); + }); + + afterEach(() => { + popup.destroy(); + }); + + function key(k: string): KeyboardEvent { + return new KeyboardEvent("keydown", { key: k, cancelable: true }); + } + + function labels(): string[] { + return Array.from(popup.element.querySelectorAll(".ma-name")).map((e) => e.textContent ?? ""); + } + + it("renders one row per suggestion, first row active", () => { + popup.setQuery("al"); + expect(labels()).toEqual(["@Alan", "@alice"]); + expect(popup.element.querySelectorAll(".ma-item--active").length).toBe(1); + expect(popup.element.querySelector(".ma-item")?.classList.contains("ma-item--active")).toBe( + true, + ); + }); + + it("reports no match so the composer can close it", () => { + expect(popup.setQuery("zzz")).toBe(false); + expect(labels()).toEqual([]); + }); + + it("moves the selection with ArrowDown and wraps", () => { + popup.setQuery("al"); + popup.handleKeydown(key("ArrowDown")); + expect(popup.element.querySelectorAll(".ma-item")[1]?.getAttribute("aria-selected")).toBe( + "true", + ); + popup.handleKeydown(key("ArrowDown")); + expect(popup.element.querySelectorAll(".ma-item")[0]?.getAttribute("aria-selected")).toBe( + "true", + ); + }); + + it("wraps backwards with ArrowUp", () => { + popup.setQuery("al"); + popup.handleKeydown(key("ArrowUp")); + expect(popup.element.querySelectorAll(".ma-item")[1]?.getAttribute("aria-selected")).toBe( + "true", + ); + }); + + it("selects the active row on Enter", () => { + popup.setQuery("al"); + popup.handleKeydown(key("ArrowDown")); + expect(popup.handleKeydown(key("Enter"))).toBe(true); + expect(onSelect).toHaveBeenCalledWith("alice"); + }); + + it("selects on Tab as well", () => { + popup.setQuery("bo"); + popup.handleKeydown(key("Tab")); + expect(onSelect).toHaveBeenCalledWith("Bob"); + }); + + it("closes on Escape without selecting", () => { + popup.setQuery("al"); + expect(popup.handleKeydown(key("Escape"))).toBe(true); + expect(onClose).toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("consumes nothing while empty, so the composer keeps its keys", () => { + popup.setQuery("zzz"); + expect(popup.handleKeydown(key("Enter"))).toBe(false); + expect(popup.handleKeydown(key("ArrowDown"))).toBe(false); + }); + + it("passes ordinary keys through", () => { + popup.setQuery("al"); + expect(popup.handleKeydown(key("a"))).toBe(false); + }); + + it("selects on mousedown without stealing focus", () => { + popup.setQuery("bo"); + const row = popup.element.querySelector(".ma-item") as HTMLElement; + const ev = new MouseEvent("mousedown", { bubbles: true, cancelable: true }); + row.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + expect(onSelect).toHaveBeenCalledWith("Bob"); + }); + + it("removes its element on destroy", () => { + popup.destroy(); + expect(popup.element.parentNode).toBeNull(); + }); +}); + +describe("composer integration", () => { + let container: HTMLDivElement; + let input: ReturnType; + let onSend: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + onSend = vi.fn(); + input = createMessageInput({ + channelId: 1, + channelName: "general", + onSend, + onTyping: vi.fn(), + onEditMessage: vi.fn(), + }); + input.mount(container); + }); + + afterEach(() => { + input.destroy?.(); + container.remove(); + }); + + function textarea(): HTMLTextAreaElement { + return container.querySelector("textarea")!; + } + + function type(value: string): void { + const ta = textarea(); + ta.value = value; + ta.selectionStart = value.length; + ta.selectionEnd = value.length; + ta.dispatchEvent(new Event("input", { bubbles: true })); + } + + function popupEl(): HTMLElement | null { + return container.querySelector(".mention-autocomplete"); + } + + function press(k: string): boolean { + const ev = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }); + textarea().dispatchEvent(ev); + return ev.defaultPrevented; + } + + it("opens the popup on a bare @", () => { + type("hello @"); + expect(popupEl()).not.toBeNull(); + }); + + it("does not open for an email-shaped @", () => { + type("mail@ali"); + expect(popupEl()).toBeNull(); + }); + + it("closes once the query matches nobody", () => { + type("@al"); + expect(popupEl()).not.toBeNull(); + type("@alzzz"); + expect(popupEl()).toBeNull(); + }); + + it("closes when the caret leaves the token", () => { + type("@al"); + type("@al hello"); + expect(popupEl()).toBeNull(); + }); + + it("inserts the picked username and a trailing space", () => { + type("hey @al"); + press("Enter"); + expect(textarea().value).toBe("hey @Alan "); + expect(onSend).not.toHaveBeenCalled(); + expect(popupEl()).toBeNull(); + }); + + it("keeps text after the caret intact", () => { + const ta = textarea(); + ta.value = "hey @al world"; + ta.selectionStart = 7; + ta.selectionEnd = 7; + ta.dispatchEvent(new Event("input", { bubbles: true })); + press("Enter"); + expect(ta.value).toBe("hey @Alan world"); + }); + + it("lets Enter send once the popup is closed", () => { + type("hey @al"); + press("Escape"); + expect(popupEl()).toBeNull(); + press("Enter"); + expect(onSend).toHaveBeenCalledWith("hey @al", null, []); + }); + + it("navigates with the arrow keys before inserting", () => { + type("@al"); + press("ArrowDown"); + press("Enter"); + expect(textarea().value).toBe("@alice "); + }); + + it("does not open while the composer is disabled", () => { + input.setDisabled("Read-only"); + type("@al"); + expect(popupEl()).toBeNull(); + }); + + it("closes on blur", () => { + type("@al"); + textarea().dispatchEvent(new FocusEvent("blur")); + expect(popupEl()).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/mention-emoji-parsing.property.test.ts b/Client/tauri-client/tests/unit/mention-emoji-parsing.property.test.ts new file mode 100644 index 00000000..00e3f62c --- /dev/null +++ b/Client/tauri-client/tests/unit/mention-emoji-parsing.property.test.ts @@ -0,0 +1,290 @@ +/** + * Property tests for custom-emoji token handling (custom-emoji.ts) and the + * @mention / #channel chip rendering in content-parser.ts. + * + * Invariants under fuzzing: + * - none of these ever throw on arbitrary input + * - a `:shortcode:` / `@token` / `#channel` chip renders only when it + * resolves to something real (a known custom emoji / member / channel); + * everything else stays literal text, byte for byte + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fc from "fast-check"; + +// The emoji image is behind the session token; stub the authenticated fetch +// so buildCustomEmojiNode's async image swap does not hit the network. +const { fetchImageAsDataUrlMock } = vi.hoisted(() => ({ + fetchImageAsDataUrlMock: vi.fn(() => Promise.resolve("data:image/png;base64,AAAA")), +})); +vi.mock("../../src/components/message-list/attachments", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, fetchImageAsDataUrl: fetchImageAsDataUrlMock }; +}); + +import { + EMOJI_TOKEN_REGEX, + buildCustomEmojiNode, + isEmojiOnlyMessage, +} from "../../src/components/message-list/custom-emoji"; +import { + renderMessageContent, + renderMentionSegment, +} from "../../src/components/message-list/content-parser"; +import { emojiStore, setCustomEmoji, clearCustomEmoji } from "../../src/stores/emoji.store"; +import { membersStore } from "../../src/stores/members.store"; +import { authStore } from "../../src/stores/auth.store"; +import { channelsStore, setChannels } from "../../src/stores/channels.store"; +import type { ReadyChannel } from "../../src/lib/types"; + +const CHANNELS: ReadyChannel[] = [ + { id: 1, name: "general", type: "text", category: null, position: 0 }, +]; + +const KNOWN_MEMBER_ID = 10; +const SELF_ID = 12; +const KNOWN_SHORTCODE = "wave"; +const KNOWN_CHANNEL_ID = 1; + +function seedStores(): void { + membersStore.setState(() => ({ + members: new Map([ + [ + KNOWN_MEMBER_ID, + { + id: KNOWN_MEMBER_ID, + username: "alice", + avatar: null, + role: "member", + status: "online" as const, + }, + ], + ]), + typingUsers: new Map(), + })); + authStore.setState(() => ({ + token: "t", + user: { id: SELF_ID, username: "me", avatar: null, role: "member" }, + serverName: null, + motd: null, + isAuthenticated: true, + })); + channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] })); + setChannels(CHANNELS); + clearCustomEmoji(); + emojiStore.flush(); + setCustomEmoji([{ id: 1, shortcode: KNOWN_SHORTCODE, url: "/api/v1/emoji/1/image" }]); + emojiStore.flush(); +} + +beforeEach(() => { + seedStores(); +}); + +let container: HTMLDivElement; +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); +}); +afterEach(() => { + container.remove(); +}); + +const anyString = fc.string({ maxLength: 300 }); + +/** Alphabet weighted toward the token-shaped characters (@, #, :, word chars, + * punctuation) so fuzzing actually exercises the tokenizers' branch points. */ +const TOKEN_CHARS = [ + "@", + "#", + ":", + "_", + "-", + ".", + " ", + "\n", + "a", + "l", + "i", + "c", + "e", + "w", + "v", + "1", + "9", + "everyone", + "here", + "wave", + "general", +]; +const tokenFragment = fc.string({ unit: fc.constantFrom(...TOKEN_CHARS), maxLength: 200 }); + +// --------------------------------------------------------------------------- +// Never throws +// --------------------------------------------------------------------------- + +describe("custom-emoji — never throws", () => { + it("buildCustomEmojiNode never throws on arbitrary tokens", () => { + fc.assert( + fc.property(anyString, (s) => { + expect(() => buildCustomEmojiNode(s)).not.toThrow(); + }), + { numRuns: 300 }, + ); + }); + + it("isEmojiOnlyMessage never throws on arbitrary content", () => { + fc.assert( + fc.property(anyString, (s) => { + expect(() => isEmojiOnlyMessage(s)).not.toThrow(); + }), + { numRuns: 300 }, + ); + }); + + it("EMOJI_TOKEN_REGEX never throws when matched against arbitrary content", () => { + fc.assert( + fc.property(anyString, (s) => { + expect(() => [...s.matchAll(EMOJI_TOKEN_REGEX)]).not.toThrow(); + }), + { numRuns: 300 }, + ); + }); +}); + +describe("mention / channel rendering — never throws", () => { + it("renderMentionSegment never throws on arbitrary content", () => { + fc.assert( + fc.property(anyString, (s) => { + expect(() => renderMentionSegment(s)).not.toThrow(); + }), + { numRuns: 300 }, + ); + }); + + it("renderMentionSegment never throws on token-shaped fragments", () => { + fc.assert( + fc.property(tokenFragment, (s) => { + expect(() => renderMentionSegment(s)).not.toThrow(); + }), + { numRuns: 500 }, + ); + }); + + it("renderMessageContent never throws on token-shaped fragments (with mentionsEveryone)", () => { + fc.assert( + fc.property(tokenFragment, fc.boolean(), (s, mentionsEveryone) => { + expect(() => renderMessageContent(s, { mentionsEveryone })).not.toThrow(); + }), + { numRuns: 500 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Chips only render for resolvable ids +// --------------------------------------------------------------------------- + +describe("chips only render for what actually resolves", () => { + it("every rendered @mention chip points at the known member or self, never a made-up id", () => { + fc.assert( + fc.property(tokenFragment, (s) => { + const host = document.createElement("div"); + host.appendChild(renderMentionSegment(s)); + for (const span of Array.from(host.querySelectorAll(".mention[data-user-id]"))) { + const id = Number(span.getAttribute("data-user-id")); + expect([KNOWN_MEMBER_ID, SELF_ID]).toContain(id); + } + }), + { numRuns: 500 }, + ); + }); + + it("every rendered #channel chip points at a known channel id", () => { + fc.assert( + fc.property(tokenFragment, (s) => { + const host = document.createElement("div"); + host.appendChild(renderMentionSegment(s)); + for (const span of Array.from(host.querySelectorAll(".channel-mention[data-channel-id]"))) { + const id = Number(span.getAttribute("data-channel-id")); + expect(id).toBe(KNOWN_CHANNEL_ID); + } + }), + { numRuns: 500 }, + ); + }); + + it("every rendered custom-emoji image points at the known shortcode", () => { + fc.assert( + fc.property(tokenFragment, (s) => { + const host = document.createElement("div"); + host.appendChild(renderMentionSegment(s)); + for (const img of Array.from(host.querySelectorAll("img.custom-emoji"))) { + expect(img.getAttribute("data-shortcode")).toBe(KNOWN_SHORTCODE); + } + }), + { numRuns: 500 }, + ); + }); + + it("@everyone/@here never highlights as mention-everyone without server-confirmed mentionsEveryone", () => { + fc.assert( + fc.property(tokenFragment, (s) => { + const host = document.createElement("div"); + // No MentionInfo passed at all -> mentionsEveryone is undefined, never true. + host.appendChild(renderMentionSegment(s)); + expect(host.querySelectorAll(".mention-everyone").length).toBe(0); + }), + { numRuns: 300 }, + ); + }); + + it("@everyone highlights only when mentionsEveryone is explicitly true", () => { + const host1 = document.createElement("div"); + host1.appendChild(renderMentionSegment("hey @everyone", { mentionsEveryone: false })); + expect(host1.querySelectorAll(".mention-everyone").length).toBe(0); + + const host2 = document.createElement("div"); + host2.appendChild(renderMentionSegment("hey @everyone", { mentionsEveryone: true })); + expect(host2.querySelectorAll(".mention-everyone").length).toBe(1); + }); + + it("resolvable shortcode still renders as an image; arbitrary unresolvable ones stay literal", () => { + fc.assert( + fc.property( + fc.string({ minLength: 2, maxLength: 32 }).filter((s) => /^[A-Za-z0-9_]+$/.test(s)), + (code) => { + const host = document.createElement("div"); + host.appendChild(renderMentionSegment(`:${code}:`)); + const imgs = host.querySelectorAll("img.custom-emoji"); + if (code.toLowerCase() === KNOWN_SHORTCODE) { + expect(imgs.length).toBe(1); + } else { + expect(imgs.length).toBe(0); + expect(host.textContent).toBe(`:${code}:`); + } + }, + ), + { numRuns: 300 }, + ); + }); + + it("arbitrary text with no @/#/: tokens renders as untouched literal text", () => { + const plain = fc.string({ + unit: fc.constantFrom(..."abcdefghijklmnopqrstuvwxyz ".split("")), + maxLength: 100, + }); + fc.assert( + fc.property(plain, (s) => { + const host = document.createElement("div"); + host.appendChild(renderMentionSegment(s)); + expect(host.textContent).toBe(s); + expect(host.querySelectorAll(".mention, .channel-mention, img.custom-emoji").length).toBe( + 0, + ); + }), + { numRuns: 200 }, + ); + }); +}); diff --git a/Client/tauri-client/tests/unit/mentions-render.test.ts b/Client/tauri-client/tests/unit/mentions-render.test.ts new file mode 100644 index 00000000..350e87b3 --- /dev/null +++ b/Client/tauri-client/tests/unit/mentions-render.test.ts @@ -0,0 +1,343 @@ +/** + * Mention + #channel-link rendering: which tokens highlight, which stay plain + * text, and how a mention of the signed-in user marks the row. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("@lib/livekitSession", () => ({ + leaveVoice: vi.fn(), + switchInputDevice: vi.fn(), + switchOutputDevice: vi.fn(), + setVoiceSensitivity: vi.fn(), + setInputVolume: vi.fn(), + setOutputVolume: vi.fn(), + getSessionDebugInfo: vi.fn().mockReturnValue({}), +})); + +import { + renderMentionSegment, + renderMessageContent, +} from "../../src/components/message-list/content-parser"; +import { renderMessage } from "../../src/components/message-list/renderers"; +import { + highlightsCurrentUser, + mentionsCurrentUser, + resolveMentionUserId, +} from "../../src/lib/mentions"; +import { membersStore } from "../../src/stores/members.store"; +import { authStore } from "../../src/stores/auth.store"; +import { channelsStore, setChannels } from "../../src/stores/channels.store"; +import type { Message } from "../../src/stores/messages.store"; +import type { MessageListOptions } from "../../src/components/MessageList"; +import type { ReadyChannel } from "../../src/lib/types"; + +const CHANNELS: ReadyChannel[] = [ + { id: 1, name: "general", type: "text", category: null, position: 0 }, + { id: 2, name: "off-topic", type: "text", category: null, position: 1 }, + { id: 3, name: "dm-3", type: "dm", category: null, position: 0 }, +]; + +function seedStores(): void { + membersStore.setState(() => ({ + members: new Map([ + [10, { id: 10, username: "alice", avatar: null, role: "member", status: "online" as const }], + [11, { id: 11, username: "Bob", avatar: null, role: "admin", status: "online" as const }], + [12, { id: 12, username: "me", avatar: null, role: "member", status: "online" as const }], + ]), + typingUsers: new Map(), + })); + authStore.setState(() => ({ + token: "t", + user: { id: 12, username: "me", avatar: null, role: "member" }, + serverName: null, + motd: null, + isAuthenticated: true, + })); + channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] })); + setChannels(CHANNELS); +} + +let container: HTMLDivElement; + +beforeEach(() => { + seedStores(); + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + container.remove(); +}); + +function render(text: string, info?: Parameters[1]): HTMLDivElement { + container.appendChild(renderMentionSegment(text, info)); + return container; +} + +describe("@mention rendering", () => { + it("highlights a username that resolves against the member list", () => { + const el = render("hey @alice"); + const mention = el.querySelector(".mention"); + expect(mention?.textContent).toBe("@alice"); + expect(mention?.getAttribute("data-user-id")).toBe("10"); + }); + + it("resolves case-insensitively", () => { + const el = render("hey @BOB"); + expect(el.querySelector(".mention")?.textContent).toBe("@BOB"); + expect(el.querySelector(".mention")?.getAttribute("data-user-id")).toBe("11"); + }); + + it("leaves an unknown username as plain text", () => { + const el = render("hey @nobody"); + expect(el.querySelector(".mention")).toBeNull(); + expect(el.textContent).toBe("hey @nobody"); + }); + + it("does not treat an email local part as a mention", () => { + const el = render("write to mail@example"); + expect(el.querySelector(".mention")).toBeNull(); + }); + + it("does not match an address-shaped token", () => { + membersStore.setState((prev) => ({ + ...prev, + members: new Map([ + ...prev.members, + [13, { id: 13, username: "bob", avatar: null, role: "member", status: "online" as const }], + ]), + })); + const el = render("ping @bob@example.com"); + expect(el.querySelector(".mention")).toBeNull(); + }); + + it("does not match a doubled @@name", () => { + const el = render("@@alice"); + expect(el.querySelector(".mention")).toBeNull(); + }); + + it("falls back to the trailing-punctuation spelling", () => { + const el = render("thanks @alice."); + expect(el.querySelector(".mention")?.textContent).toBe("@alice."); + }); + + it("marks a mention of the signed-in user with mention-self", () => { + const el = render("hey @me and @alice"); + const spans = el.querySelectorAll(".mention"); + expect(spans.length).toBe(2); + expect(spans[0]?.classList.contains("mention-self")).toBe(true); + expect(spans[1]?.classList.contains("mention-self")).toBe(false); + }); + + it("prefers the server-resolved id for the spelling it matched", () => { + // Two users could plausibly answer to "alice"; the server's list decides. + membersStore.setState((prev) => ({ + ...prev, + members: new Map([ + ...prev.members, + [ + 20, + { id: 20, username: "Alice", avatar: null, role: "member", status: "online" as const }, + ], + ]), + })); + const el = render("hi @alice", { mentions: [20] }); + expect(el.querySelector(".mention")?.getAttribute("data-user-id")).toBe("20"); + }); +}); + +describe("@everyone / @here", () => { + it("highlights when the server honoured the token", () => { + const el = render("heads up @everyone", { mentionsEveryone: true }); + const span = el.querySelector(".mention"); + expect(span?.classList.contains("mention-everyone")).toBe(true); + expect(span?.textContent).toBe("@everyone"); + }); + + it("highlights @here the same way", () => { + const el = render("@here now", { mentionsEveryone: true }); + expect(el.querySelector(".mention-everyone")?.textContent).toBe("@here"); + }); + + it("stays plain text when the sender lacked MENTION_EVERYONE", () => { + const el = render("heads up @everyone", { mentionsEveryone: false }); + expect(el.querySelector(".mention")).toBeNull(); + expect(el.textContent).toBe("heads up @everyone"); + }); + + it("stays plain text when the server said nothing", () => { + const el = render("heads up @here"); + expect(el.querySelector(".mention")).toBeNull(); + }); + + it("never resolves @everyone as a username", () => { + membersStore.setState((prev) => ({ + ...prev, + members: new Map([ + ...prev.members, + [ + 30, + { id: 30, username: "everyone", avatar: null, role: "member", status: "online" as const }, + ], + ]), + })); + expect(resolveMentionUserId("everyone")).toBeNull(); + }); +}); + +describe("#channel links", () => { + it("renders a chip for a channel that exists", () => { + const el = render("see #off-topic"); + const chip = el.querySelector(".channel-mention"); + expect(chip?.textContent).toBe("#off-topic"); + expect(chip?.getAttribute("data-channel-id")).toBe("2"); + expect(chip?.getAttribute("role")).toBe("link"); + }); + + it("uses the channel's canonical casing", () => { + const el = render("see #GENERAL"); + expect(el.querySelector(".channel-mention")?.textContent).toBe("#general"); + }); + + it("leaves an unknown channel name as plain text", () => { + const el = render("see #nowhere"); + expect(el.querySelector(".channel-mention")).toBeNull(); + expect(el.textContent).toBe("see #nowhere"); + }); + + it("does not link DM channels", () => { + const el = render("see #dm-3"); + expect(el.querySelector(".channel-mention")).toBeNull(); + }); + + it("activates the channel on click", () => { + const el = render("go to #off-topic"); + (el.querySelector(".channel-mention") as HTMLElement).click(); + channelsStore.flush(); + expect(channelsStore.getState().activeChannelId).toBe(2); + }); + + it("activates the channel on Enter", () => { + const el = render("go to #off-topic"); + const chip = el.querySelector(".channel-mention") as HTMLElement; + chip.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + channelsStore.flush(); + expect(channelsStore.getState().activeChannelId).toBe(2); + }); + + it("interleaves mentions and channel links in source order", () => { + const el = render("@alice see #general then @Bob"); + const nodes = Array.from(el.querySelectorAll(".mention, .channel-mention")); + expect(nodes.map((n) => n.textContent)).toEqual(["@alice", "#general", "@Bob"]); + }); +}); + +describe("code segments", () => { + it("does not linkify tokens inside a code block", () => { + container.appendChild(renderMessageContent("```@alice #general```")); + expect(container.querySelector(".mention")).toBeNull(); + expect(container.querySelector(".channel-mention")).toBeNull(); + }); + + it("does not linkify tokens inside inline code", () => { + container.appendChild(renderMessageContent("run `@alice` now")); + expect(container.querySelector(".mention")).toBeNull(); + }); +}); + +describe("row highlight", () => { + function makeMessage(overrides: Partial = {}): Message { + return { + id: 1, + channelId: 1, + user: { id: 10, username: "alice", avatar: null }, + content: "hello", + replyTo: null, + attachments: [], + reactions: [], + pinned: false, + editedAt: null, + deleted: false, + timestamp: "2026-01-15T12:30:00Z", + status: "sent", + correlationId: null, + errorCode: null, + ...overrides, + }; + } + + const opts = { + channelId: 1, + channelName: "general", + currentUserId: 12, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + onPinClick: vi.fn(), + } as unknown as MessageListOptions; + + function rowClasses(msg: Message): DOMTokenList { + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], opts, ac.signal); + ac.abort(); + return el.classList; + } + + it("adds .mentioned when the server names the current user", () => { + expect( + rowClasses(makeMessage({ content: "hey @me", mentions: [12] })).contains("mentioned"), + ).toBe(true); + }); + + it("adds .mentioned for an honoured @everyone", () => { + expect( + rowClasses(makeMessage({ content: "@everyone", mentionsEveryone: true })).contains( + "mentioned", + ), + ).toBe(true); + }); + + it("does not add .mentioned for someone else's mention", () => { + expect( + rowClasses(makeMessage({ content: "hey @alice", mentions: [10] })).contains("mentioned"), + ).toBe(false); + }); + + it("trusts the server list over the local name parse", () => { + // The text names the current user, but the server resolved someone else + // (e.g. the username changed since) — the server wins. + expect( + rowClasses(makeMessage({ content: "hey @me", mentions: [10] })).contains("mentioned"), + ).toBe(false); + }); + + it("falls back to name resolution when the server sent no list", () => { + expect(rowClasses(makeMessage({ content: "hey @me" })).contains("mentioned")).toBe(true); + }); + + it("does not highlight a deleted row", () => { + expect( + rowClasses(makeMessage({ content: "hey @me", mentions: [12], deleted: true })).contains( + "mentioned", + ), + ).toBe(false); + }); +}); + +describe("mention predicates", () => { + it("mentionsCurrentUser ignores @everyone", () => { + expect(mentionsCurrentUser("@everyone", { mentionsEveryone: true })).toBe(false); + }); + + it("highlightsCurrentUser counts @everyone", () => { + expect(highlightsCurrentUser("@everyone", { mentionsEveryone: true })).toBe(true); + }); + + it("is false when nobody is signed in", () => { + authStore.setState((prev) => ({ ...prev, user: null })); + expect(mentionsCurrentUser("hey @me")).toBe(false); + }); +}); diff --git a/Client/tauri-client/tests/unit/message-input.test.ts b/Client/tauri-client/tests/unit/message-input.test.ts index 9f0cf376..010b87af 100644 --- a/Client/tauri-client/tests/unit/message-input.test.ts +++ b/Client/tauri-client/tests/unit/message-input.test.ts @@ -30,7 +30,11 @@ vi.mock("@components/GifPicker", () => ({ }, })); -import { createMessageInput, type MessageInputOptions } from "@components/MessageInput"; +import { + createMessageInput, + wrapWithMarker, + type MessageInputOptions, +} from "@components/MessageInput"; import type { GifApi } from "@lib/gifProvider"; /** GIF endpoints on the user's own server (never api.klipy.com). */ @@ -972,4 +976,134 @@ describe("MessageInput", () => { comp.destroy?.(); }); }); + + // ------------------------------------------------------------------------- + // Formatting shortcuts + // ------------------------------------------------------------------------- + + describe("formatting shortcuts", () => { + /** Mount a composer with `value` selected from `start` to `end`. */ + function mountWithSelection( + value: string, + start: number, + end: number, + ): { textarea: HTMLTextAreaElement; destroy: () => void } { + const comp = createMessageInput(makeOptions()); + comp.mount(container); + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = value; + textarea.selectionStart = start; + textarea.selectionEnd = end; + return { textarea, destroy: () => comp.destroy?.() }; + } + + function press(textarea: HTMLTextAreaElement, key: string): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key, + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + textarea.dispatchEvent(event); + return event; + } + + it("Ctrl+B wraps the selection in **", () => { + const { textarea, destroy } = mountWithSelection("make this bold", 5, 9); + const event = press(textarea, "b"); + expect(textarea.value).toBe("make **this** bold"); + expect(textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)).toBe("this"); + expect(event.defaultPrevented).toBe(true); + destroy(); + }); + + it("Ctrl+I wraps the selection in *", () => { + const { textarea, destroy } = mountWithSelection("hello", 0, 5); + press(textarea, "i"); + expect(textarea.value).toBe("*hello*"); + destroy(); + }); + + it("Ctrl+U wraps the selection in __ instead of opening the file picker", () => { + const onUploadFile = vi.fn(); + const comp = createMessageInput(makeOptions({ onUploadFile })); + comp.mount(container); + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "hello"; + textarea.selectionStart = 0; + textarea.selectionEnd = 5; + + // The global Ctrl+U shortcut listens on document — the composer must + // stop the event before it gets there. + const globalHandler = vi.fn(); + document.addEventListener("keydown", globalHandler); + + press(textarea, "u"); + + expect(textarea.value).toBe("__hello__"); + expect(globalHandler).not.toHaveBeenCalled(); + expect(onUploadFile).not.toHaveBeenCalled(); + document.removeEventListener("keydown", globalHandler); + comp.destroy?.(); + }); + + it("inserts empty markers and parks the caret between them", () => { + const { textarea, destroy } = mountWithSelection("ab", 1, 1); + press(textarea, "b"); + expect(textarea.value).toBe("a****b"); + expect(textarea.selectionStart).toBe(3); + expect(textarea.selectionEnd).toBe(3); + destroy(); + }); + + it("unwraps an already-bold selection", () => { + const { textarea, destroy } = mountWithSelection("**this**", 0, 8); + press(textarea, "b"); + expect(textarea.value).toBe("this"); + destroy(); + }); + + it("leaves other Ctrl combos alone", () => { + const { textarea, destroy } = mountWithSelection("hello", 0, 5); + const event = press(textarea, "k"); + expect(textarea.value).toBe("hello"); + expect(event.defaultPrevented).toBe(false); + destroy(); + }); + + it("does nothing while the composer is disabled", () => { + const comp = createMessageInput(makeOptions()); + comp.mount(container); + comp.setDisabled("Read-only channel"); + const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement; + textarea.value = "hello"; + textarea.selectionStart = 0; + textarea.selectionEnd = 5; + press(textarea, "b"); + expect(textarea.value).toBe("hello"); + comp.destroy?.(); + }); + }); + + describe("wrapWithMarker", () => { + it("wraps a selection and reselects the inner text", () => { + expect(wrapWithMarker("abc", 1, 2, "~~")).toEqual({ + value: "a~~b~~c", + selectionStart: 3, + selectionEnd: 4, + }); + }); + + it("unwraps markers that surround the selection", () => { + expect(wrapWithMarker("a**b**c", 3, 4, "**")).toEqual({ + value: "abc", + selectionStart: 1, + selectionEnd: 2, + }); + }); + + it("does not mistake a short selection for a wrapped one", () => { + expect(wrapWithMarker("**", 0, 2, "**").value).toBe("******"); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/message-jump.test.ts b/Client/tauri-client/tests/unit/message-jump.test.ts new file mode 100644 index 00000000..843cb589 --- /dev/null +++ b/Client/tauri-client/tests/unit/message-jump.test.ts @@ -0,0 +1,629 @@ +/** + * Message navigation: the jump orchestrator, the affordances that feed it, and + * the "Jump to Present" pill that says the bottom of the list is not "now". + * + * The behaviour worth pinning is what happens when the target is *not* loaded. + * Before this, a search hit or pinned entry outside the loaded page simply said + * "not in loaded history" and stopped; now every jump goes through one path + * that fetches the around-window, detaches the channel, and scrolls. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("@lib/livekitSession", () => ({ + leaveVoice: vi.fn(), + switchInputDevice: vi.fn(), + switchOutputDevice: vi.fn(), + setVoiceSensitivity: vi.fn(), + setInputVolume: vi.fn(), + setOutputVolume: vi.fn(), + getSessionDebugInfo: vi.fn().mockReturnValue({}), +})); + +const toastCalls: Array<{ msg: string; type: string }> = []; +vi.mock("@lib/toast", () => ({ + showToast: (msg: string, type: string) => { + toastCalls.push({ msg, type }); + }, + initToast: vi.fn(), +})); + +// jsdom has no ResizeObserver — MessageList needs one to mount. +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; +} + +import { createMessageJumper } from "../../src/pages/main-page/MessageJump"; +import { createMessageList } from "@components/MessageList"; +import type { MessageListOptions } from "@components/MessageList"; +import { renderMessage } from "../../src/components/message-list/renderers"; +import { renderMentionSegment } from "../../src/components/message-list/content-parser"; +import { + jumpToMessage, + setMessageJumpHandler, + hasMessageJumpHandler, +} from "@lib/message-navigation"; +import { messagesStore, setAroundMessages } from "@stores/messages.store"; +import type { Message } from "@stores/messages.store"; +import { channelsStore, setChannels } from "@stores/channels.store"; +import { membersStore } from "@stores/members.store"; +import { authStore } from "@stores/auth.store"; +import { ApiClientError } from "@lib/api"; +import type { ApiClient } from "@lib/api"; +import type { MessageResponse, ReadyChannel } from "@lib/types"; + +const CHANNELS: ReadyChannel[] = [ + { id: 1, name: "general", type: "text", category: null, position: 0 }, + { id: 2, name: "off-topic", type: "text", category: null, position: 1 }, +]; + +function resetStores(): void { + messagesStore.setState(() => ({ + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), + historyLoadState: new Map(), + detachedChannels: new Set(), + })); + membersStore.setState(() => ({ members: new Map(), typingUsers: new Map() })); + authStore.setState(() => ({ + token: "t", + user: { id: 1, username: "alice", avatar: null, role: "member" }, + serverName: null, + motd: null, + isAuthenticated: true, + })); + channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] })); + setChannels(CHANNELS); + toastCalls.length = 0; +} + +function makeMessage(overrides: Partial & { id: number }): Message { + return { + channelId: 1, + user: { id: 1, username: "Alice", avatar: null }, + content: `Message ${overrides.id}`, + replyTo: null, + attachments: [], + reactions: [], + pinned: false, + editedAt: null, + deleted: false, + timestamp: "2026-01-15T12:00:00Z", + status: "sent", + correlationId: null, + errorCode: null, + ...overrides, + }; +} + +function response(id: number): MessageResponse { + return { + id, + channel_id: 1, + user: { id: 1, username: "alice", avatar: null }, + content: `msg ${id}`, + reply_to: null, + attachments: [], + reactions: [], + pinned: false, + edited_at: null, + deleted: false, + timestamp: "2026-01-15T11:00:00Z", + }; +} + +/** A stand-in ChannelController: only what the jumper actually touches. */ +function fakeCtrl(channelId: number | null, scrollResult = true) { + const scrollToMessage = vi.fn().mockReturnValue(scrollResult); + return { + ctrl: { + currentChannelId: channelId, + messageList: { scrollToMessage, mount: vi.fn(), destroy: vi.fn() }, + mountChannel: vi.fn(), + destroyChannel: vi.fn(), + openFilePicker: vi.fn(), + }, + scrollToMessage, + }; +} + +function fakeApi(getMessagesAround: unknown): ApiClient { + return { getMessagesAround } as unknown as ApiClient; +} + +/** Resolve immediately instead of waiting for a real animation frame. */ +const immediateFrame = (): Promise => Promise.resolve(); + +/** Store notifications are batched via queueMicrotask — let them land. */ +const flushStore = (): Promise => Promise.resolve(); + +beforeEach(() => { + resetStores(); +}); + +// --------------------------------------------------------------------------- + +describe("createMessageJumper", () => { + it("scrolls without fetching when the message is already loaded", async () => { + const { ctrl, scrollToMessage } = fakeCtrl(1); + const getMessagesAround = vi.fn(); + const jumper = createMessageJumper({ + api: fakeApi(getMessagesAround), + getChannelCtrl: () => ctrl, + nextFrame: immediateFrame, + }); + + await expect(jumper.jumpTo(1, 42)).resolves.toBe(true); + + expect(scrollToMessage).toHaveBeenCalledWith(42); + expect(getMessagesAround).not.toHaveBeenCalled(); + }); + + it("fetches the around-window when the message is not loaded, then scrolls", async () => { + // First scroll attempt misses (not loaded), second lands after the fetch. + const scrollToMessage = vi.fn().mockReturnValueOnce(false).mockReturnValue(true); + const ctrl = { + currentChannelId: 1, + messageList: { scrollToMessage }, + } as unknown as ReturnType["ctrl"]; + const getMessagesAround = vi.fn().mockResolvedValue({ + messages: [response(40), response(41), response(42)], + has_more_before: true, + has_more_after: true, + }); + const jumper = createMessageJumper({ + api: fakeApi(getMessagesAround), + getChannelCtrl: () => ctrl, + nextFrame: immediateFrame, + }); + + await expect(jumper.jumpTo(1, 42)).resolves.toBe(true); + + expect(getMessagesAround).toHaveBeenCalledWith(1, 42, { limit: 50 }); + // The window landed in the store and left the channel detached. + expect( + messagesStore + .getState() + .messagesByChannel.get(1) + ?.map((m) => m.id), + ).toEqual([40, 41, 42]); + expect(messagesStore.getState().detachedChannels.has(1)).toBe(true); + }); + + it("opens the channel first when the target lives elsewhere", async () => { + const scrollToMessage = vi.fn().mockReturnValue(true); + const ctrl = { + currentChannelId: 1, + messageList: { scrollToMessage }, + } as unknown as ReturnType["ctrl"]; + const jumper = createMessageJumper({ + api: fakeApi(vi.fn()), + getChannelCtrl: () => ctrl, + nextFrame: () => { + // The channel switch is what the frame is waiting for. + (ctrl as { currentChannelId: number }).currentChannelId = 2; + return Promise.resolve(); + }, + }); + + await jumper.jumpTo(2, 7); + + expect(channelsStore.getState().activeChannelId).toBe(2); + expect(scrollToMessage).toHaveBeenCalledWith(7); + }); + + it("refuses a channel this user cannot see, without calling the API", async () => { + const { ctrl } = fakeCtrl(1); + const getMessagesAround = vi.fn(); + const jumper = createMessageJumper({ + api: fakeApi(getMessagesAround), + getChannelCtrl: () => ctrl, + nextFrame: immediateFrame, + }); + + await expect(jumper.jumpTo(999, 42)).resolves.toBe(false); + + expect(getMessagesAround).not.toHaveBeenCalled(); + expect(toastCalls.at(-1)?.msg).toMatch(/isn't available/i); + }); + + it("reports a deleted or missing message from a 404", async () => { + const scrollToMessage = vi.fn().mockReturnValue(false); + const ctrl = { + currentChannelId: 1, + messageList: { scrollToMessage }, + } as unknown as ReturnType["ctrl"]; + const jumper = createMessageJumper({ + api: fakeApi( + vi.fn().mockRejectedValue(new ApiClientError(404, "NOT_FOUND", "message not found")), + ), + getChannelCtrl: () => ctrl, + nextFrame: immediateFrame, + }); + + await expect(jumper.jumpTo(1, 42)).resolves.toBe(false); + + expect(toastCalls.at(-1)?.msg).toMatch(/no longer exists/i); + // A failed jump must not detach the channel from the live tail. + expect(messagesStore.getState().detachedChannels.has(1)).toBe(false); + }); + + it("surfaces a transport failure without detaching the channel", async () => { + const scrollToMessage = vi.fn().mockReturnValue(false); + const ctrl = { + currentChannelId: 1, + messageList: { scrollToMessage }, + } as unknown as ReturnType["ctrl"]; + const jumper = createMessageJumper({ + api: fakeApi(vi.fn().mockRejectedValue(new Error("offline"))), + getChannelCtrl: () => ctrl, + nextFrame: immediateFrame, + }); + + await expect(jumper.jumpTo(1, 42)).resolves.toBe(false); + + expect(toastCalls.at(-1)?.type).toBe("error"); + expect(messagesStore.getState().detachedChannels.has(1)).toBe(false); + }); + + it("fails loudly when the window comes back without the centre", async () => { + const scrollToMessage = vi.fn().mockReturnValue(false); + const ctrl = { + currentChannelId: 1, + messageList: { scrollToMessage }, + } as unknown as ReturnType["ctrl"]; + const jumper = createMessageJumper({ + api: fakeApi( + vi.fn().mockResolvedValue({ + messages: [response(1), response(2)], + has_more_before: false, + has_more_after: false, + }), + ), + getChannelCtrl: () => ctrl, + nextFrame: immediateFrame, + }); + + // Landing silently on a neighbour would look like a successful jump to the + // wrong message. + await expect(jumper.jumpTo(1, 42)).resolves.toBe(false); + expect(toastCalls.at(-1)?.type).toBe("error"); + }); + + it("gives up quietly with no mounted channel controller", async () => { + const jumper = createMessageJumper({ + api: fakeApi(vi.fn()), + getChannelCtrl: () => null, + nextFrame: immediateFrame, + }); + + await expect(jumper.jumpTo(1, 42)).resolves.toBe(false); + }); +}); + +// --------------------------------------------------------------------------- + +describe("message-navigation registry", () => { + it("is a no-op before a page registers a handler", () => { + expect(hasMessageJumpHandler()).toBe(false); + expect(() => jumpToMessage(1, 2)).not.toThrow(); + }); + + it("routes jumps to the registered handler and unregisters cleanly", () => { + const handler = vi.fn(); + const unregister = setMessageJumpHandler(handler); + + jumpToMessage(5, 42); + expect(handler).toHaveBeenCalledWith(5, 42); + + unregister(); + expect(hasMessageJumpHandler()).toBe(false); + }); + + it("a stale unregister does not clear a newer handler", () => { + const first = vi.fn(); + const unregisterFirst = setMessageJumpHandler(first); + const second = vi.fn(); + setMessageJumpHandler(second); + + unregisterFirst(); + + jumpToMessage(1, 1); + expect(second).toHaveBeenCalled(); + expect(first).not.toHaveBeenCalled(); + expect(hasMessageJumpHandler()).toBe(true); + }); + + afterEach(() => { + setMessageJumpHandler(() => {})(); + }); +}); + +// --------------------------------------------------------------------------- + +describe("reply bar jump wiring", () => { + function renderRow(msg: Message, all: Message[], opts: Partial = {}) { + const options = { + channelId: 1, + channelName: "general", + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + onPinClick: vi.fn(), + ...opts, + } as MessageListOptions; + return renderMessage(msg, false, all, options, new AbortController().signal); + } + + it("clicking the quoted bar jumps to the replied-to message", () => { + const onJumpToMessage = vi.fn(); + const target = makeMessage({ id: 10, content: "the original" }); + const reply = makeMessage({ id: 11, replyTo: 10 }); + + const row = renderRow(reply, [target, reply], { onJumpToMessage }); + const bar = row.querySelector(".msg-reply-ref"); + expect(bar).not.toBeNull(); + bar!.click(); + + expect(onJumpToMessage).toHaveBeenCalledWith(10); + }); + + it("stays clickable when the replied-to message is outside the window", () => { + // The id is known even though the row is not loaded — that is exactly the + // case the around-window fetch exists for. + const onJumpToMessage = vi.fn(); + const reply = makeMessage({ id: 11, replyTo: 999 }); + + const row = renderRow(reply, [reply], { onJumpToMessage }); + const bar = row.querySelector(".msg-reply-ref"); + expect(bar?.textContent).toMatch(/unknown message/i); + expect(bar?.getAttribute("data-reply-to")).toBe("999"); + + bar!.click(); + expect(onJumpToMessage).toHaveBeenCalledWith(999); + }); + + it("is keyboard reachable and activates on Enter", () => { + const onJumpToMessage = vi.fn(); + const target = makeMessage({ id: 10 }); + const reply = makeMessage({ id: 11, replyTo: 10 }); + + const row = renderRow(reply, [target, reply], { onJumpToMessage }); + const bar = row.querySelector(".msg-reply-ref")!; + expect(bar.getAttribute("role")).toBe("button"); + expect(bar.getAttribute("tabindex")).toBe("0"); + + bar.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(onJumpToMessage).toHaveBeenCalledWith(10); + }); + + it("does nothing when no jump handler is wired", () => { + const reply = makeMessage({ id: 11, replyTo: 10 }); + const row = renderRow(reply, [reply]); + expect(() => row.querySelector(".msg-reply-ref")!.click()).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- + +describe("Copy Message Link action", () => { + it("copies the owncord:// permalink for the row", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + const msg = makeMessage({ id: 42, channelId: 7 }); + const row = renderMessage( + msg, + false, + [msg], + { + channelId: 7, + channelName: "general", + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + onPinClick: vi.fn(), + } as MessageListOptions, + new AbortController().signal, + ); + + const btn = row.querySelector('[data-testid="msg-copy-link-42"]'); + expect(btn).not.toBeNull(); + btn!.click(); + await Promise.resolve(); + + expect(writeText).toHaveBeenCalledWith("owncord://message/7/42"); + }); +}); + +// --------------------------------------------------------------------------- + +describe("permalink chips in message content", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders a pasted permalink as a compact chip naming the channel", () => { + container.appendChild(renderMentionSegment("see owncord://message/2/99 for context")); + + const chip = container.querySelector(".message-link-chip"); + expect(chip).not.toBeNull(); + expect(chip!.querySelector(".mlc-channel")?.textContent).toBe("#off-topic"); + expect(chip!.querySelector(".mlc-action")?.textContent).toBe("Jump"); + expect(chip!.getAttribute("data-channel-id")).toBe("2"); + expect(chip!.getAttribute("data-message-id")).toBe("99"); + // The raw URL is gone — the chip replaced it, not decorated it. + expect(container.textContent).not.toContain("owncord://"); + }); + + it("clicking the chip routes through the jump registry", () => { + const handler = vi.fn(); + const unregister = setMessageJumpHandler(handler); + container.appendChild(renderMentionSegment("owncord://message/2/99")); + + container.querySelector(".message-link-chip")!.click(); + + expect(handler).toHaveBeenCalledWith(2, 99); + unregister(); + }); + + it("activates on Enter for keyboard users", () => { + const handler = vi.fn(); + const unregister = setMessageJumpHandler(handler); + container.appendChild(renderMentionSegment("owncord://message/1/5")); + + const chip = container.querySelector(".message-link-chip")!; + expect(chip.getAttribute("role")).toBe("link"); + expect(chip.getAttribute("tabindex")).toBe("0"); + chip.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(handler).toHaveBeenCalledWith(1, 5); + unregister(); + }); + + it("leaves a link to an invisible channel as plain text", () => { + container.appendChild(renderMentionSegment("owncord://message/404/1")); + + expect(container.querySelector(".message-link-chip")).toBeNull(); + expect(container.textContent).toBe("owncord://message/404/1"); + }); + + it("leaves a malformed permalink as plain text", () => { + container.appendChild(renderMentionSegment("owncord://message/abc/1")); + + expect(container.querySelector(".message-link-chip")).toBeNull(); + expect(container.textContent).toBe("owncord://message/abc/1"); + }); + + it("chips several permalinks in one message", () => { + container.appendChild(renderMentionSegment("owncord://message/1/5 and owncord://message/2/6")); + + expect(container.querySelectorAll(".message-link-chip")).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- + +describe("Jump to Present pill", () => { + let container: HTMLDivElement; + let list: ReturnType; + + function baseOptions(overrides: Partial = {}): MessageListOptions { + return { + channelId: 1, + channelName: "general", + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + onPinClick: vi.fn(), + ...overrides, + } as MessageListOptions; + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + list.destroy?.(); + container.remove(); + }); + + it("stays hidden while the window is attached to the live tail", () => { + list = createMessageList(baseOptions()); + list.mount(container); + + const pill = container.querySelector('[data-testid="jump-to-present"]'); + expect(pill).not.toBeNull(); + expect(pill!.classList.contains("visible")).toBe(false); + }); + + it("appears when an around-window detaches the channel", async () => { + list = createMessageList(baseOptions()); + list.mount(container); + + setAroundMessages(1, [response(1), response(2)], true, true); + await flushStore(); + + const pill = container.querySelector('[data-testid="jump-to-present"]')!; + expect(pill.classList.contains("visible")).toBe(true); + }); + + it("is already visible when mounting into an detached channel", () => { + setAroundMessages(1, [response(1)], true, true); + + list = createMessageList(baseOptions()); + list.mount(container); + + expect( + container.querySelector('[data-testid="jump-to-present"]')!.classList.contains("visible"), + ).toBe(true); + }); + + it("hides again once the channel reattaches", async () => { + list = createMessageList(baseOptions()); + list.mount(container); + setAroundMessages(1, [response(1)], true, true); + await flushStore(); + expect( + container.querySelector('[data-testid="jump-to-present"]')!.classList.contains("visible"), + ).toBe(true); + + setAroundMessages(1, [response(1)], true, false); + await flushStore(); + + expect( + container.querySelector('[data-testid="jump-to-present"]')!.classList.contains("visible"), + ).toBe(false); + }); + + it("clicking it asks the controller to reload the tail", async () => { + const onJumpToPresent = vi.fn(); + list = createMessageList(baseOptions({ onJumpToPresent })); + list.mount(container); + setAroundMessages(1, [response(1)], true, true); + await flushStore(); + + container.querySelector('[data-testid="jump-to-present"]')!.click(); + + expect(onJumpToPresent).toHaveBeenCalledTimes(1); + }); + + it("tracks only its own channel", async () => { + list = createMessageList(baseOptions()); + list.mount(container); + + setAroundMessages(2, [response(1)], true, true); + await flushStore(); + + expect( + container.querySelector('[data-testid="jump-to-present"]')!.classList.contains("visible"), + ).toBe(false); + }); +}); diff --git a/Client/tauri-client/tests/unit/message-list-media-release.test.ts b/Client/tauri-client/tests/unit/message-list-media-release.test.ts index 7057993e..f33b6fde 100644 --- a/Client/tauri-client/tests/unit/message-list-media-release.test.ts +++ b/Client/tauri-client/tests/unit/message-list-media-release.test.ts @@ -41,6 +41,7 @@ function resetStores(): void { loadedChannels: new Set(), hasMore: new Map(), historyLoadState: new Map(), + detachedChannels: new Set(), })); membersStore.setState(() => ({ members: new Map(), diff --git a/Client/tauri-client/tests/unit/message-list-new-divider.test.ts b/Client/tauri-client/tests/unit/message-list-new-divider.test.ts new file mode 100644 index 00000000..8de01a33 --- /dev/null +++ b/Client/tauri-client/tests/unit/message-list-new-divider.test.ts @@ -0,0 +1,233 @@ +/** + * The "NEW" divider: a line above the first message the reader has not seen, + * placed from the unread count the channel carried when it was opened (the + * badge itself is cleared by the visit, so the count has to be snapshotted). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; +} + +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"; +import { channelsStore, setChannels, setActiveChannel } from "@stores/channels.store"; +import type { ReadyChannel } from "@lib/types"; + +const CHANNEL_ID = 1; + +function resetStores(): void { + messagesStore.setState(() => ({ + messagesByChannel: new Map(), + pendingSends: new Map(), + loadedChannels: new Set(), + hasMore: new Map(), + historyLoadState: new Map(), + detachedChannels: new Set(), + })); + membersStore.setState(() => ({ members: new Map(), typingUsers: new Map() })); + channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] })); +} + +function makeMessage(id: number): Message { + return { + id, + channelId: CHANNEL_ID, + user: { id: 1, username: "Alice", avatar: null }, + content: `Message ${id}`, + replyTo: null, + attachments: [], + reactions: [], + pinned: false, + editedAt: null, + deleted: false, + // Spaced far enough apart that grouping never merges rows. + timestamp: new Date(Date.UTC(2024, 0, 15, 12, id * 5)).toISOString(), + status: "sent", + correlationId: null, + errorCode: null, + }; +} + +function setMessages(messages: readonly Message[]): void { + messagesStore.setState((prev) => { + const next = new Map(prev.messagesByChannel); + next.set(CHANNEL_ID, [...messages]); + return { ...prev, messagesByChannel: next }; + }); +} + +function setDetached(detached: boolean): void { + messagesStore.setState((prev) => { + const next = new Set(prev.detachedChannels); + if (detached) next.add(CHANNEL_ID); + else next.delete(CHANNEL_ID); + return { ...prev, detachedChannels: next }; + }); +} + +/** Seed the channel list with `unread` unread messages, then "open" the channel + * the way the app does — which clears the badge and snapshots the count. */ +function openChannelWithUnread(unread: number): void { + const ch: ReadyChannel = { + id: CHANNEL_ID, + name: "general", + type: "text", + category: null, + position: 0, + unread_count: unread, + mention_count: 0, + }; + setChannels([ch]); + setActiveChannel(CHANNEL_ID); +} + +describe("MessageList — new-messages divider", () => { + let container: HTMLDivElement; + let msgList: ReturnType | null = null; + let options: MessageListOptions; + + beforeEach(() => { + resetStores(); + container = document.createElement("div"); + document.body.appendChild(container); + options = { + channelId: CHANNEL_ID, + channelName: "general", + currentUserId: 1, + onScrollTop: vi.fn(), + onReplyClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), + onReactionClick: vi.fn(), + onPinClick: vi.fn(), + }; + }); + + afterEach(() => { + msgList?.destroy?.(); + msgList = null; + container.remove(); + }); + + function mount(): void { + msgList = createMessageList(options); + msgList.mount(container); + } + + function dividerIndex(): number { + const rows = [...container.querySelectorAll(".virtual-content > *")]; + return rows.findIndex((el) => el.classList.contains("msg-new-divider")); + } + + it("renders no divider when the channel was opened with nothing unread", () => { + setMessages([1, 2, 3].map(makeMessage)); + openChannelWithUnread(0); + mount(); + + expect(container.querySelector('[data-testid="new-messages-divider"]')).toBeNull(); + }); + + it("places the divider above the first unread message", () => { + setMessages([1, 2, 3, 4, 5].map(makeMessage)); + openChannelWithUnread(2); + mount(); + + const divider = container.querySelector('[data-testid="new-messages-divider"]'); + expect(divider).not.toBeNull(); + expect(divider?.textContent).toContain("NEW"); + + // The row right after the divider must be message 4 — the first of the + // last two (unread) messages. + const next = divider?.nextElementSibling as HTMLElement; + expect(next.dataset.testid).toBe("message-4"); + }); + + it("places the divider at the top when every loaded message is unread", () => { + setMessages([1, 2, 3].map(makeMessage)); + openChannelWithUnread(10); + mount(); + + // Index 0 is the day divider; the NEW line comes right after it and + // before the first message. + const idx = dividerIndex(); + expect(idx).toBeGreaterThanOrEqual(0); + const next = container.querySelectorAll(".virtual-content > *")[idx + 1] as HTMLElement; + expect(next.dataset.testid).toBe("message-1"); + }); + + it("renders exactly one divider", () => { + setMessages([1, 2, 3, 4, 5].map(makeMessage)); + openChannelWithUnread(3); + mount(); + + expect(container.querySelectorAll('[data-testid="new-messages-divider"]')).toHaveLength(1); + }); + + it("renders no divider for an empty channel", () => { + setMessages([]); + openChannelWithUnread(5); + mount(); + + expect(container.querySelector('[data-testid="new-messages-divider"]')).toBeNull(); + }); + + // A detached window is a slice around some old message, so "the last N + // loaded messages" no longer identifies the unread ones. + it("suppresses the divider while the window is detached", () => { + setMessages([1, 2, 3, 4, 5].map(makeMessage)); + openChannelWithUnread(2); + setDetached(true); + mount(); + + expect(container.querySelector('[data-testid="new-messages-divider"]')).toBeNull(); + }); + + it("clears on the next visit to the channel", () => { + setMessages([1, 2, 3, 4, 5].map(makeMessage)); + openChannelWithUnread(2); + mount(); + expect(container.querySelector('[data-testid="new-messages-divider"]')).not.toBeNull(); + + // Leave and come back: the badge is gone now, so no divider. + msgList?.destroy?.(); + container.remove(); + container = document.createElement("div"); + document.body.appendChild(container); + setActiveChannel(null); + setActiveChannel(CHANNEL_ID); + mount(); + + expect(container.querySelector('[data-testid="new-messages-divider"]')).toBeNull(); + }); + + // The line marks a boundary; the message under it must not be rendered as a + // grouped continuation of the message above the line. + it("breaks message grouping at the divider", () => { + const sameMinute = [1, 2, 3].map((id) => ({ + ...makeMessage(id), + timestamp: "2024-01-15T12:00:00Z", + })); + setMessages(sameMinute); + openChannelWithUnread(1); + mount(); + + const divider = container.querySelector('[data-testid="new-messages-divider"]'); + const first = divider?.nextElementSibling as HTMLElement; + expect(first.dataset.testid).toBe("message-3"); + expect(first.classList.contains("grouped")).toBe(false); + // The message before the line is still grouped — only the boundary breaks. + expect( + (container.querySelector('[data-testid="message-2"]') as HTMLElement).classList.contains( + "grouped", + ), + ).toBe(true); + }); +}); diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts index 18e07031..9e361a2f 100644 --- a/Client/tauri-client/tests/unit/message-list.test.ts +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -28,6 +28,7 @@ function resetStores(): void { loadedChannels: new Set(), hasMore: new Map(), historyLoadState: new Map(), + detachedChannels: new Set(), })); membersStore.setState(() => ({ members: new Map(), @@ -227,6 +228,30 @@ describe("MessageList", () => { expect(result).toBe(false); }); + it("scrollToMessage flashes the target row so the eye can find it", () => { + setMessages(1, [makeMessage({ id: 1 }), makeMessage({ id: 2 }), makeMessage({ id: 3 })]); + msgList.mount(container); + + msgList.scrollToMessage(2); + + // A scroll with no visual marker leaves the reader hunting; the row the + // jump landed on must be the one that flashes. + const flashed = container.querySelector(".highlight-flash"); + expect(flashed).not.toBeNull(); + expect(flashed!.getAttribute("data-testid")).toBe("message-2"); + }); + + it("scrollToMessage renders a target that was outside the rendered window", () => { + // A long channel: without forcing a rebuild the target stays unrendered + // and there is nothing to scroll to or flash. + const many = Array.from({ length: 200 }, (_, i) => makeMessage({ id: i + 1 })); + setMessages(1, many); + msgList.mount(container); + + expect(msgList.scrollToMessage(150)).toBe(true); + expect(container.querySelector('[data-testid="message-150"]')).not.toBeNull(); + }); + it("renders day dividers between messages on different days", () => { const messages = [ makeMessage({ id: 1, timestamp: "2024-01-15T12:00:00Z" }), diff --git a/Client/tauri-client/tests/unit/messages-store-detached.test.ts b/Client/tauri-client/tests/unit/messages-store-detached.test.ts new file mode 100644 index 00000000..4d62471b --- /dev/null +++ b/Client/tauri-client/tests/unit/messages-store-detached.test.ts @@ -0,0 +1,226 @@ +/** + * Detached-window behaviour in the messages store. + * + * Jumping to a message outside the loaded page replaces the channel's window + * with a server "around" window. That window is a *hole punched into history*: + * the messages below it are not loaded, so the bottom of the list is no longer + * "now". Everything here pins the consequences of that — the ordering the + * around payload arrives in, the detached flag, what happens to live + * broadcasts while detached, and how a channel gets back to the present. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { + messagesStore, + addMessage, + setMessages, + prependMessages, + setAroundMessages, + reattachToPresent, + clearChannelMessages, + getChannelMessages, + isChannelLoaded, + hasMoreMessages, + isWindowDetached, + hasMessageLoaded, +} from "../../src/stores/messages.store"; +import type { ChatMessagePayload, MessageResponse, MessageUser } from "../../src/lib/types"; + +const USER: MessageUser = { id: 1, username: "alice", avatar: null }; + +function response(id: number, overrides?: Partial): MessageResponse { + return { + id, + channel_id: 1, + user: USER, + content: `msg ${id}`, + reply_to: null, + attachments: [], + reactions: [], + pinned: false, + edited_at: null, + deleted: false, + timestamp: "2026-03-15T09:00:00Z", + ...overrides, + }; +} + +/** An around-window payload: oldest-first, unlike the history endpoint. */ +function ascendingWindow(from: number, to: number): MessageResponse[] { + const out: MessageResponse[] = []; + for (let id = from; id <= to; id++) out.push(response(id)); + return out; +} + +function broadcast(id: number): ChatMessagePayload { + return { + id, + channel_id: 1, + user: USER, + content: `live ${id}`, + reply_to: null, + attachments: [], + timestamp: "2026-03-15T12:00:00Z", + } as ChatMessagePayload; +} + +function ids(channelId: number): number[] { + return getChannelMessages(channelId).map((m) => m.id); +} + +beforeEach(() => { + clearChannelMessages(1); + clearChannelMessages(2); +}); + +describe("setAroundMessages", () => { + it("keeps the payload order — an around window is already oldest-first", () => { + setAroundMessages(1, ascendingWindow(10, 14), false, false); + + // setMessages reverses (the history endpoint is newest-first); this one + // must not, or every jump would render the window upside down. + expect(ids(1)).toEqual([10, 11, 12, 13, 14]); + }); + + it("replaces the loaded window rather than merging into it", () => { + setMessages(1, [response(300), response(299)], false); + setAroundMessages(1, ascendingWindow(10, 12), true, true); + + expect(ids(1)).toEqual([10, 11, 12]); + }); + + it("marks the channel loaded and maps has_more_before onto hasMore", () => { + setAroundMessages(1, ascendingWindow(10, 12), true, false); + + expect(isChannelLoaded(1)).toBe(true); + expect(hasMoreMessages(1)).toBe(true); + + setAroundMessages(1, ascendingWindow(1, 3), false, false); + expect(hasMoreMessages(1)).toBe(false); + }); + + it("detaches only when the server reports newer messages below", () => { + setAroundMessages(1, ascendingWindow(10, 12), true, true); + expect(isWindowDetached(1)).toBe(true); + + // A window that reaches the live tail is not detached: the bottom is now. + setAroundMessages(1, ascendingWindow(10, 12), true, false); + expect(isWindowDetached(1)).toBe(false); + }); + + it("detaches when the window itself had to be trimmed to the cap", () => { + // 600 > the 500-message cap: the newest 100 are dropped, which strands the + // tail exactly as has_more_after would have. + setAroundMessages(1, ascendingWindow(1, 600), false, false); + + const loaded = getChannelMessages(1); + expect(loaded).toHaveLength(500); + // The *centre-side* head is kept, so trimming drops from the end. + expect(loaded[0]!.id).toBe(1); + expect(loaded.at(-1)!.id).toBe(500); + expect(isWindowDetached(1)).toBe(true); + }); + + it("is scoped to one channel", () => { + setMessages(2, [response(500, { channel_id: 2 })], false); + setAroundMessages(1, ascendingWindow(10, 12), true, true); + + expect(isWindowDetached(2)).toBe(false); + expect(ids(2)).toEqual([500]); + }); +}); + +describe("live messages while detached", () => { + it("does not append a broadcast onto a detached window", () => { + setAroundMessages(1, ascendingWindow(10, 12), true, true); + + addMessage(broadcast(900)); + + // Appending would splice a message from "now" directly onto history from + // an hour ago with no gap shown — a lie about ordering. + expect(ids(1)).toEqual([10, 11, 12]); + }); + + it("still reconciles an edit-shaped rebroadcast of a loaded row", () => { + setAroundMessages(1, ascendingWindow(10, 12), true, true); + + addMessage({ ...broadcast(11), content: "reconciled" }); + + expect(ids(1)).toEqual([10, 11, 12]); + expect(getChannelMessages(1)[1]!.content).toBe("reconciled"); + }); + + it("appends normally again once reattached", () => { + setAroundMessages(1, ascendingWindow(10, 12), true, true); + reattachToPresent(1); + setMessages(1, [response(12), response(11), response(10)], false); + + addMessage(broadcast(900)); + + expect(ids(1)).toEqual([10, 11, 12, 900]); + }); +}); + +describe("reattachToPresent", () => { + it("clears the detached flag and the loaded flag so the tail is refetched", () => { + setAroundMessages(1, ascendingWindow(10, 12), true, true); + expect(isChannelLoaded(1)).toBe(true); + + reattachToPresent(1); + + expect(isWindowDetached(1)).toBe(false); + // Without clearing "loaded", MessageController short-circuits and the + // stale window stays on screen forever. + expect(isChannelLoaded(1)).toBe(false); + }); + + it("is a no-op for a channel that was never detached", () => { + setMessages(1, [response(10)], false); + const before = messagesStore.getState(); + + reattachToPresent(1); + + expect(messagesStore.getState()).toBe(before); + expect(isChannelLoaded(1)).toBe(true); + }); +}); + +describe("reattaching via a fresh tail fetch", () => { + it("setMessages clears the detached flag", () => { + setAroundMessages(1, ascendingWindow(10, 12), true, true); + + setMessages(1, [response(902), response(901)], true); + + expect(isWindowDetached(1)).toBe(false); + expect(ids(1)).toEqual([901, 902]); + }); + + it("clearChannelMessages clears the detached flag", () => { + setAroundMessages(1, ascendingWindow(10, 12), true, true); + + clearChannelMessages(1); + + expect(isWindowDetached(1)).toBe(false); + }); + + it("scrolling further up a detached window keeps it detached", () => { + setAroundMessages(1, ascendingWindow(10, 12), true, true); + + // prependMessages is the infinite-scroll path; it touches history above + // the window and says nothing about the tail below it. + prependMessages(1, [response(9), response(8)], true); + + expect(ids(1)).toEqual([8, 9, 10, 11, 12]); + expect(isWindowDetached(1)).toBe(true); + }); +}); + +describe("hasMessageLoaded", () => { + it("reports membership of the loaded window", () => { + setAroundMessages(1, ascendingWindow(10, 12), true, true); + + expect(hasMessageLoaded(1, 11)).toBe(true); + expect(hasMessageLoaded(1, 900)).toBe(false); + expect(hasMessageLoaded(2, 11)).toBe(false); + }); +}); diff --git a/Client/tauri-client/tests/unit/messages.store.test.ts b/Client/tauri-client/tests/unit/messages.store.test.ts index cd85dcf8..18076891 100644 --- a/Client/tauri-client/tests/unit/messages.store.test.ts +++ b/Client/tauri-client/tests/unit/messages.store.test.ts @@ -6,6 +6,7 @@ import { prependMessages, editMessage, deleteMessage, + bulkDeleteMessages, setMessagePinned, updateReaction, addPendingSend, @@ -360,6 +361,43 @@ describe("messages store", () => { }); }); + // 6b. bulkDeleteMessages (channel purge) + describe("bulkDeleteMessages", () => { + it("marks every id as deleted while keeping the rows", () => { + for (const id of [100, 101, 102]) { + addMessage(makeChatPayload({ id, channel_id: 1 })); + } + + bulkDeleteMessages({ channel_id: 1, ids: [102, 101] }); + + const msgs = getChannelMessages(1); + expect(msgs).toHaveLength(3); + expect(msgs.find((m) => m.id === 102)!.deleted).toBe(true); + expect(msgs.find((m) => m.id === 101)!.deleted).toBe(true); + expect(msgs.find((m) => m.id === 100)!.deleted).toBe(false); + }); + + it("ignores ids that are not loaded", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + + bulkDeleteMessages({ channel_id: 1, ids: [100, 999] }); + + expect(getChannelMessages(1)).toHaveLength(1); + expect(getChannelMessages(1)[0]!.deleted).toBe(true); + }); + + it("is a no-op for an unknown channel, an empty id list, and a repeat purge", () => { + addMessage(makeChatPayload({ id: 100, channel_id: 1 })); + bulkDeleteMessages({ channel_id: 1, ids: [100] }); + + const before = messagesStore.getState(); + bulkDeleteMessages({ channel_id: 99, ids: [1] }); + bulkDeleteMessages({ channel_id: 1, ids: [] }); + bulkDeleteMessages({ channel_id: 1, ids: [100] }); + expect(messagesStore.getState()).toBe(before); + }); + }); + // 7. addPendingSend / confirmSend lifecycle describe("pending send lifecycle", () => { it("addPendingSend tracks correlationId -> channelId", () => { @@ -1011,3 +1049,65 @@ describe("messages store", () => { }); }); }); + +describe("mention plumbing", () => { + it("carries mentions from a chat_message payload onto the store row", () => { + addMessage({ + id: 1, + channel_id: 1, + user: TEST_USER, + content: "hi @bob @everyone", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + mentions: [2], + mentions_everyone: true, + } as ChatMessagePayload); + + const msg = messagesStore.getState().messagesByChannel.get(1)![0]!; + expect(msg.mentions).toEqual([2]); + expect(msg.mentionsEveryone).toBe(true); + }); + + it("leaves them undefined when an older server omits them", () => { + addMessage({ + id: 1, + channel_id: 1, + user: TEST_USER, + content: "hi", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + } as ChatMessagePayload); + + const msg = messagesStore.getState().messagesByChannel.get(1)![0]!; + expect(msg.mentions).toBeUndefined(); + expect(msg.mentionsEveryone).toBeUndefined(); + }); + + it("replaces mentions on edit — an edit re-resolves but never re-notifies", () => { + addMessage({ + id: 1, + channel_id: 1, + user: TEST_USER, + content: "hi @bob", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + mentions: [2], + mentions_everyone: false, + } as ChatMessagePayload); + + editMessage({ + message_id: 1, + channel_id: 1, + content: "hi @carol", + edited_at: "2026-03-15T10:01:00Z", + mentions: [3], + mentions_everyone: false, + } as ChatEditedPayload); + + const msg = messagesStore.getState().messagesByChannel.get(1)![0]!; + expect(msg.mentions).toEqual([3]); + }); +}); diff --git a/Client/tauri-client/tests/unit/notifications.test.ts b/Client/tauri-client/tests/unit/notifications.test.ts index 5dd78391..64ca588b 100644 --- a/Client/tauri-client/tests/unit/notifications.test.ts +++ b/Client/tauri-client/tests/unit/notifications.test.ts @@ -121,9 +121,14 @@ describe("notifyIncomingMessage", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }, ], ]), @@ -135,84 +140,151 @@ describe("notifyIncomingMessage", () => { vi.spyOn(document, "hasFocus").mockReturnValue(false); }); - it("does not notify for own messages", () => { + it("does not notify for own messages", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); const payload = makePayload({ user: { id: 1, username: "Me", avatar: null } }); notifyIncomingMessage(payload); + await new Promise((r) => setTimeout(r, 50)); + expect(sendNotification).not.toHaveBeenCalled(); }); - it("does not notify when window is focused and message is in active channel", () => { + it("does not notify when window is focused and message is in active channel", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); vi.spyOn(document, "hasFocus").mockReturnValue(true); channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); const payload = makePayload({ channel_id: 1 }); notifyIncomingMessage(payload); + await new Promise((r) => setTimeout(r, 50)); + expect(sendNotification).not.toHaveBeenCalled(); }); - it("notifies when window is focused but message is in a different channel", () => { + it("notifies when window is focused but message is in a different channel", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); vi.spyOn(document, "hasFocus").mockReturnValue(true); channelsStore.setState((prev) => ({ ...prev, activeChannelId: 2 })); const payload = makePayload({ channel_id: 1 }); notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + }); }); - it("suppresses @everyone when toggle is enabled", () => { + it("suppresses @everyone when toggle is enabled", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); testPrefs.set("suppressEveryone", true); - const payload = makePayload({ content: "Hey @everyone check this out" }); + const payload = makePayload({ + content: "Hey @everyone check this out", + mentions_everyone: true, + }); notifyIncomingMessage(payload); + await new Promise((r) => setTimeout(r, 50)); + expect(sendNotification).not.toHaveBeenCalled(); }); - it("does not suppress @everyone when toggle is disabled", () => { + it("does not suppress @everyone when toggle is disabled", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); testPrefs.set("suppressEveryone", false); const payload = makePayload({ content: "Hey @everyone check this out" }); notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + }); }); - it("handles long messages by truncating", () => { + it("handles long messages by truncating", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); const longContent = "A".repeat(200); const payload = makePayload({ content: longContent }); notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ body: "A".repeat(100) + "..." }), + ); + }); }); - it("handles @here the same as @everyone", () => { + it("handles @here the same as @everyone", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); testPrefs.set("suppressEveryone", true); - const payload = makePayload({ content: "Hey @here important update" }); + const payload = makePayload({ content: "Hey @here important update", mentions_everyone: true }); notifyIncomingMessage(payload); + await new Promise((r) => setTimeout(r, 50)); + expect(sendNotification).not.toHaveBeenCalled(); }); - it("skips desktop notification when toggle is off", () => { + it("skips desktop notification when toggle is off", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); testPrefs.set("desktopNotifications", false); const payload = makePayload(); notifyIncomingMessage(payload); + await new Promise((r) => setTimeout(r, 50)); + expect(sendNotification).not.toHaveBeenCalled(); }); - it("skips taskbar flash when toggle is off", () => { + it("skips taskbar flash when toggle is off", async () => { + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const win = getCurrentWindow(); + (win.requestUserAttention as ReturnType).mockClear(); testPrefs.set("flashTaskbar", false); const payload = makePayload(); notifyIncomingMessage(payload); + await new Promise((r) => setTimeout(r, 50)); + expect(win.requestUserAttention).not.toHaveBeenCalled(); }); it("skips notification sound when toggle is off", () => { + mockOscillator.start.mockClear(); testPrefs.set("notificationSounds", false); const payload = makePayload(); notifyIncomingMessage(payload); + expect(mockOscillator.start).not.toHaveBeenCalled(); }); - it("falls back to channel ID string when channel is not in store", () => { + it("falls back to channel ID string when channel is not in store", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); // Set channels store to have no channels channelsStore.setState((prev) => ({ ...prev, channels: new Map() })); const payload = makePayload({ channel_id: 999 }); // Should not throw; uses fallback "Channel 999" notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: expect.stringContaining("Channel 999") }), + ); + }); }); - it("notifies when window is not focused, even for active channel", () => { + it("notifies when window is not focused, even for active channel", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); vi.spyOn(document, "hasFocus").mockReturnValue(false); channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); const payload = makePayload({ channel_id: 1 }); // Should proceed to notification since window is not focused notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + }); }); - it("does not notify when current user is null", () => { + // NOTE: original title was "does not notify when current user is null" but + // the code (see the guard clause in notifyIncomingMessage: `currentUser !== + // null && payload.user.id === currentUser.id`) and the original inline + // comment both make clear a null/logged-out user is NOT treated as a match + // and the notification proceeds. Renamed to match actual, intended + // behavior (also covered by "proceeds when current user is null" below). + it("notifies when current user is null (not logged in)", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); authStore.setState(() => ({ token: null, user: null, @@ -223,47 +295,91 @@ describe("notifyIncomingMessage", () => { // payload.user.id = 2 (different from null user), should proceed const payload = makePayload(); notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + }); }); - it("fires all notification types when all enabled", () => { - // All defaults are true, so just fire and confirm no error + it("fires all notification types when all enabled", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const win = getCurrentWindow(); + (sendNotification as ReturnType).mockClear(); + (win.requestUserAttention as ReturnType).mockClear(); + mockOscillator.start.mockClear(); + + // All defaults are true, so just fire and confirm all three channels fired testPrefs.set("desktopNotifications", true); testPrefs.set("flashTaskbar", true); testPrefs.set("notificationSounds", true); const payload = makePayload(); notifyIncomingMessage(payload); + + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + expect(win.requestUserAttention).toHaveBeenCalled(); + }); + expect(mockOscillator.start).toHaveBeenCalled(); }); - it("handles short content without truncation", () => { + it("handles short content without truncation", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); const payload = makePayload({ content: "Hi" }); notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ body: "Hi" })); + }); }); - it("handles content exactly at 100 char boundary", () => { + it("handles content exactly at 100 char boundary", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); const payload = makePayload({ content: "A".repeat(100) }); notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ body: "A".repeat(100) }), + ); + }); }); - it("handles content just over 100 chars (101)", () => { + it("handles content just over 100 chars (101)", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); const payload = makePayload({ content: "A".repeat(101) }); notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ body: "A".repeat(100) + "..." }), + ); + }); }); - it("does not suppress normal message when suppressEveryone is enabled", () => { + it("does not suppress normal message when suppressEveryone is enabled", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); testPrefs.set("suppressEveryone", true); const payload = makePayload({ content: "Normal message without at-mentions" }); // Should proceed to notification (not suppressed) notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + }); }); - it("suppresses @everyone regardless of other toggles", () => { + it("suppresses @everyone regardless of other toggles", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); testPrefs.set("suppressEveryone", true); testPrefs.set("desktopNotifications", true); testPrefs.set("flashTaskbar", true); testPrefs.set("notificationSounds", true); - const payload = makePayload({ content: "Hey @everyone look!" }); + const payload = makePayload({ content: "Hey @everyone look!", mentions_everyone: true }); // Should be suppressed before any notification fires notifyIncomingMessage(payload); + await new Promise((r) => setTimeout(r, 50)); + expect(sendNotification).not.toHaveBeenCalled(); }); it("fires desktop notification via Tauri plugin when permission granted", async () => { @@ -327,6 +443,8 @@ describe("notifyIncomingMessage", () => { throw new Error("Window not available"); }); + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); + testPrefs.set("flashTaskbar", true); // Disable other notification types to isolate testPrefs.set("desktopNotifications", false); @@ -337,7 +455,15 @@ describe("notifyIncomingMessage", () => { // Give async time to resolve await new Promise((r) => setTimeout(r, 50)); - // Should not throw, just log debug + // Should not throw, and the catch path should have logged via the + // notifications logger (console.debug is its underlying sink). + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("[notifications]"), + "Taskbar flash not available", + "", + ); + + debugSpy.mockRestore(); }); it("handles playNotificationSound error gracefully (catch path)", () => { @@ -347,6 +473,8 @@ describe("notifyIncomingMessage", () => { throw new Error("Oscillator error"); }); + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); + testPrefs.set("notificationSounds", true); testPrefs.set("desktopNotifications", false); testPrefs.set("flashTaskbar", false); @@ -355,6 +483,14 @@ describe("notifyIncomingMessage", () => { // Should not throw notifyIncomingMessage(payload); + // The catch block logs via the notifications logger before returning. + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("[notifications]"), + "Notification sound not available", + "", + ); + + debugSpy.mockRestore(); // Restore the mock mockOscillator.start.mockImplementation(() => {}); }); @@ -449,6 +585,8 @@ describe("notifyIncomingMessage", () => { configurable: true, }); + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); + testPrefs.set("desktopNotifications", true); testPrefs.set("flashTaskbar", false); testPrefs.set("notificationSounds", false); @@ -457,8 +595,15 @@ describe("notifyIncomingMessage", () => { notifyIncomingMessage(payload); await new Promise((r) => setTimeout(r, 50)); - // Should not throw — just logs debug + // Should not throw — and the inner catch should have logged via the + // notifications logger. + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("[notifications]"), + "Notifications not available", + "", + ); + debugSpy.mockRestore(); shouldTauriNotifThrow.value = false; Object.defineProperty(globalThis, "Notification", { value: originalNotification, @@ -698,8 +843,8 @@ describe("notifyIncomingMessage", () => { }); }); - describe("containsEveryone: OR logic and exact strings", () => { - it("suppresses message containing only @everyone (not @here)", async () => { + describe("suppressEveryone: driven by mentions_everyone, not substrings", () => { + it("suppresses an @everyone the server honoured", async () => { const { sendNotification } = await import("@tauri-apps/plugin-notification"); (sendNotification as ReturnType).mockClear(); @@ -708,14 +853,14 @@ describe("notifyIncomingMessage", () => { testPrefs.set("flashTaskbar", false); testPrefs.set("notificationSounds", false); - notifyIncomingMessage(makePayload({ content: "ping @everyone" })); + notifyIncomingMessage(makePayload({ content: "ping @everyone", mentions_everyone: true })); await new Promise((r) => setTimeout(r, 50)); // Should NOT have reached sendNotification expect(sendNotification).not.toHaveBeenCalled(); }); - it("suppresses message containing only @here (not @everyone)", async () => { + it("suppresses an @here the server honoured", async () => { const { sendNotification } = await import("@tauri-apps/plugin-notification"); (sendNotification as ReturnType).mockClear(); @@ -724,12 +869,48 @@ describe("notifyIncomingMessage", () => { testPrefs.set("flashTaskbar", false); testPrefs.set("notificationSounds", false); - notifyIncomingMessage(makePayload({ content: "ping @here" })); + notifyIncomingMessage(makePayload({ content: "ping @here", mentions_everyone: true })); await new Promise((r) => setTimeout(r, 50)); expect(sendNotification).not.toHaveBeenCalled(); }); + it("does NOT suppress an @everyone the server did not honour", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); + + testPrefs.set("suppressEveryone", true); + testPrefs.set("desktopNotifications", true); + testPrefs.set("flashTaskbar", false); + testPrefs.set("notificationSounds", false); + + // Sender lacked MENTION_EVERYONE: the token is plain text. + notifyIncomingMessage(makePayload({ content: "ping @everyone", mentions_everyone: false })); + + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + }); + }); + + it("does NOT suppress an @everyone that also names the user", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); + + testPrefs.set("suppressEveryone", true); + testPrefs.set("desktopNotifications", true); + testPrefs.set("flashTaskbar", false); + testPrefs.set("notificationSounds", false); + + notifyIncomingMessage( + makePayload({ content: "@everyone and @Me", mentions: [1], mentions_everyone: true }), + ); + + await vi.waitFor(() => { + const call = (sendNotification as ReturnType).mock.calls[0]![0]; + expect(call.title).toBe("TestUser mentioned you in #general"); + }); + }); + it("does NOT suppress message without @everyone or @here even with toggle on", async () => { const { sendNotification } = await import("@tauri-apps/plugin-notification"); (sendNotification as ReturnType).mockClear(); @@ -755,7 +936,7 @@ describe("notifyIncomingMessage", () => { testPrefs.set("flashTaskbar", false); testPrefs.set("notificationSounds", false); - notifyIncomingMessage(makePayload({ content: "Hey @everyone" })); + notifyIncomingMessage(makePayload({ content: "Hey @everyone", mentions_everyone: true })); await vi.waitFor(() => { expect(sendNotification).toHaveBeenCalled(); @@ -1178,4 +1359,59 @@ describe("notifyIncomingMessage", () => { (globalThis as Record).Notification = originalNotification; }); }); + describe("mention titles", () => { + async function titleFor(payload: ChatMessagePayload): Promise { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); + testPrefs.set("desktopNotifications", true); + testPrefs.set("flashTaskbar", false); + testPrefs.set("notificationSounds", false); + notifyIncomingMessage(payload); + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + }); + return (sendNotification as ReturnType).mock.calls[0]![0].title as string; + } + + it("says 'mentioned you' when the server names the current user", async () => { + const title = await titleFor(makePayload({ content: "hi @Me", mentions: [1] })); + expect(title).toBe("TestUser mentioned you in #general"); + }); + + it("says 'mentioned you' for an honoured @everyone", async () => { + const title = await titleFor( + makePayload({ content: "hi all @everyone", mentions_everyone: true }), + ); + expect(title).toBe("TestUser mentioned you in #general"); + }); + + it("uses the plain title when the mention names someone else", async () => { + const title = await titleFor(makePayload({ content: "hi @other", mentions: [7] })); + expect(title).toBe("TestUser in #general"); + }); + + it("uses the plain title for an unhonoured @everyone", async () => { + const title = await titleFor( + makePayload({ content: "hi @everyone", mentions_everyone: false }), + ); + expect(title).toBe("TestUser in #general"); + }); + + it("falls back to name resolution when the server sent no list", async () => { + const title = await titleFor(makePayload({ content: "hi @Me" })); + expect(title).toBe("TestUser mentioned you in #general"); + }); + + it("stays silent under Do Not Disturb even for a mention", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); + testPrefs.set("userStatus", "dnd"); + testPrefs.set("desktopNotifications", true); + + notifyIncomingMessage(makePayload({ content: "hi @Me", mentions: [1] })); + + await new Promise((r) => setTimeout(r, 50)); + expect(sendNotification).not.toHaveBeenCalled(); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/nsfw-gate.test.ts b/Client/tauri-client/tests/unit/nsfw-gate.test.ts new file mode 100644 index 00000000..f5366682 --- /dev/null +++ b/Client/tauri-client/tests/unit/nsfw-gate.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + isNsfwAcknowledged, + acknowledgeNsfw, + clearNsfwAcknowledgements, + nsfwGateRequired, +} from "@lib/nsfw-gate"; +import { createNsfwGate } from "@components/NsfwGate"; + +describe("nsfw-gate acknowledgements", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("reports an un-acknowledged channel as not acknowledged", () => { + expect(isNsfwAcknowledged(42)).toBe(false); + }); + + it("remembers an acknowledgement", () => { + acknowledgeNsfw(42); + expect(isNsfwAcknowledged(42)).toBe(true); + }); + + it("keys the acknowledgement per channel", () => { + acknowledgeNsfw(42); + expect(isNsfwAcknowledged(43)).toBe(false); + }); + + // The promise is "once per session", so it must live in sessionStorage — + // localStorage would silently make it "once ever" and the flag would stop + // meaning anything after the first visit. + it("stores the acknowledgement in sessionStorage, not localStorage", () => { + acknowledgeNsfw(7); + expect(sessionStorage.length).toBeGreaterThan(0); + expect(localStorage.getItem("owncord:nsfw-ack:7")).toBeNull(); + }); + + it("clears every acknowledgement", () => { + acknowledgeNsfw(1); + acknowledgeNsfw(2); + clearNsfwAcknowledgements(); + expect(isNsfwAcknowledged(1)).toBe(false); + expect(isNsfwAcknowledged(2)).toBe(false); + }); + + it("leaves unrelated session keys alone when clearing", () => { + sessionStorage.setItem("unrelated", "keep me"); + acknowledgeNsfw(1); + clearNsfwAcknowledgements(); + expect(sessionStorage.getItem("unrelated")).toBe("keep me"); + }); + + // A storage that throws must not hide the gate — erring toward asking again + // is harmless, where erring the other way drops the whole feature. + it("reads a throwing sessionStorage as not acknowledged", () => { + const spy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("denied"); + }); + expect(isNsfwAcknowledged(1)).toBe(false); + spy.mockRestore(); + }); + + it("does not throw when the acknowledgement cannot be stored", () => { + const spy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota"); + }); + expect(() => acknowledgeNsfw(1)).not.toThrow(); + spy.mockRestore(); + }); + + describe("nsfwGateRequired", () => { + it("is false for a channel that is not flagged", () => { + expect(nsfwGateRequired({ id: 1, nsfw: false })).toBe(false); + }); + + it("is true for a flagged channel not yet acknowledged", () => { + expect(nsfwGateRequired({ id: 1, nsfw: true })).toBe(true); + }); + + it("is false once the channel has been acknowledged this session", () => { + acknowledgeNsfw(1); + expect(nsfwGateRequired({ id: 1, nsfw: true })).toBe(false); + }); + }); +}); + +describe("NsfwGate component", () => { + let container: HTMLDivElement; + + beforeEach(() => { + sessionStorage.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function mountGate(overrides?: { + onContinue?: () => void; + onCancel?: () => void; + channelId?: number; + }) { + const onContinue = overrides?.onContinue ?? vi.fn(); + const gate = createNsfwGate({ + channelId: overrides?.channelId ?? 9, + channelName: "spicy", + onContinue, + ...(overrides?.onCancel !== undefined ? { onCancel: overrides.onCancel } : {}), + }); + gate.mount(container); + return { gate, onContinue }; + } + + it("renders the warning over the container", () => { + const { gate } = mountGate(); + const el = container.querySelector("[data-testid='nsfw-gate']"); + expect(el).not.toBeNull(); + expect(el?.textContent).toContain("This channel may contain sensitive content"); + gate.destroy?.(); + }); + + it("names the channel it is gating", () => { + const { gate } = mountGate(); + expect(container.querySelector(".nsfw-gate-title")?.textContent).toBe("#spicy"); + gate.destroy?.(); + }); + + // The copy must not imply the server is filtering anything — it is not. + it("says plainly that nothing is filtered", () => { + const { gate } = mountGate(); + expect(container.querySelector("[data-testid='nsfw-gate']")?.textContent).toContain( + "Nothing is filtered", + ); + gate.destroy?.(); + }); + + it("records the acknowledgement and notifies on Continue", () => { + const onContinue = vi.fn(); + const { gate } = mountGate({ onContinue, channelId: 11 }); + + (container.querySelector("[data-testid='nsfw-gate-continue']") as HTMLButtonElement).click(); + + expect(isNsfwAcknowledged(11)).toBe(true); + expect(onContinue).toHaveBeenCalledTimes(1); + gate.destroy?.(); + }); + + it("offers no Go Back button without an onCancel", () => { + const { gate } = mountGate(); + expect(container.querySelector("[data-testid='nsfw-gate-back']")).toBeNull(); + gate.destroy?.(); + }); + + it("calls onCancel from Go Back without acknowledging", () => { + const onCancel = vi.fn(); + const { gate } = mountGate({ onCancel, channelId: 12 }); + + (container.querySelector("[data-testid='nsfw-gate-back']") as HTMLButtonElement).click(); + + expect(onCancel).toHaveBeenCalledTimes(1); + // Declining must not be remembered as acceptance — the next open asks again. + expect(isNsfwAcknowledged(12)).toBe(false); + gate.destroy?.(); + }); + + it("removes itself on destroy", () => { + const { gate } = mountGate(); + gate.destroy?.(); + expect(container.querySelector("[data-testid='nsfw-gate']")).toBeNull(); + }); + + it("stops responding to clicks after destroy", () => { + const onContinue = vi.fn(); + const gate = createNsfwGate({ channelId: 3, channelName: "spicy", onContinue }); + gate.mount(container); + const btn = container.querySelector("[data-testid='nsfw-gate-continue']") as HTMLButtonElement; + gate.destroy?.(); + btn.click(); + expect(onContinue).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/overlay-managers.test.ts b/Client/tauri-client/tests/unit/overlay-managers.test.ts index b1c4e2e3..b66b4b12 100644 --- a/Client/tauri-client/tests/unit/overlay-managers.test.ts +++ b/Client/tauri-client/tests/unit/overlay-managers.test.ts @@ -341,17 +341,17 @@ describe("createPinnedPanelController", () => { expect(mockPinnedMessagesDestroy).toHaveBeenCalled(); }); - it("onJumpToMessage shows toast when message not in loaded window", async () => { + it("closes and delegates even for a message outside the loaded window", async () => { const api = makeMockApi(); const toast = makeMockToast(); - const mockScrollToMessage = vi.fn().mockReturnValue(false); + const mockJump = vi.fn(); const controller = createPinnedPanelController({ api: api as never, getRoot: () => root, getCurrentChannelId: () => 42, - onJumpToMessage: mockScrollToMessage, + onJumpToMessage: mockJump, }); await controller.toggle(); @@ -362,10 +362,12 @@ describe("createPinnedPanelController", () => { opts.onJumpToMessage(999); - expect(mockScrollToMessage).toHaveBeenCalledWith(999); - expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("not in"), "info"); - // Panel should NOT close when message not found - expect(mockPinnedMessagesDestroy).not.toHaveBeenCalled(); + // The jumper fetches the around-window for an unloaded target and reports + // its own failures, so the panel no longer second-guesses it with a + // "not in loaded window" toast — it just gets out of the way. + expect(mockJump).toHaveBeenCalledWith(999); + expect(mockPinnedMessagesDestroy).toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); }); it("shows toast when toggle fails to load pins", async () => { @@ -1131,9 +1133,9 @@ describe("createSearchOverlayController", () => { expect(mockLogError).toHaveBeenCalled(); }); - it("onSelectResult sets active channel and calls onJumpToMessage", () => { + it("onSelectResult hands the whole jump to onJumpToMessage", () => { const api = makeMockApi(); - const mockJump = vi.fn().mockReturnValue(true); + const mockJump = vi.fn(); const controller = createSearchOverlayController({ api: api as never, @@ -1148,24 +1150,17 @@ describe("createSearchOverlayController", () => { onSelectResult: (result: { channel_id: number; message_id: number }) => void; }; - // Mock requestAnimationFrame to execute immediately - const origRaf = globalThis.requestAnimationFrame; - globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { - cb(0); - return 0; - }; - opts.onSelectResult({ channel_id: 3, message_id: 42 }); - expect(mockSetActiveChannel).toHaveBeenCalledWith(3); expect(mockJump).toHaveBeenCalledWith(3, 42); - - globalThis.requestAnimationFrame = origRaf; + // The jumper owns the channel switch too, so the around-window fetch it + // may need is sequenced after the switch instead of racing it. + expect(mockSetActiveChannel).not.toHaveBeenCalled(); }); - it("onSelectResult shows toast when message not found", () => { + it("onSelectResult does not second-guess the jumper with a toast", () => { const api = makeMockApi(); - const mockJump = vi.fn().mockReturnValue(false); + const mockJump = vi.fn(); const controller = createSearchOverlayController({ api: api as never, @@ -1180,17 +1175,10 @@ describe("createSearchOverlayController", () => { onSelectResult: (result: { channel_id: number; message_id: number }) => void; }; - const origRaf = globalThis.requestAnimationFrame; - globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { - cb(0); - return 0; - }; - opts.onSelectResult({ channel_id: 3, message_id: 999 }); - expect(mockShowToast).toHaveBeenCalledWith("Message not in loaded history", "info"); - - globalThis.requestAnimationFrame = origRaf; + // A hit outside the loaded page is now a fetch, not a dead end. + expect(mockShowToast).not.toHaveBeenCalled(); }); it("onSelectResult works without onJumpToMessage callback", () => { diff --git a/Client/tauri-client/tests/unit/permissions.test.ts b/Client/tauri-client/tests/unit/permissions.test.ts index 36640a52..56d59421 100644 --- a/Client/tauri-client/tests/unit/permissions.test.ts +++ b/Client/tauri-client/tests/unit/permissions.test.ts @@ -5,8 +5,16 @@ import { hasAllPermissions, computeEffective, isAdministrator, + permissionsForRole, + currentUserPermissions, + isLegacyAdminRole, + roleHasPermission, + canManageChannels, + canViewAuditLog, } from "../../src/lib/permissions"; import { Permission } from "../../src/lib/types"; +import { setRoles } from "../../src/stores/channels.store"; +import { authStore } from "../../src/stores/auth.store"; // Default role permission values (from SCHEMA.md) const OWNER_PERMS = 0x7fffffff; @@ -212,3 +220,160 @@ describe("edge cases", () => { expect(hasPermission(MODERATOR_PERMS, Permission.MANAGE_SERVER)).toBe(false); }); }); + +describe("permissionsForRole", () => { + it("resolves a role's mask case-insensitively from the ready role list", () => { + setRoles([{ id: 3, name: "Moderator", color: null, permissions: MODERATOR_PERMS }]); + expect(permissionsForRole("moderator")).toBe(MODERATOR_PERMS); + expect(permissionsForRole("MODERATOR")).toBe(MODERATOR_PERMS); + }); + + it("returns null for a role the server did not send, so callers can fall back", () => { + setRoles([{ id: 3, name: "Moderator", color: null, permissions: MODERATOR_PERMS }]); + expect(permissionsForRole("owner")).toBeNull(); + setRoles([]); + expect(permissionsForRole("moderator")).toBeNull(); + }); + + it("distinguishes a zero-permission role from an unknown one", () => { + setRoles([{ id: 9, name: "Muted", color: null, permissions: 0 }]); + expect(permissionsForRole("muted")).toBe(0); + expect(permissionsForRole("nope")).toBeNull(); + }); + + it("currentUserPermissions denies by default when the role is unknown", () => { + setRoles([]); + authStore.setState(() => ({ + token: "tok", + user: { id: 1, username: "A", avatar: null, role: "moderator" }, + serverName: "T", + motd: null, + isAuthenticated: true, + })); + expect(currentUserPermissions()).toBe(0); + setRoles([{ id: 3, name: "Moderator", color: null, permissions: MODERATOR_PERMS }]); + expect(currentUserPermissions()).toBe(MODERATOR_PERMS); + }); +}); + +describe("roleHasPermission", () => { + it("uses the server mask when the ready role list has the role", () => { + setRoles([{ id: 3, name: "Moderator", color: null, permissions: MODERATOR_PERMS }]); + expect(roleHasPermission("moderator", Permission.KICK_MEMBERS)).toBe(true); + expect(roleHasPermission("moderator", Permission.MANAGE_SERVER)).toBe(false); + }); + + it("lets the ADMINISTRATOR bit pass every permission", () => { + setRoles([{ id: 1, name: "Owner", color: null, permissions: OWNER_PERMS }]); + expect(roleHasPermission("owner", Permission.MUTE_MEMBERS)).toBe(true); + expect(roleHasPermission("owner", Permission.MANAGE_SERVER)).toBe(true); + }); + + it("prefers the mask over the role name — a renamed 'admin' with no bits is denied", () => { + setRoles([{ id: 7, name: "Admin", color: null, permissions: 0 }]); + expect(roleHasPermission("admin", Permission.KICK_MEMBERS)).toBe(false); + }); + + it("falls back to the legacy owner/admin name check when the server sent no role list", () => { + setRoles([]); + expect(roleHasPermission("owner", Permission.MUTE_MEMBERS)).toBe(true); + expect(roleHasPermission("admin", Permission.KICK_MEMBERS)).toBe(true); + expect(roleHasPermission("moderator", Permission.MUTE_MEMBERS)).toBe(false); + expect(roleHasPermission("member", Permission.KICK_MEMBERS)).toBe(false); + }); + + it("derives voice moderation and member-list gates identically", () => { + // Regression: canModerateVoice used to deny on an unknown role while the + // member list fell back to the legacy name check, so on a server that sent + // no role list an admin kept kick/ban but silently lost the voice menu. + setRoles([]); + for (const role of ["owner", "admin", "moderator", "member"]) { + expect(roleHasPermission(role, Permission.MUTE_MEMBERS)).toBe( + roleHasPermission(role, Permission.KICK_MEMBERS), + ); + } + }); +}); + +describe("isLegacyAdminRole", () => { + it("matches owner and admin case-insensitively and nothing else", () => { + expect(isLegacyAdminRole("Owner")).toBe(true); + expect(isLegacyAdminRole("ADMIN")).toBe(true); + expect(isLegacyAdminRole("moderator")).toBe(false); + expect(isLegacyAdminRole("")).toBe(false); + }); +}); + +// ─── Channel management / audit-log gates ──────────────────────────────────── +// +// Both are derived from the permission BIT, not from a role name: a custom role +// granted MANAGE_CHANNELS could edit channels through the API while the client +// hid the affordance, because the old check asked whether the role was called +// "owner" or "admin". + +describe("canManageChannels / canViewAuditLog", () => { + function signInAs(role: string): void { + authStore.setState(() => ({ + token: "tok", + user: { id: 1, username: "A", avatar: null, role }, + serverName: "T", + motd: null, + isAuthenticated: true, + })); + } + + it("grants channel management to a custom role holding the bit", () => { + setRoles([{ id: 9, name: "Curator", color: null, permissions: Permission.MANAGE_CHANNELS }]); + signInAs("Curator"); + expect(canManageChannels()).toBe(true); + }); + + it("denies channel management to a role without the bit", () => { + setRoles([{ id: 4, name: "Member", color: null, permissions: MEMBER_PERMS }]); + signInAs("Member"); + expect(canManageChannels()).toBe(false); + }); + + it("lets the ADMINISTRATOR bit pass channel management", () => { + setRoles([{ id: 1, name: "Owner", color: null, permissions: OWNER_PERMS }]); + signInAs("Owner"); + expect(canManageChannels()).toBe(true); + }); + + // Without a role list there is nothing to check the bit against; the legacy + // name check stands in so channel management is not hidden from every actual + // admin on an older server. + it("falls back to the legacy owner/admin names with no role list", () => { + setRoles([]); + signInAs("admin"); + expect(canManageChannels()).toBe(true); + signInAs("moderator"); + expect(canManageChannels()).toBe(false); + }); + + it("gates the audit-log entry on VIEW_AUDIT_LOG", () => { + setRoles([ + { id: 3, name: "Moderator", color: null, permissions: Permission.VIEW_AUDIT_LOG }, + { id: 4, name: "Member", color: null, permissions: MEMBER_PERMS }, + ]); + signInAs("Moderator"); + expect(canViewAuditLog()).toBe(true); + signInAs("Member"); + expect(canViewAuditLog()).toBe(false); + }); + + // The two gates are independent: an auditor who may read the log need not be + // able to edit channels, and vice versa. + it("keeps the two gates independent", () => { + setRoles([ + { id: 9, name: "Auditor", color: null, permissions: Permission.VIEW_AUDIT_LOG }, + { id: 10, name: "Curator", color: null, permissions: Permission.MANAGE_CHANNELS }, + ]); + signInAs("Auditor"); + expect(canViewAuditLog()).toBe(true); + expect(canManageChannels()).toBe(false); + signInAs("Curator"); + expect(canViewAuditLog()).toBe(false); + expect(canManageChannels()).toBe(true); + }); +}); diff --git a/Client/tauri-client/tests/unit/profiles.test.ts b/Client/tauri-client/tests/unit/profiles.test.ts index c3c577dd..a381cb1f 100644 --- a/Client/tauri-client/tests/unit/profiles.test.ts +++ b/Client/tauri-client/tests/unit/profiles.test.ts @@ -191,6 +191,9 @@ describe("ProfileManager", () => { const m = mgr(); // Should not throw m.setLastConnected("missing"); + + expect(m.getAll()).toHaveLength(0); + expect(m.getById("missing")).toBeNull(); }); it("only updates lastConnected on the matching profile, not others", () => { diff --git a/Client/tauri-client/tests/unit/reaction-tooltip.test.ts b/Client/tauri-client/tests/unit/reaction-tooltip.test.ts new file mode 100644 index 00000000..8634cef3 --- /dev/null +++ b/Client/tauri-client/tests/unit/reaction-tooltip.test.ts @@ -0,0 +1,316 @@ +/** + * Who-reacted tooltip: the hover debounce, the per-message+emoji cache and its + * invalidation, and the name formatting the tooltip renders. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); + +import { + REACTION_TOOLTIP_DEBOUNCE_MS, + attachReactionTooltip, + buildReactionTooltip, + clearReactionUsersCache, + formatReactorNames, + getCachedReactionUsers, + invalidateReactionUsers, + loadReactionUsers, + setReactionUsersFetcher, +} from "../../src/components/message-list/reaction-tooltip"; +import type { ReactionUser } from "@lib/types"; + +function user(id: number, username: string): ReactionUser { + return { id, username, avatar: "" }; +} + +const ALICE = user(1, "alice"); +const BOB = user(2, "bob"); + +let fetcher: ReturnType; + +beforeEach(() => { + clearReactionUsersCache(); + fetcher = vi.fn().mockResolvedValue([ALICE, BOB]); + setReactionUsersFetcher(fetcher as never); +}); + +afterEach(() => { + setReactionUsersFetcher(null); + vi.useRealTimers(); +}); + +describe("formatReactorNames", () => { + it("names a single reactor", () => { + expect(formatReactorNames(["alice"])).toBe("alice"); + }); + + it("joins two with 'and'", () => { + expect(formatReactorNames(["alice", "bob"])).toBe("alice and bob"); + }); + + it("joins three with commas and a final 'and'", () => { + expect(formatReactorNames(["alice", "bob", "carol"])).toBe("alice, bob and carol"); + }); + + it("collapses the tail into 'and N others'", () => { + expect(formatReactorNames(["alice", "bob", "carol", "dave", "erin"])).toBe( + "alice, bob, carol and 2 others", + ); + }); + + it("uses the singular for exactly one overflow name", () => { + expect(formatReactorNames(["alice", "bob", "carol", "dave"])).toBe( + "alice, bob, carol and 1 other", + ); + }); + + // The server caps the list at 100 but the pill's count can be higher; the + // phrasing must follow the count, not the truncated list. + it("counts overflow from the pill count, not the fetched list", () => { + expect(formatReactorNames(["alice", "bob", "carol"], 250)).toBe( + "alice, bob, carol and 247 others", + ); + }); + + it("returns an empty string for no reactors", () => { + expect(formatReactorNames([])).toBe(""); + }); +}); + +describe("buildReactionTooltip", () => { + it("renders names and the emoji as text nodes", () => { + const tip = buildReactionTooltip("👍", [ALICE, BOB], 2); + expect(tip.querySelector(".reaction-tooltip-names")?.textContent).toBe("alice and bob"); + expect(tip.querySelector(".reaction-tooltip-emoji")?.textContent).toBe("reacted with 👍"); + }); + + // Usernames are user-controlled: they must never be parsed as markup. + it("escapes a username that looks like HTML", () => { + const tip = buildReactionTooltip("👍", [user(9, "")], 1); + const names = tip.querySelector(".reaction-tooltip-names") as HTMLElement; + expect(names.querySelector("img")).toBeNull(); + expect(names.textContent).toBe(""); + }); +}); + +describe("loadReactionUsers — cache", () => { + it("fetches once and serves later calls from the cache", async () => { + await loadReactionUsers(5, 42, "👍"); + await loadReactionUsers(5, 42, "👍"); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledWith(5, 42, "👍"); + expect(getCachedReactionUsers(42, "👍")).toEqual([ALICE, BOB]); + }); + + it("keys the cache by emoji as well as message", async () => { + await loadReactionUsers(5, 42, "👍"); + await loadReactionUsers(5, 42, "🎉"); + + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("deduplicates concurrent requests for the same key", async () => { + const [a, b] = await Promise.all([ + loadReactionUsers(5, 42, "👍"), + loadReactionUsers(5, 42, "👍"), + ]); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(a).toEqual(b); + }); + + it("returns null and caches nothing when the request fails", async () => { + fetcher.mockRejectedValue(new Error("403")); + + expect(await loadReactionUsers(5, 42, "👍")).toBeNull(); + expect(getCachedReactionUsers(42, "👍")).toBeUndefined(); + }); + + it("returns null when no fetcher is registered", async () => { + setReactionUsersFetcher(null); + expect(await loadReactionUsers(5, 42, "👍")).toBeNull(); + }); +}); + +describe("invalidateReactionUsers", () => { + it("drops every emoji's list for the message", async () => { + await loadReactionUsers(5, 42, "👍"); + await loadReactionUsers(5, 42, "🎉"); + + invalidateReactionUsers(42); + + expect(getCachedReactionUsers(42, "👍")).toBeUndefined(); + expect(getCachedReactionUsers(42, "🎉")).toBeUndefined(); + + await loadReactionUsers(5, 42, "👍"); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it("leaves other messages' caches alone", async () => { + await loadReactionUsers(5, 42, "👍"); + await loadReactionUsers(5, 43, "👍"); + + invalidateReactionUsers(42); + + expect(getCachedReactionUsers(43, "👍")).toEqual([ALICE, BOB]); + }); + + // Message ids share a prefix (42 / 420): the key separator must keep them apart. + it("does not evict a message whose id merely starts with the same digits", async () => { + await loadReactionUsers(5, 420, "👍"); + invalidateReactionUsers(42); + expect(getCachedReactionUsers(420, "👍")).toEqual([ALICE, BOB]); + }); + + // A response that lands after an invalidation describes a state that already + // changed — it must not repopulate the cache. + it("discards an in-flight response that an invalidation raced", async () => { + let resolve!: (users: readonly ReactionUser[]) => void; + fetcher.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + + const pending = loadReactionUsers(5, 42, "👍"); + invalidateReactionUsers(42); + resolve([ALICE]); + await pending; + + expect(getCachedReactionUsers(42, "👍")).toBeUndefined(); + }); + + it("clearReactionUsersCache drops everything", async () => { + await loadReactionUsers(5, 42, "👍"); + clearReactionUsersCache(); + expect(getCachedReactionUsers(42, "👍")).toBeUndefined(); + }); +}); + +describe("attachReactionTooltip", () => { + function makeChip(): { chip: HTMLElement; ac: AbortController } { + const chip = document.createElement("span"); + chip.className = "reaction-chip"; + document.body.appendChild(chip); + return { chip, ac: new AbortController() }; + } + + beforeEach(() => { + document.body.textContent = ""; + }); + + it("does not fetch before the debounce elapses", () => { + vi.useFakeTimers(); + const { chip, ac } = makeChip(); + attachReactionTooltip(chip, { channelId: 5, messageId: 42, emoji: "👍", count: 2 }, ac.signal); + + chip.dispatchEvent(new Event("mouseenter")); + vi.advanceTimersByTime(REACTION_TOOLTIP_DEBOUNCE_MS - 1); + + expect(fetcher).not.toHaveBeenCalled(); + expect(chip.querySelector(".reaction-tooltip")).toBeNull(); + }); + + it("shows the tooltip after the debounce elapses", async () => { + const { chip, ac } = makeChip(); + attachReactionTooltip(chip, { channelId: 5, messageId: 42, emoji: "👍", count: 2 }, ac.signal); + + chip.dispatchEvent(new Event("mouseenter")); + + await vi.waitFor(() => { + expect(chip.querySelector(".reaction-tooltip")).not.toBeNull(); + }); + expect(chip.querySelector(".reaction-tooltip-names")?.textContent).toBe("alice and bob"); + expect(fetcher).toHaveBeenCalledWith(5, 42, "👍"); + }); + + it("cancels the pending fetch when the pointer leaves first", () => { + vi.useFakeTimers(); + const { chip, ac } = makeChip(); + attachReactionTooltip(chip, { channelId: 5, messageId: 42, emoji: "👍", count: 2 }, ac.signal); + + chip.dispatchEvent(new Event("mouseenter")); + chip.dispatchEvent(new Event("mouseleave")); + vi.advanceTimersByTime(REACTION_TOOLTIP_DEBOUNCE_MS * 2); + + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("removes the tooltip on mouseleave", async () => { + const { chip, ac } = makeChip(); + attachReactionTooltip(chip, { channelId: 5, messageId: 42, emoji: "👍", count: 2 }, ac.signal); + + chip.dispatchEvent(new Event("mouseenter")); + await vi.waitFor(() => { + expect(chip.querySelector(".reaction-tooltip")).not.toBeNull(); + }); + + chip.dispatchEvent(new Event("mouseleave")); + expect(chip.querySelector(".reaction-tooltip")).toBeNull(); + }); + + it("does not pop a tooltip for a response that lands after the pointer left", async () => { + let resolve!: (users: readonly ReactionUser[]) => void; + fetcher.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + + const { chip, ac } = makeChip(); + attachReactionTooltip(chip, { channelId: 5, messageId: 42, emoji: "👍", count: 2 }, ac.signal); + + chip.dispatchEvent(new Event("mouseenter")); + await vi.waitFor(() => { + expect(fetcher).toHaveBeenCalled(); + }); + chip.dispatchEvent(new Event("mouseleave")); + resolve([ALICE]); + await Promise.resolve(); + await Promise.resolve(); + + expect(chip.querySelector(".reaction-tooltip")).toBeNull(); + }); + + it("shows nothing when the reactor list comes back empty", async () => { + fetcher.mockResolvedValue([]); + const { chip, ac } = makeChip(); + attachReactionTooltip(chip, { channelId: 5, messageId: 42, emoji: "👍", count: 0 }, ac.signal); + + chip.dispatchEvent(new Event("mouseenter")); + await vi.waitFor(() => { + expect(fetcher).toHaveBeenCalled(); + }); + await Promise.resolve(); + + expect(chip.querySelector(".reaction-tooltip")).toBeNull(); + }); + + it("mirrors hover on keyboard focus", async () => { + const { chip, ac } = makeChip(); + attachReactionTooltip(chip, { channelId: 5, messageId: 42, emoji: "👍", count: 2 }, ac.signal); + + chip.dispatchEvent(new Event("focusin")); + await vi.waitFor(() => { + expect(chip.querySelector(".reaction-tooltip")).not.toBeNull(); + }); + + chip.dispatchEvent(new Event("focusout")); + expect(chip.querySelector(".reaction-tooltip")).toBeNull(); + }); + + it("tears down the pending timer when the list is destroyed", () => { + vi.useFakeTimers(); + const { chip, ac } = makeChip(); + attachReactionTooltip(chip, { channelId: 5, messageId: 42, emoji: "👍", count: 2 }, ac.signal); + + chip.dispatchEvent(new Event("mouseenter")); + ac.abort(); + vi.advanceTimersByTime(REACTION_TOOLTIP_DEBOUNCE_MS * 2); + + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/read-state.test.ts b/Client/tauri-client/tests/unit/read-state.test.ts new file mode 100644 index 00000000..503d8071 --- /dev/null +++ b/Client/tauri-client/tests/unit/read-state.test.ts @@ -0,0 +1,162 @@ +/** + * Explicit mark-as-read. The property that matters is that it uses `mark_read` + * rather than `channel_focus` — the local badge clearing is the easy half; not + * moving the connection's focused channel is the reason this exists. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import { + markAllRead, + markChannelRead, + hasUnread, + setMarkReadSender, + unreadChannelIds, +} from "@lib/read-state"; +import { channelsStore, setChannels } from "@stores/channels.store"; +import { dmStore, setDmChannels } from "@stores/dm.store"; +import type { ReadyChannel } from "@lib/types"; +import type { DmChannel } from "@stores/dm.store"; + +function channel(id: number, unread: number, mentions = 0): ReadyChannel { + return { + id, + name: `chan-${id}`, + type: "text", + category: null, + position: id, + unread_count: unread, + mention_count: mentions, + }; +} + +function dm(channelId: number, unread: number, mentions = 0): DmChannel { + return { + channelId, + recipient: { id: channelId * 10, username: `u${channelId}`, avatar: "", status: "online" }, + participants: [], + name: "", + isGroup: false, + lastMessageId: null, + lastMessage: "", + lastMessageAt: "", + unreadCount: unread, + mentionCount: mentions, + }; +} + +let sent: number[]; + +beforeEach(() => { + sent = []; + setMarkReadSender((id) => sent.push(id)); + channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] })); + dmStore.setState(() => ({ channels: [] })); +}); + +describe("hasUnread", () => { + it("is true for an unread channel and false once it is read", () => { + setChannels([channel(1, 3), channel(2, 0)]); + expect(hasUnread(1)).toBe(true); + expect(hasUnread(2)).toBe(false); + }); + + it("counts a mention-only channel as unread", () => { + setChannels([channel(1, 0, 2)]); + expect(hasUnread(1)).toBe(true); + }); + + it("sees DM badges, which live in a different store", () => { + setDmChannels([dm(50, 4)]); + expect(hasUnread(50)).toBe(true); + }); + + it("is false for an unknown channel", () => { + expect(hasUnread(999)).toBe(false); + }); +}); + +describe("markChannelRead", () => { + it("sends mark_read and clears the local badges", () => { + setChannels([channel(1, 3, 2)]); + + markChannelRead(1); + + expect(sent).toEqual([1]); + const ch = channelsStore.getState().channels.get(1); + expect(ch?.unreadCount).toBe(0); + expect(ch?.mentionCount).toBe(0); + }); + + it("does not make the channel active — marking read is not visiting", () => { + setChannels([channel(1, 3), channel(2, 1)]); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 2 })); + + markChannelRead(1); + + expect(channelsStore.getState().activeChannelId).toBe(2); + }); + + it("clears a DM's badges", () => { + setDmChannels([dm(50, 4, 1)]); + + markChannelRead(50); + + expect(sent).toEqual([50]); + const conv = dmStore.getState().channels[0]; + expect(conv?.unreadCount).toBe(0); + expect(conv?.mentionCount).toBe(0); + }); + + it("ignores a channel this client does not know", () => { + markChannelRead(999); + expect(sent).toEqual([]); + }); + + // A dropped send still clears locally; the next ready re-asserts the server's + // view, so the badge self-corrects rather than being stuck. + it("still clears the badge with no sender registered", () => { + setMarkReadSender(null); + setChannels([channel(1, 3)]); + + markChannelRead(1); + + expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(0); + }); +}); + +describe("unreadChannelIds / markAllRead", () => { + it("lists every badged channel and DM, and nothing else", () => { + setChannels([channel(1, 3), channel(2, 0), channel(3, 0, 1)]); + setDmChannels([dm(50, 2), dm(51, 0)]); + + expect([...unreadChannelIds()].sort((a, b) => a - b)).toEqual([1, 3, 50]); + }); + + it("marks everything read and reports how many", () => { + setChannels([channel(1, 3), channel(2, 0), channel(3, 0, 1)]); + setDmChannels([dm(50, 2)]); + + expect(markAllRead()).toBe(3); + expect([...sent].sort((a, b) => a - b)).toEqual([1, 3, 50]); + expect(unreadChannelIds()).toEqual([]); + }); + + it("is a no-op when nothing is unread", () => { + setChannels([channel(1, 0)]); + + expect(markAllRead()).toBe(0); + expect(sent).toEqual([]); + }); +}); + +describe("mark_read wiring", () => { + it("hands the sender only the channel id", () => { + const sender = vi.fn(); + setMarkReadSender(sender); + setChannels([channel(7, 1)]); + + markChannelRead(7); + + expect(sender).toHaveBeenCalledExactlyOnceWith(7); + }); +}); diff --git a/Client/tauri-client/tests/unit/renderers.test.ts b/Client/tauri-client/tests/unit/renderers.test.ts index 6152aaf0..d728ca1b 100644 --- a/Client/tauri-client/tests/unit/renderers.test.ts +++ b/Client/tauri-client/tests/unit/renderers.test.ts @@ -21,6 +21,10 @@ import { membersStore } from "../../src/stores/members.store"; import { channelsStore, setRoles } from "../../src/stores/channels.store"; import { authStore } from "../../src/stores/auth.store"; import type { MessageListOptions } from "../../src/components/MessageList"; +import { + clearReactionUsersCache, + setReactionUsersFetcher, +} from "../../src/components/message-list/reaction-tooltip"; function resetStores(): void { membersStore.setState(() => ({ @@ -37,6 +41,17 @@ function resetStores(): void { })); } +/** Seed the member list so @tokens resolve — unresolvable tokens stay plain text. */ +function seedMentionMembers(): void { + membersStore.setState((prev) => ({ + ...prev, + members: new Map([ + [10, { id: 10, username: "alice", avatar: null, role: "member", status: "online" as const }], + [11, { id: 11, username: "bob", avatar: null, role: "member", status: "online" as const }], + ]), + })); +} + function makeMessage(overrides: Partial = {}): Message { return { id: 1, @@ -168,7 +183,8 @@ describe("renderers", () => { }); describe("renderMentions", () => { - it("wraps @mentions in span with mention class", () => { + it("wraps resolvable @mentions in span with mention class", () => { + seedMentionMembers(); const fragment = renderMentions("Hello @alice how are you?"); container.appendChild(fragment); @@ -186,6 +202,7 @@ describe("renderers", () => { }); it("handles multiple mentions", () => { + seedMentionMembers(); const fragment = renderMentions("@alice and @bob"); container.appendChild(fragment); @@ -412,6 +429,52 @@ describe("renderers", () => { ac.abort(); }); + // The who-reacted tooltip hangs off the pill, so the pill has to carry its + // emoji and be focusable for a keyboard user to reach the tooltip at all. + it("makes reaction pills focusable and tags them with their emoji", () => { + const msg = makeMessage({ + reactions: [{ emoji: "\uD83D\uDC4D", count: 3, me: false }], + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const chip = container.querySelector( + ".reaction-chip:not(.add-reaction)", + ) as HTMLElement | null; + expect(chip?.dataset.emoji).toBe("\uD83D\uDC4D"); + expect(chip?.getAttribute("tabindex")).toBe("0"); + + ac.abort(); + }); + + it("shows the who-reacted tooltip after hovering a pill", async () => { + setReactionUsersFetcher(() => + Promise.resolve([ + { id: 1, username: "alice", avatar: "" }, + { id: 2, username: "bob", avatar: "" }, + ]), + ); + clearReactionUsersCache(); + + const msg = makeMessage({ + reactions: [{ emoji: "\uD83D\uDC4D", count: 2, me: false }], + }); + const ac = new AbortController(); + const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); + container.appendChild(el); + + const chip = container.querySelector(".reaction-chip:not(.add-reaction)") as HTMLElement; + chip.dispatchEvent(new Event("mouseenter")); + + await vi.waitFor(() => { + expect(chip.querySelector(".reaction-tooltip-names")?.textContent).toBe("alice and bob"); + }); + + ac.abort(); + setReactionUsersFetcher(null); + }); + it("renders attachments for image types", () => { const msg = makeMessage({ attachments: [ @@ -740,6 +803,7 @@ describe("renderers", () => { user: { id: 0, username: "System", avatar: null }, content: "@alice was promoted to admin", }); + seedMentionMembers(); const ac = new AbortController(); const el = renderMessage(msg, false, [msg], makeOpts(), ac.signal); container.appendChild(el); @@ -1229,6 +1293,7 @@ describe("renderers", () => { }); it("renders @mention at start of text", () => { + seedMentionMembers(); const fragment = renderMentionSegment("@alice hello"); container.appendChild(fragment); const mention = container.querySelector(".mention"); @@ -1258,6 +1323,7 @@ describe("renderers", () => { }); it("renders mention inside non-code text", () => { + seedMentionMembers(); const fragment = renderInlineContent("hello @alice and `code`"); container.appendChild(fragment); expect(container.querySelector(".mention")).not.toBeNull(); @@ -1466,3 +1532,95 @@ describe("renderers", () => { }); }); }); + +// ─── Phase 6: display names and avatars on message rows ────────────────────── + +describe("message row author identity", () => { + beforeEach(() => { + resetStores(); + }); + + afterEach(() => { + resetStores(); + }); + + it("renders the display name from the member store, keeping the username as a title", () => { + membersStore.setState((prev) => ({ + ...prev, + members: new Map([ + [ + 10, + { + id: 10, + username: "alice", + displayName: "Alice A.", + avatar: null, + role: "member", + status: "online" as const, + }, + ], + ]), + })); + + const el = renderMessage( + makeMessage({ user: { id: 10, username: "alice", avatar: null } }), + false, + [], + makeOpts(), + new AbortController().signal, + ); + + const author = el.querySelector(".msg-author"); + expect(author?.textContent).toBe("Alice A."); + // The handle you would @mention is one hover away. + expect(author?.getAttribute("title")).toBe("alice"); + // And the letter follows the rendered name. + expect(el.querySelector(".msg-avatar .avatar-initial")?.textContent).toBe("A"); + }); + + it("prefers the live member store over the identity frozen into the payload", () => { + // A rename arrives as a user_update and patches the member store; the + // messages already on screen still carry the old name in their payload. + membersStore.setState((prev) => ({ + ...prev, + members: new Map([ + [ + 10, + { + id: 10, + username: "renamed", + displayName: null, + avatar: null, + role: "member", + status: "online" as const, + }, + ], + ]), + })); + + const el = renderMessage( + makeMessage({ user: { id: 10, username: "old-name", avatar: null } }), + false, + [], + makeOpts(), + new AbortController().signal, + ); + + expect(el.querySelector(".msg-author")?.textContent).toBe("renamed"); + }); + + it("falls back to the payload for an author who is not in the member list", () => { + const el = renderMessage( + makeMessage({ + user: { id: 99, username: "ghost", avatar: null, display_name: "Ghosty" }, + }), + false, + [], + makeOpts(), + new AbortController().signal, + ); + + expect(el.querySelector(".msg-author")?.textContent).toBe("Ghosty"); + expect(el.querySelector(".msg-avatar .avatar-initial")?.textContent).toBe("G"); + }); +}); diff --git a/Client/tauri-client/tests/unit/screen-share-button.test.ts b/Client/tauri-client/tests/unit/screen-share-button.test.ts index fdda83aa..432bf894 100644 --- a/Client/tauri-client/tests/unit/screen-share-button.test.ts +++ b/Client/tauri-client/tests/unit/screen-share-button.test.ts @@ -77,9 +77,14 @@ function setVoiceConnected(screenshare = false): void { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }, ], ]), diff --git a/Client/tauri-client/tests/unit/search-overlay.test.ts b/Client/tauri-client/tests/unit/search-overlay.test.ts index c0c80b69..0c004f20 100644 --- a/Client/tauri-client/tests/unit/search-overlay.test.ts +++ b/Client/tauri-client/tests/unit/search-overlay.test.ts @@ -555,4 +555,30 @@ describe("createSearchOverlay", () => { overlay.destroy?.(); }); + + it("reschedules a rate-limited search instead of dropping it", async () => { + const onSearch = vi.fn().mockResolvedValue([]); + const opts = makeOptions({ onSearch }); + const overlay = createSearchOverlay(opts); + overlay.mount(container); + + const input = container.querySelector(".search-overlay-input") as HTMLInputElement; + + // First query fires after debounce, stamping the rate-limit clock. + input.value = "he"; + input.dispatchEvent(new Event("input")); + await vi.advanceTimersByTimeAsync(300); + expect(onSearch).toHaveBeenLastCalledWith("he", undefined, expect.any(AbortSignal)); + + // The user keeps typing; the next debounced search lands only ~300ms after + // the first, inside the 500ms rate-limit window. It must be rescheduled, + // not silently dropped (which would leave the "he" results on screen). + input.value = "hello"; + input.dispatchEvent(new Event("input")); + // Debounce (300ms) then the remaining rate-limit window (~200ms) elapse. + await vi.advanceTimersByTimeAsync(300 + 500); + expect(onSearch).toHaveBeenLastCalledWith("hello", undefined, expect.any(AbortSignal)); + + overlay.destroy?.(); + }); }); diff --git a/Client/tauri-client/tests/unit/settings-overlay.test.ts b/Client/tauri-client/tests/unit/settings-overlay.test.ts index 3d4fc109..ef27af9e 100644 --- a/Client/tauri-client/tests/unit/settings-overlay.test.ts +++ b/Client/tauri-client/tests/unit/settings-overlay.test.ts @@ -65,6 +65,7 @@ describe("SettingsOverlay", () => { onClose: vi.fn(), onChangePassword: vi.fn().mockResolvedValue(undefined), onUpdateProfile: vi.fn().mockResolvedValue(undefined), + onUploadAvatar: vi.fn().mockResolvedValue("/api/v1/files/test"), onLogout: vi.fn(), onDeleteAccount: vi.fn().mockResolvedValue(undefined), onStatusChange: vi.fn(), @@ -497,7 +498,9 @@ describe("SettingsOverlay", () => { const editBtn = container.querySelector(".account-field-edit") as HTMLElement; editBtn.click(); - const editInput = container.querySelector("input.form-input[type='text']") as HTMLInputElement; + const editInput = container.querySelector( + '[data-testid="username-edit-input"]', + ) as HTMLInputElement; editInput.value = "taken-name"; const saveBtn = Array.from(container.querySelectorAll(".ac-btn")).find( @@ -524,7 +527,9 @@ describe("SettingsOverlay", () => { ) as HTMLElement; editProfileBtn.click(); - const editInput = container.querySelector("input.form-input[type='text']") as HTMLInputElement; + const editInput = container.querySelector( + '[data-testid="username-edit-input"]', + ) as HTMLInputElement; expect(editInput).not.toBeNull(); // The edit form should be visible const editForm = editInput.closest(".setting-row") as HTMLElement; @@ -548,7 +553,9 @@ describe("SettingsOverlay", () => { cancelBtn.click(); // Edit form should be hidden - const editInput = container.querySelector("input.form-input[type='text']") as HTMLInputElement; + const editInput = container.querySelector( + '[data-testid="username-edit-input"]', + ) as HTMLInputElement; const editForm = editInput.closest(".setting-row") as HTMLElement; expect(editForm.style.display).toBe("none"); @@ -764,7 +771,9 @@ describe("SettingsOverlay", () => { editBtn.click(); // Type a single character - const editInput = container.querySelector("input.form-input[type='text']") as HTMLInputElement; + const editInput = container.querySelector( + '[data-testid="username-edit-input"]', + ) as HTMLInputElement; editInput.value = "A"; // Click Save @@ -786,7 +795,9 @@ describe("SettingsOverlay", () => { const editBtn = container.querySelector(".account-field-edit") as HTMLElement; editBtn.click(); - const editInput = container.querySelector("input.form-input[type='text']") as HTMLInputElement; + const editInput = container.querySelector( + '[data-testid="username-edit-input"]', + ) as HTMLInputElement; editInput.value = "AB"; const saveBtn = Array.from(container.querySelectorAll(".ac-btn")).find( @@ -794,21 +805,24 @@ describe("SettingsOverlay", () => { ) as HTMLElement; saveBtn.click(); - expect(defaultOptions.onUpdateProfile).toHaveBeenCalledWith("AB"); + expect(defaultOptions.onUpdateProfile).toHaveBeenCalledWith({ username: "AB" }); overlay.destroy?.(); }); // --- Status selector --- - it("labels the offline status as 'Offline' (not 'Invisible')", () => { + // Phase 6 flipped this: "invisible" is a real, settable status now (the + // server stores it and shows everyone else offline), so the option says what + // it does instead of borrowing "offline"'s name. + it("offers Invisible as a status, not Offline", () => { const overlay = createSettingsOverlay(defaultOptions); overlay.mount(container); const statusLabels = container.querySelectorAll(".settings-status-label"); const labels = Array.from(statusLabels).map((el) => el.textContent); - expect(labels).toContain("Offline"); - expect(labels).not.toContain("Invisible"); + expect(labels).toContain("Invisible"); + expect(labels).not.toContain("Offline"); overlay.destroy?.(); }); diff --git a/Client/tauri-client/tests/unit/sidebar-area.test.ts b/Client/tauri-client/tests/unit/sidebar-area.test.ts index 7bf738ba..5c4e3300 100644 --- a/Client/tauri-client/tests/unit/sidebar-area.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-area.test.ts @@ -98,6 +98,11 @@ vi.mock("@components/CreateChannelModal", () => ({ }), })); +const mockOpenUrl = vi.fn(async (_url: string) => {}); +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: (url: string) => mockOpenUrl(url), +})); + vi.mock("@components/EditChannelModal", () => ({ createEditChannelModal: vi.fn().mockReturnValue({ mount: vi.fn(), @@ -124,6 +129,12 @@ vi.mock("../../src/pages/main-page/VoiceCallbacks", () => ({ onVoiceJoin: vi.fn(), onVoiceLeave: vi.fn(), }), + createVoiceModerationCallbacks: vi.fn().mockReturnValue({ + onServerMute: vi.fn(), + onServerDeafen: vi.fn(), + onMove: vi.fn(), + onDisconnect: vi.fn(), + }), })); vi.mock("../../src/pages/main-page/OverlayManagers", () => ({ @@ -138,10 +149,11 @@ vi.mock("../../src/pages/main-page/OverlayManagers", () => ({ // --------------------------------------------------------------------------- import { createSidebarArea, type SidebarAreaOptions } from "../../src/pages/main-page/SidebarArea"; -import { channelsStore, setActiveChannel } from "../../src/stores/channels.store"; +import { channelsStore, setActiveChannel, setRoles } from "../../src/stores/channels.store"; import { dmStore, addDmChannel } from "../../src/stores/dm.store"; import { uiStore, setSidebarMode, setActiveDmUser } from "../../src/stores/ui.store"; import { authStore } from "../../src/stores/auth.store"; +import { Permission } from "../../src/lib/types"; import { membersStore } from "../../src/stores/members.store"; import { voiceStore } from "../../src/stores/voice.store"; import type { DmChannel } from "../../src/stores/dm.store"; @@ -250,10 +262,14 @@ function makeDm(overrides: Partial = {}): DmChannel { return { channelId: 100, recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, + participants: [{ id: 10, username: "Alice", avatar: "", status: "online" }], + name: "", + isGroup: false, lastMessageId: null, lastMessage: "", lastMessageAt: "", unreadCount: 0, + mentionCount: 0, ...overrides, }; } @@ -280,6 +296,7 @@ function defaultOpts(): SidebarAreaOptions { adminCreateChannel: vi.fn().mockResolvedValue(undefined), adminUpdateChannel: vi.fn().mockResolvedValue(undefined), adminDeleteChannel: vi.fn().mockResolvedValue(undefined), + purgeMessages: vi.fn().mockResolvedValue({ channel_id: 1, ids: [3, 2], count: 2 }), adminKickMember: vi.fn().mockResolvedValue(undefined), adminBanMember: vi.fn().mockResolvedValue(undefined), adminChangeRole: vi.fn().mockResolvedValue(undefined), @@ -846,7 +863,10 @@ describe("SidebarArea", () => { cleanup(result); }); - it("re-renders DM sidebar when activeDmUserId changes in DMs mode", () => { + // Keyed on the active CHANNEL, not activeDmUserId: a group DM leaves the + // latter null, so a subscription on it would stop redrawing the list the + // moment a group became the active conversation. + it("re-renders DM sidebar when the active channel changes in DMs mode", () => { uiStore.setState((prev) => ({ ...prev, sidebarMode: "dms" })); const result = createSidebarArea(defaultOpts()); @@ -854,8 +874,8 @@ describe("SidebarArea", () => { const initialCallCount = (createDmSidebar as MockedFn).mock.calls.length; - setActiveDmUser(42); - uiStore.flush(); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 4242 })); + channelsStore.flush?.(); const newCallCount = (createDmSidebar as MockedFn).mock.calls.length; expect(newCallCount).toBeGreaterThan(initialCallCount); @@ -963,8 +983,13 @@ describe("SidebarArea", () => { const addBtn = container.querySelector(".category-add-btn") as HTMLElement; addBtn.click(); + // The picker is multi-select since group DMs: a click selects, and the + // confirm button (labelled for the selection size) commits. const item = document.querySelector(".dm-member-picker-item") as HTMLElement; item.click(); + const confirm = document.querySelector('[data-testid="dm-picker-create"]') as HTMLElement; + expect(confirm.textContent).toBe("Create DM"); + confirm.click(); expect(document.querySelector(".modal-overlay")).toBeNull(); @@ -1031,9 +1056,14 @@ describe("SidebarArea", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: next, activeChannelId: 1 }; }); @@ -1054,6 +1084,22 @@ describe("SidebarArea", () => { it("does not save DM channel as channelBeforeDm", () => { channelsStore.setState((prev) => { const next = new Map(prev.channels); + next.set(1, { + id: 1, + name: "general", + type: "text", + category: null, + position: 0, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: true, + topic: "", + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }); next.set(50, { id: 50, name: "DmCh", @@ -1061,9 +1107,14 @@ describe("SidebarArea", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: next, activeChannelId: 50 }; }); @@ -1075,9 +1126,21 @@ describe("SidebarArea", () => { const entry = container.querySelector("[data-testid='dm-entry']") as HTMLElement; entry.click(); + uiStore.flush(); + + // DM was active but type was dm, so channelBeforeDm should be null: + // going to DMs mode and clicking back should NOT restore the DM + // channel (50) — it should fall back to the first text channel (1). + expect(uiStore.getState().sidebarMode).toBe("dms"); + + const dmSidebarCalls = (createDmSidebar as MockedFn).mock.calls; + const lastCall = dmSidebarCalls[dmSidebarCalls.length - 1]![0]; + lastCall.onBack(); + + expect(uiStore.getState().sidebarMode).toBe("channels"); + expect(channelsStore.getState().activeChannelId).toBe(1); + expect(channelsStore.getState().activeChannelId).not.toBe(50); - // DM was active but type was dm, so channelBeforeDm should be null - // We can verify by going to DMs mode and clicking back cleanup(result); }); }); @@ -1284,9 +1347,14 @@ describe("SidebarArea", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: next, activeChannelId: 1 }; }); @@ -1322,9 +1390,14 @@ describe("SidebarArea", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); next.set(2, { id: 2, @@ -1333,9 +1406,14 @@ describe("SidebarArea", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: next }; }); @@ -1371,7 +1449,7 @@ describe("SidebarArea", () => { const dmSidebarCalls = (createDmSidebar as MockedFn).mock.calls; const lastCall = dmSidebarCalls[dmSidebarCalls.length - 1]![0]; - lastCall.onCloseDm(10); + lastCall.onCloseDm(100); expect(opts.api.closeDm).toHaveBeenCalledWith(100); @@ -1396,9 +1474,14 @@ describe("SidebarArea", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: next, activeChannelId: 100 }; }); @@ -1408,7 +1491,7 @@ describe("SidebarArea", () => { const dmSidebarCalls = (createDmSidebar as MockedFn).mock.calls; const lastCall = dmSidebarCalls[dmSidebarCalls.length - 1]![0]; - lastCall.onCloseDm(10); + lastCall.onCloseDm(100); expect(uiStore.getState().sidebarMode).toBe("channels"); @@ -1430,7 +1513,7 @@ describe("SidebarArea", () => { const dmSidebarCalls = (createDmSidebar as MockedFn).mock.calls; const lastCall = dmSidebarCalls[dmSidebarCalls.length - 1]![0]; - lastCall.onSelectConversation(10); + lastCall.onSelectConversation(100); expect(uiStore.getState().activeDmUserId).toBe(10); expect(channelsStore.getState().activeChannelId).toBe(100); @@ -1472,6 +1555,7 @@ describe("SidebarArea", () => { expect(typeof callArgs.onEditChannel).toBe("function"); expect(typeof callArgs.onDeleteChannel).toBe("function"); expect(typeof callArgs.onReorderChannel).toBe("function"); + expect(typeof callArgs.onPurgeChannel).toBe("function"); cleanup(result); }); @@ -1525,6 +1609,38 @@ describe("SidebarArea", () => { cleanup(result); }); + it("onPurgeChannel calls the purge API and toasts the server's count", async () => { + const opts = defaultOpts(); + const toast = { show: vi.fn() }; + (opts.getToast as MockedFn).mockReturnValue(toast); + const result = createSidebarArea(opts); + container.appendChild(result.sidebarWrapper); + + const callArgs = (createChannelSidebar as MockedFn).mock.calls[0]![0]; + await callArgs.onPurgeChannel({ id: 1, name: "general" }, 50); + + expect(opts.api.purgeMessages).toHaveBeenCalledWith(1, 50); + expect(toast.show).toHaveBeenCalledWith("Purged 2 messages from #general", "success"); + + cleanup(result); + }); + + it("onPurgeChannel surfaces a failure as an error toast", async () => { + const opts = defaultOpts(); + const toast = { show: vi.fn() }; + (opts.getToast as MockedFn).mockReturnValue(toast); + (opts.api.purgeMessages as MockedFn).mockRejectedValue(new Error("forbidden")); + const result = createSidebarArea(opts); + container.appendChild(result.sidebarWrapper); + + const callArgs = (createChannelSidebar as MockedFn).mock.calls[0]![0]; + await callArgs.onPurgeChannel({ id: 1, name: "general" }, 50); + + expect(toast.show).toHaveBeenCalledWith("forbidden", "error"); + + cleanup(result); + }); + it("onReorderChannel calls API for each reorder", () => { const opts = defaultOpts(); const result = createSidebarArea(opts); @@ -1791,19 +1907,24 @@ describe("SidebarArea", () => { cleanup(result); }); - it("does not overwrite existing channel with non-empty name", () => { + it("does not rewrite an existing channel whose name already matches", () => { channelsStore.setState((prev) => { const next = new Map(prev.channels); next.set(100, { id: 100, - name: "ExistingName", + name: "Alice", type: "dm", category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: next }; }); @@ -1817,7 +1938,7 @@ describe("SidebarArea", () => { entry.click(); const ch = channelsStore.getState().channels.get(100); - expect(ch!.name).toBe("ExistingName"); + expect(ch!.name).toBe("Alice"); cleanup(result); }); @@ -1846,14 +1967,19 @@ describe("SidebarArea", () => { /** Extract callbacks passed to createMemberList */ function getMemberListCallbacks(): { onKick: (userId: number, username: string) => Promise; - onBan: (userId: number, username: string, reason: string) => Promise; + onBan: ( + userId: number, + username: string, + reason: string, + durationHours: number, + ) => Promise; onChangeRole: (userId: number, username: string, newRole: string) => Promise; } { const calls = (createMemberList as MockedFn).mock.calls; return calls[calls.length - 1]![0]; } - it("onKick calls API and shows success toast", async () => { + it("onKick (Force Logout) calls API and shows success toast", async () => { const mockShow = vi.fn(); const opts = defaultOpts(); (opts.getToast as MockedFn).mockReturnValue({ show: mockShow }); @@ -1865,7 +1991,7 @@ describe("SidebarArea", () => { await callbacks.onKick(2, "Alice"); expect(opts.api.adminKickMember).toHaveBeenCalledWith(2); - expect(mockShow).toHaveBeenCalledWith("Kicked Alice", "success"); + expect(mockShow).toHaveBeenCalledWith("Forced Alice to log out", "success"); cleanup(result); }); @@ -1899,7 +2025,7 @@ describe("SidebarArea", () => { const callbacks = getMemberListCallbacks(); await callbacks.onKick(2, "Alice"); - expect(mockShow).toHaveBeenCalledWith("Failed to kick member", "error"); + expect(mockShow).toHaveBeenCalledWith("Failed to force logout", "error"); cleanup(result); }); @@ -1913,9 +2039,9 @@ describe("SidebarArea", () => { container.appendChild(result.sidebarWrapper); const callbacks = getMemberListCallbacks(); - await callbacks.onBan(3, "Bob", "spamming"); + await callbacks.onBan(3, "Bob", "spamming", 0); - expect(opts.api.adminBanMember).toHaveBeenCalledWith(3, "spamming"); + expect(opts.api.adminBanMember).toHaveBeenCalledWith(3, "spamming", 0); expect(mockShow).toHaveBeenCalledWith("Banned Bob", "success"); cleanup(result); @@ -1931,7 +2057,7 @@ describe("SidebarArea", () => { container.appendChild(result.sidebarWrapper); const callbacks = getMemberListCallbacks(); - await callbacks.onBan(3, "Bob", ""); + await callbacks.onBan(3, "Bob", "", 0); expect(mockShow).toHaveBeenCalledWith("Ban denied", "error"); @@ -1948,7 +2074,7 @@ describe("SidebarArea", () => { container.appendChild(result.sidebarWrapper); const callbacks = getMemberListCallbacks(); - await callbacks.onBan(3, "Bob", ""); + await callbacks.onBan(3, "Bob", "", 0); expect(mockShow).toHaveBeenCalledWith("Failed to ban member", "error"); @@ -2108,6 +2234,7 @@ describe("SidebarArea", () => { addBtn.click(); const item = document.querySelector(".dm-member-picker-item") as HTMLElement; item.click(); + (document.querySelector('[data-testid="dm-picker-create"]') as HTMLElement).click(); await vi.waitFor(() => { expect(mockShow).toHaveBeenCalledWith("Server error", "error"); @@ -2192,4 +2319,91 @@ describe("SidebarArea", () => { cleanup(result); }); }); + // ------------------------------------------------------------------------- + // Audit log entry point + // ------------------------------------------------------------------------- + // + // The log itself stays in the admin panel; the desktop client only owes its + // moderators a way in. The entry is gated on VIEW_AUDIT_LOG, and the gate has + // to survive `ready` landing after the header is built. + + describe("audit log entry point", () => { + function signInAs(roleName: string, permissions: number): void { + setRoles([{ id: 9, name: roleName, color: null, permissions }]); + authStore.setState((prev) => ({ + ...prev, + token: "tok", + user: { id: 9, username: "U", avatar: null, role: roleName }, + isAuthenticated: true, + })); + } + + function auditBtn(result: ReturnType): HTMLElement | null { + return result.sidebarWrapper.querySelector("[data-testid='audit-log-btn']"); + } + + it("is shown to a role holding VIEW_AUDIT_LOG", () => { + signInAs("Moderator", Permission.VIEW_AUDIT_LOG); + const result = createSidebarArea(defaultOpts()); + expect(auditBtn(result)?.style.display).not.toBe("none"); + cleanup(result); + }); + + it("is hidden from a role without the bit", () => { + signInAs("Member", Permission.SEND_MESSAGES); + const result = createSidebarArea(defaultOpts()); + expect(auditBtn(result)?.style.display).toBe("none"); + cleanup(result); + }); + + // `ready` can land after the header is built, and a moderator whose role + // only becomes known then would otherwise never see the entry. + it("appears when the role list arrives after mount", () => { + authStore.setState((prev) => ({ + ...prev, + token: "tok", + user: { id: 9, username: "U", avatar: null, role: "Moderator" }, + isAuthenticated: true, + })); + setRoles([]); + const result = createSidebarArea(defaultOpts()); + expect(auditBtn(result)?.style.display).toBe("none"); + + setRoles([{ id: 9, name: "Moderator", color: null, permissions: Permission.VIEW_AUDIT_LOG }]); + // Store notifications are batched on a microtask. + channelsStore.flush(); + + expect(auditBtn(result)?.style.display).not.toBe("none"); + cleanup(result); + }); + + it("opens the admin panel's audit section in the browser", async () => { + mockOpenUrl.mockClear(); + signInAs("Owner", Permission.ADMINISTRATOR); + const result = createSidebarArea(defaultOpts()); + + auditBtn(result)?.click(); + + await vi.waitFor(() => { + expect(mockOpenUrl).toHaveBeenCalledWith("https://localhost:8080/admin#audit"); + }); + cleanup(result); + }); + + it("toasts instead of opening a bogus URL with no host", async () => { + mockOpenUrl.mockClear(); + signInAs("Owner", Permission.ADMINISTRATOR); + const opts = defaultOpts(); + (opts.api.getConfig as ReturnType).mockReturnValue({ host: "" }); + const show = vi.fn(); + (opts.getToast as ReturnType).mockReturnValue({ show }); + const result = createSidebarArea(opts); + + auditBtn(result)?.click(); + + expect(mockOpenUrl).not.toHaveBeenCalled(); + expect(show).toHaveBeenCalledWith("Not connected to a server", "error"); + cleanup(result); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts b/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts index 764fc6aa..fbe71b64 100644 --- a/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-dm-helpers.test.ts @@ -47,7 +47,7 @@ function resetStores(): void { // --------------------------------------------------------------------------- function makeDmChannel(overrides: Partial = {}): DmChannel { - return { + const base: DmChannel = { channelId: 100, recipient: { id: 10, @@ -55,12 +55,19 @@ function makeDmChannel(overrides: Partial = {}): DmChannel { avatar: "", status: "online", }, + participants: [], + name: "", + isGroup: false, lastMessageId: null, lastMessage: "", lastMessageAt: "", unreadCount: 0, + mentionCount: 0, ...overrides, }; + // A 1:1 DM's participant list IS its recipient, so a fixture that overrides + // only `recipient` should not silently keep the default's participants. + return base.participants.length > 0 ? base : { ...base, participants: [base.recipient] }; } function makeDeps(overrides: Partial = {}): DmHelperDeps { @@ -106,20 +113,25 @@ describe("SidebarDmHelpers", () => { expect(ch!.unreadCount).toBe(3); }); - it("does not overwrite an existing channel with a non-empty name", () => { - // Pre-populate with a channel that already has a name + it("does not rewrite an existing channel whose name already matches", () => { + // Pre-populate with a channel that already carries the DM's display name channelsStore.setState((prev) => { const next = new Map(prev.channels); next.set(100, { id: 100, - name: "ExistingName", + name: "Alice", type: "dm", category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: next }; }); @@ -129,7 +141,7 @@ describe("SidebarDmHelpers", () => { // Name should remain unchanged const ch = channelsStore.getState().channels.get(100); - expect(ch!.name).toBe("ExistingName"); + expect(ch!.name).toBe("Alice"); }); it("overwrites an existing channel with an empty name", () => { @@ -143,9 +155,14 @@ describe("SidebarDmHelpers", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: next }; }); @@ -175,9 +192,14 @@ describe("SidebarDmHelpers", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: next, activeChannelId: 1 }; }); @@ -200,9 +222,14 @@ describe("SidebarDmHelpers", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels: next, activeChannelId: 50 }; }); @@ -384,24 +411,35 @@ describe("SidebarDmHelpers", () => { lastMessage: "Hello!", lastMessageAt: "2025-01-01T00:00:00Z", unreadCount: 3, + mentionCount: 1, }), ); const result = buildDmConversations(null); expect(result).toHaveLength(1); expect(result[0]).toEqual({ + channelId: 100, userId: 10, username: "Alice", avatar: "alice.png", status: "online", + isGroup: false, + participants: [{ id: 10, username: "Alice", avatar: "alice.png" }], lastMessage: "Hello!", timestamp: "2025-01-01T00:00:00Z", unread: true, + // The real counts ride along so the sidebar can render badges rather + // than a bare dot, and so DM mentions survive a reconnect. + unreadCount: 3, + mentionCount: 1, + muted: false, active: false, }); }); - it("marks conversation as active when userId matches activeDmUserId", () => { + // Active is keyed on the CHANNEL, not the recipient: a group DM has no + // single recipient, and the same person can be in both a 1:1 and a group. + it("marks conversation as active when the channel is the active one", () => { addDmChannel( makeDmChannel({ channelId: 100, @@ -409,11 +447,11 @@ describe("SidebarDmHelpers", () => { }), ); - const result = buildDmConversations(10); + const result = buildDmConversations(100); expect(result[0]!.active).toBe(true); }); - it("does not mark conversation as active when userId does not match", () => { + it("does not mark conversation as active when the channel does not match", () => { addDmChannel( makeDmChannel({ channelId: 100, @@ -492,7 +530,7 @@ describe("SidebarDmHelpers", () => { }), ); - const result = buildDmConversations(11); + const result = buildDmConversations(101); expect(result).toHaveLength(2); // Bob was added second so goes first (addDmChannel prepends) const bob = result.find((c) => c.username === "Bob"); diff --git a/Client/tauri-client/tests/unit/sidebar-dm-section.test.ts b/Client/tauri-client/tests/unit/sidebar-dm-section.test.ts index bf3636ec..df4002c4 100644 --- a/Client/tauri-client/tests/unit/sidebar-dm-section.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-dm-section.test.ts @@ -36,10 +36,14 @@ function makeDm(overrides: Partial = {}): DmChannel { return { channelId: 100, recipient: { id: 10, username: "Alice", avatar: "", status: "online" }, + participants: [], + name: "", + isGroup: false, lastMessageId: null, lastMessage: "", lastMessageAt: "", unreadCount: 0, + mentionCount: 0, ...overrides, }; } diff --git a/Client/tauri-client/tests/unit/sidebar-member-section.test.ts b/Client/tauri-client/tests/unit/sidebar-member-section.test.ts index 0b53aa90..baf102d2 100644 --- a/Client/tauri-client/tests/unit/sidebar-member-section.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-member-section.test.ts @@ -391,14 +391,19 @@ describe("SidebarMemberSection", () => { /** Extract the callbacks passed to createMemberList */ function getCapturedCallbacks(): { onKick: (userId: number, username: string) => Promise; - onBan: (userId: number, username: string, reason: string) => Promise; + onBan: ( + userId: number, + username: string, + reason: string, + durationHours: number, + ) => Promise; onChangeRole: (userId: number, username: string, newRole: string) => Promise; } { const calls = (createMemberList as ReturnType).mock.calls; return calls[calls.length - 1]![0]; } - it("kick: calls API and shows success toast", async () => { + it("force logout: calls API and shows success toast", async () => { const mockShow = vi.fn(); const mockApi = { adminKickMember: vi.fn().mockResolvedValue(undefined), @@ -417,12 +422,12 @@ describe("SidebarMemberSection", () => { await callbacks.onKick(2, "Alice"); expect(mockApi.adminKickMember).toHaveBeenCalledWith(2); - expect(mockShow).toHaveBeenCalledWith("Kicked Alice", "success"); + expect(mockShow).toHaveBeenCalledWith("Forced Alice to log out", "success"); section.destroy(); }); - it("kick: shows error toast on API failure", async () => { + it("force logout: shows error toast on API failure", async () => { const mockShow = vi.fn(); const mockApi = { adminKickMember: vi.fn().mockRejectedValue(new Error("Kick denied")), @@ -445,7 +450,7 @@ describe("SidebarMemberSection", () => { section.destroy(); }); - it("kick: shows generic error for non-Error exceptions", async () => { + it("force logout: shows generic error for non-Error exceptions", async () => { const mockShow = vi.fn(); const mockApi = { adminKickMember: vi.fn().mockRejectedValue("string error"), @@ -463,7 +468,7 @@ describe("SidebarMemberSection", () => { const callbacks = getCapturedCallbacks(); await callbacks.onKick(2, "Alice"); - expect(mockShow).toHaveBeenCalledWith("Failed to kick member", "error"); + expect(mockShow).toHaveBeenCalledWith("Failed to force logout", "error"); section.destroy(); }); @@ -484,9 +489,9 @@ describe("SidebarMemberSection", () => { container.appendChild(section.element); const callbacks = getCapturedCallbacks(); - await callbacks.onBan(3, "Bob", "spamming"); + await callbacks.onBan(3, "Bob", "spamming", 0); - expect(mockApi.adminBanMember).toHaveBeenCalledWith(3, "spamming"); + expect(mockApi.adminBanMember).toHaveBeenCalledWith(3, "spamming", 0); expect(mockShow).toHaveBeenCalledWith("Banned Bob", "success"); section.destroy(); @@ -508,7 +513,7 @@ describe("SidebarMemberSection", () => { container.appendChild(section.element); const callbacks = getCapturedCallbacks(); - await callbacks.onBan(3, "Bob", ""); + await callbacks.onBan(3, "Bob", "", 0); expect(mockShow).toHaveBeenCalledWith("Ban denied", "error"); @@ -531,7 +536,7 @@ describe("SidebarMemberSection", () => { container.appendChild(section.element); const callbacks = getCapturedCallbacks(); - await callbacks.onBan(3, "Bob", ""); + await callbacks.onBan(3, "Bob", "", 0); expect(mockShow).toHaveBeenCalledWith("Failed to ban member", "error"); diff --git a/Client/tauri-client/tests/unit/status-picker-custom.test.ts b/Client/tauri-client/tests/unit/status-picker-custom.test.ts new file mode 100644 index 00000000..8e5c22c8 --- /dev/null +++ b/Client/tauri-client/tests/unit/status-picker-custom.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createStatusPicker, type StatusPickerComponent } from "@components/StatusPicker"; +import { MAX_CUSTOM_STATUS_LEN } from "@lib/userStatus"; + +/** + * Phase 6 gave the picker two new jobs: offer "invisible" as a real status + * (instead of "offline" wearing that label), and take a custom status line. + */ + +describe("StatusPicker", () => { + let container: HTMLDivElement; + let picker: StatusPickerComponent | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + picker?.destroy?.(); + picker = null; + container.remove(); + }); + + function labels(): string[] { + return Array.from(container.querySelectorAll(".status-picker-option-label")).map( + (el) => el.textContent ?? "", + ); + } + + function clickOption(label: string): void { + const row = Array.from(container.querySelectorAll(".status-picker-option")).find( + (el) => el.querySelector(".status-picker-option-label")?.textContent === label, + ); + (row as HTMLElement).click(); + } + + it("offers Invisible and sends it as its own value", () => { + const onStatusChange = vi.fn(); + picker = createStatusPicker({ currentStatus: "online", onStatusChange }); + picker.mount(container); + + expect(labels()).toEqual(["Online", "Idle", "Do Not Disturb", "Invisible"]); + + clickOption("Invisible"); + // The picker used to send "offline" here, which the server could not tell + // apart from a dropped connection. + expect(onStatusChange).toHaveBeenCalledExactlyOnceWith("invisible"); + }); + + it("renders no custom status input when no handler is supplied", () => { + picker = createStatusPicker({ currentStatus: "online", onStatusChange: vi.fn() }); + picker.mount(container); + expect(container.querySelector('[data-testid="custom-status-input"]')).toBeNull(); + }); + + it("pre-fills the custom status input and commits it on Enter", () => { + const onCustomStatusChange = vi.fn(); + picker = createStatusPicker({ + currentStatus: "online", + onStatusChange: vi.fn(), + currentCustomStatus: "shipping", + onCustomStatusChange, + }); + picker.mount(container); + + const input = container.querySelector('[data-testid="custom-status-input"]')!; + expect(input.value).toBe("shipping"); + expect(input.getAttribute("maxlength")).toBe(String(MAX_CUSTOM_STATUS_LEN)); + + input.value = " in a meeting "; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(onCustomStatusChange).toHaveBeenCalledExactlyOnceWith("in a meeting"); + expect(input.value).toBe("in a meeting"); + }); + + it("commits on blur and clears with an empty value", () => { + const onCustomStatusChange = vi.fn(); + picker = createStatusPicker({ + currentStatus: "online", + onStatusChange: vi.fn(), + currentCustomStatus: "busy", + onCustomStatusChange, + }); + picker.mount(container); + + const input = container.querySelector('[data-testid="custom-status-input"]')!; + input.value = ""; + input.dispatchEvent(new FocusEvent("blur")); + + expect(onCustomStatusChange).toHaveBeenCalledExactlyOnceWith(""); + }); + + it("does not re-send an unchanged value", () => { + const onCustomStatusChange = vi.fn(); + picker = createStatusPicker({ + currentStatus: "online", + onStatusChange: vi.fn(), + currentCustomStatus: "busy", + onCustomStatusChange, + }); + picker.mount(container); + + const input = container.querySelector('[data-testid="custom-status-input"]')!; + // Enter then the blur it causes: without the guard this would burn two + // presence updates against a one-per-ten-seconds limit. + input.value = "busy"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + input.dispatchEvent(new FocusEvent("blur")); + + expect(onCustomStatusChange).not.toHaveBeenCalled(); + }); + + it("restores the last committed text on Escape", () => { + const onCustomStatusChange = vi.fn(); + picker = createStatusPicker({ + currentStatus: "online", + onStatusChange: vi.fn(), + currentCustomStatus: "busy", + onCustomStatusChange, + }); + picker.mount(container); + + const input = container.querySelector('[data-testid="custom-status-input"]')!; + input.value = "half-typed"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + expect(input.value).toBe("busy"); + expect(onCustomStatusChange).not.toHaveBeenCalled(); + }); + + it("setCustomStatus updates the input without firing the handler", () => { + const onCustomStatusChange = vi.fn(); + picker = createStatusPicker({ + currentStatus: "online", + onStatusChange: vi.fn(), + onCustomStatusChange, + }); + picker.mount(container); + + picker.setCustomStatus("from the server"); + const input = container.querySelector('[data-testid="custom-status-input"]')!; + expect(input.value).toBe("from the server"); + expect(onCustomStatusChange).not.toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/totp-settings.test.ts b/Client/tauri-client/tests/unit/totp-settings.test.ts index d1ae84ef..08699a61 100644 --- a/Client/tauri-client/tests/unit/totp-settings.test.ts +++ b/Client/tauri-client/tests/unit/totp-settings.test.ts @@ -60,6 +60,7 @@ function makeOptions(overrides: Partial = {}): SettingsO onClose: vi.fn(), onChangePassword: vi.fn().mockResolvedValue(undefined), onUpdateProfile: vi.fn().mockResolvedValue(undefined), + onUploadAvatar: vi.fn().mockResolvedValue("/api/v1/files/test"), onLogout: vi.fn(), onDeleteAccount: vi.fn().mockResolvedValue(undefined), onStatusChange: vi.fn(), diff --git a/Client/tauri-client/tests/unit/types.test.ts b/Client/tauri-client/tests/unit/types.test.ts index 4c938d86..f526808c 100644 --- a/Client/tauri-client/tests/unit/types.test.ts +++ b/Client/tauri-client/tests/unit/types.test.ts @@ -239,6 +239,7 @@ describe("Permission bitfield", () => { expect(P.KICK_MEMBERS).toBe(0x40000); expect(P.BAN_MEMBERS).toBe(0x80000); expect(P.MUTE_MEMBERS).toBe(0x100000); + expect(P.MENTION_EVERYONE).toBe(0x200000); expect(P.MANAGE_ROLES).toBe(0x1000000); expect(P.MANAGE_SERVER).toBe(0x2000000); expect(P.MANAGE_INVITES).toBe(0x4000000); diff --git a/Client/tauri-client/tests/unit/user-profile-popup.test.ts b/Client/tauri-client/tests/unit/user-profile-popup.test.ts index 6930623a..9a82da9a 100644 --- a/Client/tauri-client/tests/unit/user-profile-popup.test.ts +++ b/Client/tauri-client/tests/unit/user-profile-popup.test.ts @@ -68,6 +68,8 @@ describe("UserProfilePopup", () => { user, anchorX: 100, anchorY: 100, + onMessage: () => {}, + onCall: () => {}, }); popup.mount(container); @@ -83,7 +85,7 @@ describe("UserProfilePopup", () => { const avatar = container.querySelector(".upp-avatar span"); expect(avatar?.textContent).toBe("B"); - // Message and Call buttons + // Message and Call buttons render when handlers are wired const msgBtn = container.querySelector('[data-testid="upp-message-btn"]'); expect(msgBtn).not.toBeNull(); const callBtn = container.querySelector('[data-testid="upp-call-btn"]'); @@ -92,6 +94,20 @@ describe("UserProfilePopup", () => { popup.destroy?.(); }); + it("omits action buttons that have no handler", () => { + const popup = createUserProfilePopup({ + user: makeUser(), + anchorX: 100, + anchorY: 100, + }); + popup.mount(container); + + expect(container.querySelector('[data-testid="upp-message-btn"]')).toBeNull(); + expect(container.querySelector('[data-testid="upp-call-btn"]')).toBeNull(); + + popup.destroy?.(); + }); + it("outside click closes the popup", () => { const user = makeUser(); const popup = createUserProfilePopup({ @@ -157,3 +173,110 @@ describe("UserProfilePopup", () => { expect(popup.isOpen()).toBe(false); }); }); + +// ─── Phase 6: display name, about, custom status ───────────────────────────── + +describe("UserProfilePopup profile fields", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("shows the display name as the heading and the username as an @handle", () => { + const popup = createUserProfilePopup({ + user: makeUser({ username: "alice", displayName: "Alice A." }), + anchorX: 10, + anchorY: 10, + }); + popup.mount(container); + + expect(container.querySelector(".upp-username")?.textContent).toBe("Alice A."); + // The username is still the handle you @mention, so the popup keeps + // telling you what to type. + expect(container.querySelector(".upp-username-handle")?.textContent).toBe("@alice"); + + popup.destroy?.(); + }); + + it("omits the @handle when there is no display name", () => { + const popup = createUserProfilePopup({ + user: makeUser({ username: "alice", displayName: null }), + anchorX: 10, + anchorY: 10, + }); + popup.mount(container); + + expect(container.querySelector(".upp-username")?.textContent).toBe("alice"); + expect(container.querySelector(".upp-username-handle")?.textContent).toBe(""); + + popup.destroy?.(); + }); + + it("renders the about section from real data", () => { + const popup = createUserProfilePopup({ + user: makeUser({ about: "Writes tests for a living." }), + anchorX: 10, + anchorY: 10, + }); + popup.mount(container); + + // The section used to be dead code — `about` was hardcoded null at every + // call site until phase 6 gave the column somewhere to come from. + expect(container.querySelector(".upp-about-text")?.textContent).toBe( + "Writes tests for a living.", + ); + + popup.destroy?.(); + }); + + it("renders the custom status line, and nothing when there is none", () => { + const withStatus = createUserProfilePopup({ + user: makeUser({ customStatus: "shipping phase 6" }), + anchorX: 10, + anchorY: 10, + }); + withStatus.mount(container); + expect(container.querySelector(".upp-custom-status")?.textContent).toBe("shipping phase 6"); + withStatus.destroy?.(); + + const without = createUserProfilePopup({ user: makeUser(), anchorX: 10, anchorY: 10 }); + without.mount(container); + expect(container.querySelector(".upp-custom-status")?.textContent).toBe(""); + without.destroy?.(); + }); + + it("labels the owner's own invisible status", () => { + // Only ever reachable for the signed-in user: everyone else is mapped to + // offline before the payload leaves the server. + const popup = createUserProfilePopup({ + user: makeUser({ status: "invisible" }), + anchorX: 10, + anchorY: 10, + }); + popup.mount(container); + + const statusText = container.querySelector(".upp-status-line")?.textContent ?? ""; + expect(statusText).toContain("Invisible"); + + popup.destroy?.(); + }); + + it("uses the display name's initial for the letter fallback", () => { + const popup = createUserProfilePopup({ + user: makeUser({ username: "alice", displayName: "Zoe", avatar: null }), + anchorX: 10, + anchorY: 10, + }); + popup.mount(container); + + expect(container.querySelector(".upp-avatar .avatar-initial")?.textContent).toBe("Z"); + + popup.destroy?.(); + }); +}); diff --git a/Client/tauri-client/tests/unit/user-status.test.ts b/Client/tauri-client/tests/unit/user-status.test.ts index 6ae175dc..546481dd 100644 --- a/Client/tauri-client/tests/unit/user-status.test.ts +++ b/Client/tauri-client/tests/unit/user-status.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { loadUserStatus, saveUserStatus, onUserStatusChange } from "@lib/userStatus"; +import { + MAX_CUSTOM_STATUS_LEN, + loadCustomStatus, + loadUserStatus, + loadUserStatusOrigin, + onUserStatusChange, + saveCustomStatus, + saveUserStatus, +} from "@lib/userStatus"; describe("userStatus", () => { beforeEach(() => { @@ -33,10 +41,44 @@ describe("userStatus", () => { expect(seen).toHaveBeenCalledWith("idle"); unsub(); - saveUserStatus("offline"); + saveUserStatus("invisible"); expect(seen).toHaveBeenCalledTimes(1); }); + it("migrates a stored 'offline' to invisible", () => { + // "offline" was this client's old spelling of "appear offline"; phase 6 + // gave that its own value, and a user who picked it meant invisible. + localStorage.setItem("owncord:settings:userStatus", JSON.stringify("offline")); + expect(loadUserStatus()).toBe("invisible"); + }); + + it("records who chose the status", () => { + saveUserStatus("dnd"); + expect(loadUserStatusOrigin()).toBe("manual"); + + saveUserStatus("idle", "auto"); + expect(loadUserStatusOrigin()).toBe("auto"); + + // The default is "manual" on purpose: everything that is not the idle + // timer is a deliberate choice, and defaulting the other way would let a + // real choice be silently revoked. + saveUserStatus("online"); + expect(loadUserStatusOrigin()).toBe("manual"); + }); + + it("defaults the origin to manual when nothing is stored", () => { + expect(loadUserStatusOrigin()).toBe("manual"); + }); + + it("round-trips and bounds the custom status text", () => { + expect(loadCustomStatus()).toBe(""); + saveCustomStatus("shipping phase 6"); + expect(loadCustomStatus()).toBe("shipping phase 6"); + + saveCustomStatus("x".repeat(MAX_CUSTOM_STATUS_LEN + 50)); + expect(loadCustomStatus()).toHaveLength(MAX_CUSTOM_STATUS_LEN); + }); + it("ignores unrelated preference changes", () => { const seen = vi.fn(); onUserStatusChange(seen, { signal: new AbortController().signal }); diff --git a/Client/tauri-client/tests/unit/voice-audio-tab.test.ts b/Client/tauri-client/tests/unit/voice-audio-tab.test.ts index 9afd7d71..181a1541 100644 --- a/Client/tauri-client/tests/unit/voice-audio-tab.test.ts +++ b/Client/tauri-client/tests/unit/voice-audio-tab.test.ts @@ -725,7 +725,10 @@ describe("VoiceAudioTab UI structure", () => { ac.abort(); }); - it("cleanup stops mic and camera streams", () => { + it("cleanup stops mic and camera streams", async () => { + // A saved video device is required for the camera preview to start at all. + localStorage.setItem("owncord:settings:videoInputDevice", '"cam-1"'); + const stopMicTrack = vi.fn(); const stopCamTrack = vi.fn(); const micStream = { getTracks: () => [{ stop: stopMicTrack }] } as unknown as MediaStream; @@ -734,17 +737,32 @@ describe("VoiceAudioTab UI structure", () => { vi.stubGlobal("navigator", { mediaDevices: { enumerateDevices: vi.fn().mockResolvedValue([]), - getUserMedia: vi.fn().mockResolvedValue(micStream), + getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => { + if (constraints.video && constraints.audio === false) { + return Promise.resolve(camStream); + } + return Promise.resolve(micStream); + }), }, }); const ac = new AbortController(); const tab = createVoiceAudioTab(ac.signal); - tab.build(); + const el = tab.build(); + document.body.appendChild(el); + const preview = el.querySelector("video") as HTMLVideoElement; + + // Wait for both the mic-monitoring and camera-preview getUserMedia calls to + // resolve and register their streams before triggering cleanup. + await vi.waitFor(() => { + expect(preview.srcObject).toBe(camStream); + }); + tab.cleanup(); - // After cleanup, streams should be stopped - // (the mic track stop is called in cleanupMic) + expect(stopMicTrack).toHaveBeenCalled(); + expect(stopCamTrack).toHaveBeenCalled(); + ac.abort(); }); diff --git a/Client/tauri-client/tests/unit/voice-widget.test.ts b/Client/tauri-client/tests/unit/voice-widget.test.ts index dc0f17a1..a8e88018 100644 --- a/Client/tauri-client/tests/unit/voice-widget.test.ts +++ b/Client/tauri-client/tests/unit/voice-widget.test.ts @@ -127,9 +127,14 @@ describe("VoiceWidget", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels }; }); @@ -161,9 +166,14 @@ describe("VoiceWidget", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels }; }); @@ -195,9 +205,14 @@ describe("VoiceWidget", () => { category: null, position: 0, unreadCount: 0, + mentionCount: 0, lastMessageId: null, canSend: true, + topic: "", slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, }); return { ...prev, channels }; }); diff --git a/Client/tauri-client/tests/unit/voice.store.test.ts b/Client/tauri-client/tests/unit/voice.store.test.ts index 9bfc0adc..b74e7daa 100644 --- a/Client/tauri-client/tests/unit/voice.store.test.ts +++ b/Client/tauri-client/tests/unit/voice.store.test.ts @@ -146,6 +146,9 @@ describe("voice store", () => { speaking: true, camera: false, screenshare: false, + // Absent from the payload means not moderator-imposed. + serverMuted: false, + serverDeafened: false, }); }); @@ -168,6 +171,33 @@ describe("voice store", () => { updateVoiceState(FULL_VOICE_PAYLOAD); expect(voiceStore.getState()).not.toBe(before); }); + + it("mirrors the moderator flags into the local ones for the signed-in user", () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "dave", avatar: null, role: "member" }, + })); + updateVoiceState({ + ...FULL_VOICE_PAYLOAD, + muted: true, + deafened: true, + server_muted: true, + server_deafened: true, + }); + const state = voiceStore.getState(); + expect(state.localServerMuted).toBe(true); + expect(state.localServerDeafened).toBe(true); + expect(state.voiceUsers.get(10)?.get(5)?.serverMuted).toBe(true); + }); + + it("leaves the local flags alone for another user's state", () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 999, username: "me", avatar: null, role: "member" }, + })); + updateVoiceState({ ...FULL_VOICE_PAYLOAD, muted: true, server_muted: true }); + expect(voiceStore.getState().localServerMuted).not.toBe(true); + }); }); describe("removeVoiceUser", () => { @@ -205,6 +235,20 @@ describe("voice store", () => { expect(voiceStore.getState().currentChannelId).toBe(42); }); + it("leaveVoiceChannel clears the moderator-imposed flags with the session", () => { + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "dave", avatar: null, role: "member" }, + })); + joinVoiceChannel(10); + updateVoiceState({ ...FULL_VOICE_PAYLOAD, muted: true, server_muted: true }); + expect(voiceStore.getState().localServerMuted).toBe(true); + + leaveVoiceChannel(); + expect(voiceStore.getState().localServerMuted).toBe(false); + expect(voiceStore.getState().localServerDeafened).toBe(false); + }); + it("joinVoiceChannel overwrites previous channel", () => { joinVoiceChannel(42); joinVoiceChannel(99); diff --git a/Server/admin/admin.go b/Server/admin/admin.go index 71a6af57..1d3db65e 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -21,13 +21,13 @@ var staticFiles embed.FS // // Routes: // -// /api/* — admin REST API (all require ADMINISTRATOR permission) +// /api/* — admin REST API (all require a moderation permission; see NewAdminAPI) // /* — embedded static files (SPA; index.html for unknown paths) -func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, opts ...SetupOptions) http.Handler { +func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, opts ...SetupOptions) http.Handler { r := chi.NewRouter() // Admin REST API mounted at /api - r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod, opts...)) + r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod, roles, opts...)) // Static files — serve from the "static" sub-tree of the embedded FS. // The //go:embed static directive in this package embeds as "static/…", @@ -48,8 +48,13 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater. } r.Get("/", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + // img-src adds blob: for the Emoji section: /api/v1/emoji/{id}/image + // requires an Authorization header, which cannot send, so + // each thumbnail is fetched with the session token and swapped in as a + // blob: URL. blob: is same-origin, opaque and unreadable across + // documents — it widens nothing an attacker could aim at. w.Header().Set("Content-Security-Policy", - "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'") + "default-src 'self'; img-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'") _, _ = w.Write(indexHTML) }) r.Handle("/*", http.FileServer(http.FS(staticFS))) diff --git a/Server/admin/admin_handler_test.go b/Server/admin/admin_handler_test.go index 5c6e884d..d4fc897c 100644 --- a/Server/admin/admin_handler_test.go +++ b/Server/admin/admin_handler_test.go @@ -20,7 +20,7 @@ import ( // http.Handler with all dependencies wired. func TestNewHandler_ReturnsNonNilHandler(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) if h == nil { t.Fatal("NewHandler returned nil handler") } @@ -30,7 +30,7 @@ func TestNewHandler_ReturnsNonNilHandler(t *testing.T) { // responds with 200 and HTML content (the embedded admin SPA). func TestNewHandler_ServesStaticRoot(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -61,7 +61,7 @@ func TestNewHandler_ServesStaticRoot(t *testing.T) { // Content-Security-Policy header allowing inline scripts and styles. func TestNewHandler_SetsCSPOnRoot(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -77,7 +77,7 @@ func TestNewHandler_SetsCSPOnRoot(t *testing.T) { // through the NewHandler-returned handler (setup/status endpoint is unauthenticated). func TestNewHandler_APIRoutesMounted(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) req := httptest.NewRequest(http.MethodGet, "/api/setup/status", nil) w := httptest.NewRecorder() @@ -93,7 +93,7 @@ func TestNewHandler_APIRoutesMounted(t *testing.T) { // /api require a valid token. func TestNewHandler_AuthProtectedRoute(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) // /api/stats requires authentication req := httptest.NewRequest(http.MethodGet, "/api/stats", nil) @@ -110,7 +110,7 @@ func TestNewHandler_AuthProtectedRoute(t *testing.T) { func TestNewHandler_WithUpdater(t *testing.T) { database := openAdminTestDB(t) u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord") - h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil, newTestModService(database)) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil, newTestModService(database), newTestRoleService(database)) if h == nil { t.Fatal("NewHandler with updater returned nil handler") } @@ -122,7 +122,7 @@ func TestNewHandler_WithUpdater(t *testing.T) { // (position == 100) can reach backup endpoints. func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) // createAdminUser creates an Owner-role user (role_id=1, position=100) ownerToken := createAdminUser(t, database) @@ -157,7 +157,7 @@ func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) { // (position < 100) cannot reach owner-only endpoints. func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) // Create admin user (role_id=2, position=80) adminUID, _ := database.CreateUser(context.Background(), "middlewareadmin", "hash", 2) @@ -175,7 +175,7 @@ func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) { // reach owner-only endpoints. func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) memberToken := createMemberUser(t, database) @@ -192,7 +192,7 @@ func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) { // rejected before reaching ownerOnlyMiddleware. func TestOwnerOnlyMiddleware_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodPost, "/backup", "", nil) diff --git a/Server/admin/api.go b/Server/admin/api.go index 4e0ccd7a..d09e7ead 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -6,6 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/owncord/server/auth" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" "github.com/owncord/server/service" "github.com/owncord/server/updater" ) @@ -13,13 +14,15 @@ import ( // ─── NewAdminAPI ────────────────────────────────────────────────────────────── // NewAdminAPI returns a chi router with all /admin/api/* routes. All routes -// are protected by adminAuthMiddleware which requires the ADMINISTRATOR bit, -// except for the setup endpoints which are unauthenticated. +// except the unauthenticated setup endpoints are protected by +// adminAuthMiddleware, which admits any role holding a bit of +// permissions.AdminPerimeter; route groups then require the specific bit +// (requirePerm) or the Owner role (ownerOnlyMiddleware). // // The optional trailing SetupOptions enables the first-run wizard's // config.yaml write-back and restart; without it the setup endpoints keep // their legacy account-only behaviour (the case in most tests). -func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, opts ...SetupOptions) http.Handler { +func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, opts ...SetupOptions) http.Handler { r := chi.NewRouter() var setupOpts SetupOptions @@ -40,25 +43,60 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater r.Get("/logs/stream", handleLogStream(database, logBuf)) } - // All remaining routes require authentication and ADMINISTRATOR permission. + // All remaining routes require authentication plus at least one + // moderation-capable bit (permissions.AdminPerimeter). Route groups that + // map onto a specific bit re-check it with requirePerm; the rest + // (stats, users list, me) are perimeter-level. r.Group(func(r chi.Router) { r.Use(adminAuthMiddleware(database)) // Log stream ticket — issues a single-use, 30s TTL ticket for SSE auth. - r.Post("/logs/ticket", handleLogTicket(database)) + // ADMINISTRATOR-gated to match handleLogStream's own re-check: server + // logs are not scoped to any narrower moderation bit. + r.With(requirePerm(permissions.Administrator)). + Post("/logs/ticket", handleLogTicket(database)) r.Get("/stats", handleGetStats(database, hub)) + r.Get("/me", handleGetMe()) r.Get("/users", handleListUsers(database)) + // Ban/unban and role change are authorized inside ModerationService + // (BAN_MEMBERS / MANAGE_ROLES + hierarchy), so the route itself stays + // perimeter-level — a moderator with only BAN_MEMBERS must reach it. r.Patch("/users/{id}", handlePatchUser(database, hub, permInvalidator, mod)) - r.Delete("/users/{id}/sessions", handleForceLogout(database)) - r.Get("/channels", handleListChannels(database)) - r.Post("/channels", handleCreateChannel(database, hub)) - r.Patch("/channels/{id}", handlePatchChannel(database, hub)) - r.Delete("/channels/{id}", handleDeleteChannel(database, hub)) - r.Get("/channels/{id}/permissions", handleGetChannelPermissions(database)) - r.Put("/channels/{id}/permissions/{roleId}", handlePutChannelPermission(database, hub, permInvalidator)) - r.Delete("/channels/{id}/permissions/{roleId}", handleDeleteChannelPermission(database, hub, permInvalidator)) - r.Get("/audit-log", handleGetAuditLog(database)) + r.With(requirePerm(permissions.KickMembers)). + Delete("/users/{id}/sessions", handleForceLogout(mod)) + + r.Group(func(r chi.Router) { + r.Use(requirePerm(permissions.ManageChannels)) + r.Get("/channels", handleListChannels(database)) + r.Post("/channels", handleCreateChannel(database, hub)) + r.Patch("/channels/{id}", handlePatchChannel(database, hub)) + r.Delete("/channels/{id}", handleDeleteChannel(database, hub)) + r.Get("/channels/{id}/permissions", handleGetChannelPermissions(database)) + r.Put("/channels/{id}/permissions/{roleId}", handlePutChannelPermission(database, hub, permInvalidator)) + r.Delete("/channels/{id}/permissions/{roleId}", handleDeleteChannelPermission(database, hub, permInvalidator)) + // Per-user overrides — the last layer of the resolution order, + // gated on the same MANAGE_CHANNELS bit as the role layer. + r.Put("/channels/{id}/user-permissions/{userId}", handlePutChannelUserPermission(database, hub, permInvalidator)) + r.Delete("/channels/{id}/user-permissions/{userId}", handleDeleteChannelUserPermission(database, hub, permInvalidator)) + }) + + // Role CRUD. MANAGE_ROLES gates the group; RoleService additionally + // enforces the hierarchy (manage only roles below your own position, + // never grant a bit your role lacks) and refuses to delete the Owner + // or the default role. + r.Group(func(r chi.Router) { + r.Use(requirePerm(permissions.ManageRoles)) + r.Get("/roles", handleListRoles(roles)) + r.Post("/roles", handleCreateRole(database, hub, roles)) + // Registered before /roles/{id} so "reorder" is never parsed as an id. + r.Patch("/roles/reorder", handleReorderRoles(hub, permInvalidator, roles)) + r.Patch("/roles/{id}", handlePatchRole(database, hub, permInvalidator, roles)) + r.Delete("/roles/{id}", handleDeleteRole(database, hub, permInvalidator, roles)) + }) + + r.With(requirePerm(permissions.ViewAuditLog)). + Get("/audit-log", handleGetAuditLog(database)) // API tokens — Owner-only. Minting a network-reachable, revocation- // surviving bearer credential is gated like backups/updates. r.Get("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { @@ -70,8 +108,11 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater r.Delete("/tokens/{id}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { ownerOnlyMiddleware(database, handleRevokeAPIToken(database)).ServeHTTP(w, req) })) - r.Get("/settings", handleGetSettings(database)) - r.Patch("/settings", handlePatchSettings(database)) + r.Group(func(r chi.Router) { + r.Use(requirePerm(permissions.ManageServer)) + r.Get("/settings", handleGetSettings(database)) + r.Patch("/settings", handlePatchSettings(database)) + }) r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { ownerOnlyMiddleware(database, handleBackup(database)).ServeHTTP(w, req) })) diff --git a/Server/admin/api_edge_cases_test.go b/Server/admin/api_edge_cases_test.go index 4e6b5e46..e49e8578 100644 --- a/Server/admin/api_edge_cases_test.go +++ b/Server/admin/api_edge_cases_test.go @@ -21,7 +21,7 @@ import ( // their own account via the admin panel. func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // The admin user created by createAdminUser has id=1. We try to patch id=1. @@ -37,7 +37,7 @@ func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) { // banned user unbans them and returns 200. func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Create and ban a target user first. @@ -58,10 +58,52 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { } } +// TestAdminAPI_PatchUser_TempBan verifies that ban_duration_hours stores an +// expiry so the ban lapses on its own. +func TestAdminAPI_PatchUser_TempBan(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + targetUID, _ := database.CreateUser(context.Background(), "tempbanme", "hash", 3) + + body := map[string]any{"banned": true, "ban_reason": "cooling off", "ban_duration_hours": 24} + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) + + if w.Code != http.StatusOK { + t.Fatalf("temp ban status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + user, _ := database.GetUserByID(context.Background(), targetUID) + if !user.Banned { + t.Fatal("user should be banned") + } + if user.BanExpires == nil { + t.Fatal("ban_expires should be set for a temporary ban") + } +} + +// TestAdminAPI_PatchUser_TempBanOutOfRange verifies duration bounds are enforced. +func TestAdminAPI_PatchUser_TempBanOutOfRange(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + targetUID, _ := database.CreateUser(context.Background(), "toolongban", "hash", 3) + + for _, hours := range []int{-1, 24*365 + 1} { + body := map[string]any{"banned": true, "ban_duration_hours": hours} + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) + if w.Code != http.StatusBadRequest { + t.Errorf("ban_duration_hours=%d status = %d, want 400; body: %s", hours, w.Code, w.Body.String()) + } + } +} + // TestAdminAPI_PatchUser_InvalidBody verifies that a non-JSON body returns 400. func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "invalidbody", "hash", 3) @@ -83,7 +125,7 @@ func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) { // "type" field causes the channel to be created with type "text". func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -108,7 +150,7 @@ func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) { // TestAdminAPI_CreateChannel_InvalidBody verifies that a malformed body returns 400. func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) req := httptest.NewRequest(http.MethodPost, "/channels", bytes.NewReader([]byte("not-json"))) @@ -128,7 +170,7 @@ func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) { // the URL returns 400. func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/users/notanumber/sessions", token, nil) @@ -144,7 +186,7 @@ func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) { // returns 400. func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "malformed", "text", "", "", 0) @@ -166,7 +208,7 @@ func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) { // to 500 (testing the queryInt cap branch). func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Passing limit=9999 should be silently capped to 500. @@ -183,7 +225,7 @@ func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) { // when no updater is configured. func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -199,7 +241,7 @@ func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) { // returns 400. func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/channels/notanumber", token, nil) @@ -215,7 +257,7 @@ func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) { // returns 400. func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "x"} @@ -231,7 +273,7 @@ func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) { // TestAdminAPI_AuditLog_Pagination verifies that limit and offset params work. func TestAdminAPI_AuditLog_Pagination(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Create several audit entries. @@ -262,7 +304,7 @@ func TestAdminAPI_AuditLog_Pagination(t *testing.T) { // hub is nil (the OnlineCount field defaults to 0). func TestAdminAPI_Stats_NilHub(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -289,7 +331,7 @@ func TestAdminAPI_Stats_NilHub(t *testing.T) { // falls back to the default (testing the queryInt error-fallback branch). func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=notanumber", token, nil) @@ -303,7 +345,7 @@ func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) { // the default (testing the n < 1 branch of queryInt). func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // limit=0 triggers the n < 1 fallback in queryInt @@ -321,7 +363,7 @@ func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) { // BroadcastMemberBan). func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "ban-nohub", "hash", 3) @@ -346,7 +388,7 @@ func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) { func TestAdminAPI_LogStreamTicketFlow(t *testing.T) { database := openAdminTestDB(t) logBuf := admin.NewRingBuffer(8) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) ticketResp := doRequest(t, handler, http.MethodPost, "/logs/ticket", token, nil) @@ -441,7 +483,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) { // around BroadcastMemberUpdate). func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "role-nohub", "hash", 3) @@ -466,7 +508,7 @@ func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) { // providing ban_reason is accepted (reason defaults to empty string). func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "banwithout", "hash", 3) @@ -487,7 +529,7 @@ func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) { func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "rolebroadcast", "hash", 3) @@ -509,7 +551,7 @@ func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) { // needs_setup=true when the database has no users. func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil) @@ -529,7 +571,7 @@ func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) { // TestAdminAPI_SetupStatus_AlreadySetup verifies needs_setup=false when users exist. func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) _, _ = database.CreateUser(context.Background(), "existing", "hash", 1) @@ -550,7 +592,7 @@ func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) { // session, channel, and invite. func TestAdminAPI_Setup_Success(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) body := map[string]string{ "username": "owner", @@ -581,7 +623,7 @@ func TestAdminAPI_Setup_Success(t *testing.T) { // when users already exist. func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) _, _ = database.CreateUser(context.Background(), "existing", "hash", 1) @@ -600,7 +642,7 @@ func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) { // username or password returns 400. func TestAdminAPI_Setup_MissingFields(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) body := map[string]string{ "username": "", @@ -616,7 +658,7 @@ func TestAdminAPI_Setup_MissingFields(t *testing.T) { // TestAdminAPI_Setup_WeakPassword verifies that a weak password is rejected. func TestAdminAPI_Setup_WeakPassword(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) body := map[string]string{ "username": "owner", @@ -632,7 +674,7 @@ func TestAdminAPI_Setup_WeakPassword(t *testing.T) { // TestAdminAPI_Setup_InvalidBody verifies that a non-JSON body returns 400. func TestAdminAPI_Setup_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewReader([]byte("not-json"))) w := httptest.NewRecorder() diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index ee9401cf..c1863002 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -27,6 +27,15 @@ func newTestModService(database *db.DB) *service.ModerationService { return service.NewModerationService(st, service.NewPermissionService(st, checker)) } +// newTestRoleService builds a real RoleService over the test database so the +// role routes exercise the production authorization (MANAGE_ROLES + hierarchy) +// instead of a stub. +func newTestRoleService(database *db.DB) *service.RoleService { + st := database + checker := permissions.NewChecker(st) + return service.NewRoleService(st, service.NewPermissionService(st, checker)) +} + // adminSchema is a minimal in-memory schema for admin API tests. var adminSchema = []byte(` CREATE TABLE IF NOT EXISTS roles ( @@ -38,6 +47,8 @@ CREATE TABLE IF NOT EXISTS roles ( is_default INTEGER NOT NULL DEFAULT 0 ); +CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name_nocase ON roles(name COLLATE NOCASE); + 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), @@ -56,7 +67,10 @@ CREATE TABLE IF NOT EXISTS users ( banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT, - identity_public_key TEXT + identity_public_key TEXT, + display_name TEXT, + about TEXT, + custom_status TEXT ); CREATE TABLE IF NOT EXISTS sessions ( @@ -85,7 +99,9 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, - voice_max_video INTEGER NOT NULL DEFAULT 0 + voice_max_video INTEGER NOT NULL DEFAULT 0, + nsfw INTEGER NOT NULL DEFAULT 0, + is_group INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channel_overrides ( @@ -97,6 +113,14 @@ CREATE TABLE IF NOT EXISTS channel_overrides ( UNIQUE(channel_id, role_id) ); +CREATE TABLE IF NOT EXISTS channel_user_overrides ( + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (channel_id, user_id) +); + CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, @@ -106,8 +130,15 @@ CREATE TABLE IF NOT EXISTS messages ( pinned INTEGER NOT NULL DEFAULT 0, timestamp TEXT NOT NULL DEFAULT (datetime('now')), reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, - edited_at TEXT + edited_at TEXT, + mentions_everyone INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS message_mentions ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, mentioned_user_id) +); + CREATE TABLE IF NOT EXISTS invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -231,7 +262,7 @@ func doRequest(t *testing.T, handler http.Handler, method, path, token string, b func TestAdminAPI_Stats_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -254,7 +285,7 @@ func TestAdminAPI_Stats_OK(t *testing.T) { func TestAdminAPI_Stats_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodGet, "/stats", "", nil) @@ -265,7 +296,7 @@ func TestAdminAPI_Stats_Unauthenticated(t *testing.T) { func TestAdminAPI_Stats_Forbidden(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createMemberUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -279,7 +310,7 @@ func TestAdminAPI_Stats_Forbidden(t *testing.T) { func TestAdminAPI_ListUsers_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil) @@ -300,7 +331,7 @@ func TestAdminAPI_ListUsers_OK(t *testing.T) { func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // No query params — should use defaults @@ -313,7 +344,7 @@ func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) { func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodGet, "/users", "", nil) @@ -331,7 +362,7 @@ func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) { // owner via the raw UPDATE). func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) ownerToken := createAdminUser(t, database) // Owner role (pos 100) // A second owner-rank user: equal position, cannot be banned. @@ -394,7 +425,7 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { func TestAdminAPI_PatchUser_BanUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Create a target user @@ -422,7 +453,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) { func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "rolechange", "hash", 3) @@ -444,7 +475,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { func TestAdminAPI_PatchUser_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]any{"banned": true} @@ -457,7 +488,7 @@ func TestAdminAPI_PatchUser_NotFound(t *testing.T) { func TestAdminAPI_PatchUser_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil) @@ -471,7 +502,7 @@ func TestAdminAPI_PatchUser_InvalidID(t *testing.T) { func TestAdminAPI_ForceLogout_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "logoutme", "hash", 3) @@ -491,7 +522,7 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) { func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil) @@ -504,7 +535,7 @@ func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) { func TestAdminAPI_ListChannels_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) _, _ = database.AdminCreateChannel(context.Background(), "general", "text", "", "", 0) @@ -528,7 +559,7 @@ func TestAdminAPI_ListChannels_OK(t *testing.T) { func TestAdminAPI_CreateChannel_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -555,7 +586,7 @@ func TestAdminAPI_CreateChannel_OK(t *testing.T) { func TestAdminAPI_CreateChannel_MissingName(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -572,7 +603,7 @@ func TestAdminAPI_CreateChannel_MissingName(t *testing.T) { func TestAdminAPI_UpdateChannel_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "old", "text", "", "", 0) @@ -593,7 +624,7 @@ func TestAdminAPI_UpdateChannel_OK(t *testing.T) { func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "x"} @@ -608,7 +639,7 @@ func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) { func TestAdminAPI_DeleteChannel_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "del-me", "text", "", "", 0) @@ -622,7 +653,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) { func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil) @@ -636,7 +667,7 @@ func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) { func TestAdminAPI_AuditLog_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) uid, _ := database.CreateUser(context.Background(), "actor", "hash", 1) @@ -659,7 +690,7 @@ func TestAdminAPI_AuditLog_OK(t *testing.T) { func TestAdminAPI_AuditLog_Empty(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil) @@ -679,7 +710,7 @@ func TestAdminAPI_AuditLog_Empty(t *testing.T) { func TestAdminAPI_GetSettings_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/settings", token, nil) @@ -701,7 +732,7 @@ func TestAdminAPI_GetSettings_OK(t *testing.T) { func TestAdminAPI_PatchSettings_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -726,7 +757,7 @@ func TestAdminAPI_PatchSettings_OK(t *testing.T) { func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json"))) @@ -743,7 +774,7 @@ func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) { func TestAdminAPI_Backup_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) // Admin (role 2) can authenticate but is not Owner (role 1, position 100) adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2) @@ -760,7 +791,7 @@ func TestAdminAPI_Backup_RequiresOwner(t *testing.T) { func TestAdminAPI_Backup_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodPost, "/backup", "", nil) @@ -777,7 +808,7 @@ func TestAdminAPI_Backup_Unauthenticated(t *testing.T) { // which logs an audit entry containing the actor_id. func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Create a target user to act on. @@ -811,7 +842,7 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { // DELETE /users/{id}/sessions path. func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "logoutctx", "hash", 3) @@ -843,7 +874,7 @@ func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { // returns 400 without writing anything to the database. func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -869,7 +900,7 @@ func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) { // containing both valid and invalid keys is rejected entirely (no partial write). func TestAdminAPI_PatchSettings_RejectsMixedKeys(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -913,7 +944,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) { for _, key := range whitelistedKeys { t.Run(key, func(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) value := "testvalue" @@ -934,7 +965,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) { // (no-op update) is accepted and returns the current settings. func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]string{} @@ -947,7 +978,7 @@ func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) { func TestAdminAPI_PatchSettings_RejectsRequire2FAWhenUsersNotEnrolled(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -963,7 +994,7 @@ func TestAdminAPI_PatchSettings_RejectsRequire2FAWhenUsersNotEnrolled(t *testing func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrationClosed(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil { @@ -983,7 +1014,7 @@ func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrat func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -1002,7 +1033,7 @@ func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) { // expose the PasswordHash field in any returned user object. func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Create a second user so the list is non-trivial. @@ -1029,7 +1060,7 @@ func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { // expose the TOTPSecret field. func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) @@ -1048,7 +1079,7 @@ func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) { // are still present after the sensitive-field removal. func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) @@ -1077,7 +1108,7 @@ func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) { // not expose PasswordHash in the returned user object. func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "patchvictim", "topsecretbcrypt", 3) @@ -1105,7 +1136,7 @@ func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { // not expose TOTPSecret in the returned user object. func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "patchtotp", "hash", 3) @@ -1135,7 +1166,11 @@ type mockHub struct { memberBanIDs []int64 memberUpdates []memberUpdateCall visibilityRefreshes []*db.Channel - clientCount int + // allVisibilityRefreshes counts RefreshAllChannelVisibility calls — the + // role-edit equivalent of visibilityRefreshes. + allVisibilityRefreshes int + rolesUpdates [][]*db.Role + clientCount int } type memberUpdateCall struct { @@ -1176,6 +1211,14 @@ func (m *mockHub) RefreshChannelVisibility(ch *db.Channel) { m.visibilityRefreshes = append(m.visibilityRefreshes, ch) } +func (m *mockHub) RefreshAllChannelVisibility() { + m.allVisibilityRefreshes++ +} + +func (m *mockHub) BroadcastRolesUpdate(roles []*db.Role) { + m.rolesUpdates = append(m.rolesUpdates, roles) +} + func (m *mockHub) ClientCount() int { return m.clientCount } @@ -1183,7 +1226,7 @@ func (m *mockHub) ClientCount() int { func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -1206,7 +1249,7 @@ func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) { func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) // nil hub: handler must not panic - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "safe-channel", "type": "text"} @@ -1220,7 +1263,7 @@ func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) { func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "before", "text", "", "", 0) @@ -1241,7 +1284,7 @@ func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "patchme", "text", "", "", 0) @@ -1256,7 +1299,7 @@ func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) { func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "delete-me", "text", "", "", 0) @@ -1276,7 +1319,7 @@ func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) { func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "del-no-hub", "text", "", "", 0) @@ -1291,7 +1334,7 @@ func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) { func TestAdminAPI_CreateAPIToken_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Owner role w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "ci-bot"}) @@ -1321,7 +1364,7 @@ func TestAdminAPI_CreateAPIToken_OK(t *testing.T) { func TestAdminAPI_CreateAPIToken_MissingLabel(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": " "}) @@ -1332,7 +1375,7 @@ func TestAdminAPI_CreateAPIToken_MissingLabel(t *testing.T) { func TestAdminAPI_ListAPITokens_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) hash := auth.HashToken("raw-secret-value") @@ -1361,7 +1404,7 @@ func TestAdminAPI_ListAPITokens_OK(t *testing.T) { func TestAdminAPI_RevokeAPIToken_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) hash := auth.HashToken("revoke-me") @@ -1383,7 +1426,7 @@ func TestAdminAPI_RevokeAPIToken_OK(t *testing.T) { func TestAdminAPI_RevokeAPIToken_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/tokens/99999", token, nil) @@ -1397,7 +1440,7 @@ func TestAdminAPI_RevokeAPIToken_NotFound(t *testing.T) { // survives password change + bulk logout). func TestAdminAPI_Tokens_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2) // Admin, not Owner token := "admin-only-token" @@ -1411,7 +1454,7 @@ func TestAdminAPI_Tokens_RequiresOwner(t *testing.T) { func TestAdminAPI_Tokens_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodGet, "/tokens", "", nil) if w.Code != http.StatusUnauthorized { diff --git a/Server/admin/emoji_section_test.go b/Server/admin/emoji_section_test.go new file mode 100644 index 00000000..f3a87f03 --- /dev/null +++ b/Server/admin/emoji_section_test.go @@ -0,0 +1,58 @@ +package admin_test + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// The panel's Emoji section is plain JS inside static/index.html, so nothing +// compiles it. These tests tie the three places that have to agree — the NAV +// entry, the renderContent dispatch map, and the permission gate — so a +// half-wired section fails here rather than as a blank page for an operator. + +func adminPanelSource(t *testing.T) string { + t.Helper() + source, err := os.ReadFile("static/index.html") + if err != nil { + t.Fatalf("read admin panel: %v", err) + } + return string(source) +} + +func TestAdminPanelEmojiSectionIsWired(t *testing.T) { + source := adminPanelSource(t) + + navRe := regexp.MustCompile(`\{id:'emoji',[^}]*allowed:\(\)=>can\(PERM\.MANAGE_SERVER\)\}`) + if !navRe.MatchString(source) { + t.Error("no NAV entry for 'emoji' gated on PERM.MANAGE_SERVER") + } + if !strings.Contains(source, "emoji:renderEmoji") { + t.Error("renderContent dispatch map has no emoji:renderEmoji entry") + } + for _, fn := range []string{ + "async function renderEmoji(", + "async function uploadEmoji(", + "async function deleteEmoji(", + "function confirmDeleteEmoji(", + "async function emojiApi(", + } { + if !strings.Contains(source, fn) { + t.Errorf("missing %q", fn) + } + } +} + +func TestAdminPanelEmojiUsesTheMemberAPI(t *testing.T) { + source := adminPanelSource(t) + // The panel deliberately calls the ordinary /api/v1/emoji routes (which + // enforce MANAGE_SERVER themselves) rather than a duplicate set of + // /admin/api handlers. If that ever moves, the helper below moves with it. + if !strings.Contains(source, "fetch('/api/v1/emoji'+path,init)") { + t.Error("emojiApi no longer targets /api/v1/emoji") + } + if !strings.Contains(source, "data-emoji-url") { + t.Error("thumbnails are no longer loaded through the authenticated blob path") + } +} diff --git a/Server/admin/handlers_backup_test.go b/Server/admin/handlers_backup_test.go index 0c775577..3d0d594c 100644 --- a/Server/admin/handlers_backup_test.go +++ b/Server/admin/handlers_backup_test.go @@ -43,7 +43,7 @@ func chdirTemp(t *testing.T) string { func TestHandleBackup_Success(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) @@ -78,7 +78,7 @@ func TestHandleBackup_Success(t *testing.T) { func TestHandleBackup_RequiresOwner(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) adminUID, _ := database.CreateUser(context.Background(), "backupadmin", "hash", 2) token := "backup-admin-token" @@ -98,7 +98,7 @@ func TestHandleBackup_RequiresOwner(t *testing.T) { func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/backups", token, nil) @@ -121,7 +121,7 @@ func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) { func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Create a backup first. @@ -163,7 +163,7 @@ func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) { func TestHandleDeleteBackup_Success(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Create a real backup file to delete. @@ -194,7 +194,7 @@ func TestHandleDeleteBackup_Success(t *testing.T) { func TestHandleDeleteBackup_NotFound(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/backups/nonexistent.db", token, nil) @@ -209,7 +209,7 @@ func TestHandleDeleteBackup_NotFound(t *testing.T) { func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // The chi router URL-decodes the path parameter, so ".." arrives decoded. @@ -227,7 +227,7 @@ func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) { func TestHandleDeleteBackup_RequiresOwner(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) adminUID, _ := database.CreateUser(context.Background(), "deladmin", "hash", 2) token := "del-admin-token" @@ -252,7 +252,7 @@ func TestHandleDeleteBackup_RequiresOwner(t *testing.T) { func TestHandleRestoreBackup_Success(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Set up backup and data directories. @@ -325,7 +325,7 @@ func TestHandleRestoreBackup_Success(t *testing.T) { func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) backupDir := filepath.Join(tmpDir, "data", "backups") @@ -346,9 +346,10 @@ func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(t *testing.T) { // Make the safety copy impossible: VACUUM INTO refuses a destination that // already exists. The name is pre_restore_.db, so occupy the - // next few seconds' worth of candidates. + // next two minutes' worth of candidates — a 4-second window flaked on slow + // Windows CI runners where the request itself outlived it. admin.SetBackupBaseDir(backupDir) - for i := range 4 { + for i := range 120 { name := "pre_restore_" + time.Now().UTC().Add(time.Duration(i)*time.Second).Format("20060102_150405") + ".db" if err := os.WriteFile(filepath.Join(backupDir, name), []byte("occupied"), 0o644); err != nil { t.Fatalf("WriteFile blocker: %v", err) @@ -377,7 +378,7 @@ func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(t *testing.T) { func TestHandleRestoreBackup_NotFound(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backups/missing.db/restore", token, nil) @@ -392,7 +393,7 @@ func TestHandleRestoreBackup_NotFound(t *testing.T) { func TestHandleRestoreBackup_InvalidName(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backups/..evil.db/restore", token, nil) @@ -408,7 +409,7 @@ func TestHandleRestoreBackup_InvalidName(t *testing.T) { func TestHandleListBackups_ErrorReadingDir(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) // Create data/ directory but make "backups" a file instead of a directory. @@ -436,7 +437,7 @@ func TestHandleListBackups_ErrorReadingDir(t *testing.T) { func TestHandleRestoreBackup_RequiresOwner(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) adminUID, _ := database.CreateUser(context.Background(), "restoreadmin", "hash", 2) token := "restore-admin-token" diff --git a/Server/admin/handlers_channel_perms.go b/Server/admin/handlers_channel_perms.go index 83daf7ba..f5b48f66 100644 --- a/Server/admin/handlers_channel_perms.go +++ b/Server/admin/handlers_channel_perms.go @@ -45,9 +45,14 @@ func getPermChannel(database *db.DB, w http.ResponseWriter, r *http.Request) *db } // channelPermissionsResponse is the JSON shape for GET .../permissions. +// Roles lists EVERY role (zero allow/deny when it carries no override), while +// Users lists only the members who actually have a per-user override row — +// every member of a server is not a sensible list to ship, and the matrix +// editor adds a member by writing one. type channelPermissionsResponse struct { ChannelID int64 `json:"channel_id"` Roles []db.ChannelRoleOverride `json:"roles"` + Users []db.ChannelUserOverride `json:"users"` } func handleGetChannelPermissions(database *db.DB) http.HandlerFunc { @@ -61,7 +66,16 @@ func handleGetChannelPermissions(database *db.DB) http.HandlerFunc { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channel permissions") return } - writeJSON(w, http.StatusOK, channelPermissionsResponse{ChannelID: ch.ID, Roles: overrides}) + userOverrides, err := database.ListChannelUserOverrides(r.Context(), ch.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channel user permissions") + return + } + writeJSON(w, http.StatusOK, channelPermissionsResponse{ + ChannelID: ch.ID, + Roles: overrides, + Users: userOverrides, + }) } } @@ -71,6 +85,22 @@ type putChannelPermissionRequest struct { Deny int64 `json:"deny"` } +// requireGrantableOverride refuses to write a channel override whose allow or +// deny mask contains a bit the actor's own role does not hold. Without this, +// any MANAGE_CHANNELS holder could grant themselves or another user a +// permission (e.g. MANAGE_SERVER) they were never assigned by writing a +// channel-scoped override. ADMINISTRATOR bypasses, mirroring +// service.requireGrantable's escalation rule for role permission masks. +func requireGrantableOverride(actorRole *db.Role, allow, deny int64) error { + if permissions.HasAdmin(actorRole.Permissions) { + return nil + } + if escalated := (allow | deny) &^ actorRole.Permissions; escalated != 0 { + return fmt.Errorf("cannot grant a permission your own role lacks (%s)", permissions.Name(escalated&-escalated)) + } + return nil +} + func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ch := getPermChannel(database, w, r) @@ -101,6 +131,24 @@ func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalid allow := req.Allow & permissions.AllPerms deny := req.Deny & permissions.AllPerms + actorRole := actorRoleFromContext(r) + if actorRole == nil { + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated") + return + } + // Escalation guard: a MANAGE_CHANNELS holder without ADMINISTRATOR + // cannot grant bits their own role lacks via a channel override. + if err := requireGrantableOverride(actorRole, allow, deny); err != nil { + writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error()) + return + } + // Hierarchy guard: a role override can only target a role strictly + // below the actor's own position, mirroring service.requireBelowActor. + if role.Position >= actorRole.Position { + writeErr(w, http.StatusForbidden, "FORBIDDEN", "cannot manage a role at or above your own rank") + return + } + if err := database.UpsertChannelOverride(r.Context(), ch.ID, roleID, allow, deny); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save channel permission") return @@ -160,3 +208,165 @@ func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInva w.WriteHeader(http.StatusNoContent) } } + +// ─── Per-user overrides ────────────────────────────────────────────────────── +// +// channel_user_overrides is the last layer of the resolution order (base role +// perms -> role override -> user override), so these two endpoints can grant a +// single member access to a channel their role is denied, or take it away +// without minting a role for them. +// +// Unlike the role endpoints they invalidate only the target user's cached +// permissions (InvalidateUser): a per-user override cannot change anyone else's +// verdict, and blowing the whole cache away for one member would cost every +// connected client a repopulate. + +// getPermUser resolves the {userId} path parameter for a per-user override +// request, writing the error response itself. Returns nil when a response has +// already been written. +func getPermUser(database *db.DB, w http.ResponseWriter, r *http.Request) *db.User { + userID, err := pathInt64(r, "userId") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id") + return nil + } + user, err := database.GetUserByID(r.Context(), userID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch user") + return nil + } + if user == nil { + writeErr(w, http.StatusNotFound, "NOT_FOUND", "user not found") + return nil + } + return user +} + +// requireManageableUser refuses a per-user channel override that targets a +// member whose role sits at or above the actor's own rank, mirroring the +// hierarchy guard the role-layer handler applies (handlePutChannelPermission). +// Without it a MANAGE_CHANNELS holder could deny a higher-ranked member access +// to a channel via the per-user layer, which is last in the resolution order +// and therefore beats that member's role allow. ADMINISTRATOR bypasses. Writes +// the error response and returns false when the action must be refused. +func requireManageableUser(database *db.DB, w http.ResponseWriter, r *http.Request, target *db.User, actorRole *db.Role) bool { + if permissions.HasAdmin(actorRole.Permissions) { + return true + } + targetRole, err := database.GetRoleByID(r.Context(), target.RoleID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch target role") + return false + } + if targetRole != nil && targetRole.Position >= actorRole.Position { + writeErr(w, http.StatusForbidden, "FORBIDDEN", "cannot manage a user ranked at or above your own") + return false + } + return true +} + +func handlePutChannelUserPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ch := getPermChannel(database, w, r) + if ch == nil { + return + } + user := getPermUser(database, w, r) + if user == nil { + return + } + + var req putChannelPermissionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") + return + } + // Drop unknown bits so garbage input cannot persist undefined perms. + allow := req.Allow & permissions.AllPerms + deny := req.Deny & permissions.AllPerms + + actorRole := actorRoleFromContext(r) + if actorRole == nil { + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated") + return + } + // Escalation guard: a MANAGE_CHANNELS holder without ADMINISTRATOR + // cannot grant bits their own role lacks via a per-user override. + if err := requireGrantableOverride(actorRole, allow, deny); err != nil { + writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error()) + return + } + // Hierarchy guard: cannot override a member ranked at or above you. + if !requireManageableUser(database, w, r, user, actorRole) { + return + } + + if err := database.UpsertChannelUserOverride(r.Context(), ch.ID, user.ID, allow, deny); err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save channel user permission") + return + } + + actor := actorFromContext(r) + slog.Info("channel user permissions updated", "actor_id", actor, "channel_id", ch.ID, + "user_id", user.ID, "allow", allow, "deny", deny) + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_user_perms_update", "channel", ch.ID, + fmt.Sprintf("set overrides for user %s on #%s (allow=%#x deny=%#x)", user.Username, ch.Name, allow, deny)) + + // Invalidate BEFORE the hub call: RefreshChannelVisibility resolves the + // target's visibility through the same cache (see handlePutChannelPermission). + if permInvalidator != nil { + permInvalidator.InvalidateUser(user.ID) + } + if hub != nil { + hub.RefreshChannelVisibility(ch) + } + writeJSON(w, http.StatusOK, db.ChannelUserOverride{ + UserID: user.ID, + Username: user.Username, + RoleID: user.RoleID, + Allow: allow, + Deny: deny, + }) + } +} + +func handleDeleteChannelUserPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ch := getPermChannel(database, w, r) + if ch == nil { + return + } + user := getPermUser(database, w, r) + if user == nil { + return + } + actorRole := actorRoleFromContext(r) + if actorRole == nil { + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated") + return + } + // Hierarchy guard: clearing a higher-ranked member's override is the + // same authority as writing one, so gate it identically. + if !requireManageableUser(database, w, r, user, actorRole) { + return + } + + if err := database.DeleteChannelUserOverride(r.Context(), ch.ID, user.ID); err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel user permission") + return + } + + actor := actorFromContext(r) + slog.Info("channel user permissions cleared", "actor_id", actor, "channel_id", ch.ID, "user_id", user.ID) + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_user_perms_clear", "channel", ch.ID, + fmt.Sprintf("cleared overrides for user %s on #%s", user.Username, ch.Name)) + + if permInvalidator != nil { + permInvalidator.InvalidateUser(user.ID) + } + if hub != nil { + hub.RefreshChannelVisibility(ch) + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/Server/admin/handlers_channel_perms_test.go b/Server/admin/handlers_channel_perms_test.go index 449ed264..8a36d41d 100644 --- a/Server/admin/handlers_channel_perms_test.go +++ b/Server/admin/handlers_channel_perms_test.go @@ -29,7 +29,7 @@ func (m *mockPermInvalidator) InvalidateAll() { func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0) @@ -67,7 +67,7 @@ func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) { func TestGetChannelPermissions_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/channels/9999/permissions", token, nil) @@ -78,7 +78,7 @@ func TestGetChannelPermissions_NotFound(t *testing.T) { func TestGetChannelPermissions_DMRejected(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, err := database.CreateChannel(context.Background(), "dm-chan", "dm", "", "", 0) @@ -99,7 +99,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} inv := &mockPermInvalidator{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0) @@ -147,7 +147,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) { func TestPutChannelPermission_MasksUnknownBits(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, err := database.CreateChannel(context.Background(), "secret2", "text", "", "", 0) @@ -177,7 +177,7 @@ func TestPutChannelPermission_MasksUnknownBits(t *testing.T) { func TestPutChannelPermission_UnknownRole(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, err := database.CreateChannel(context.Background(), "secret3", "text", "", "", 0) @@ -194,7 +194,7 @@ func TestPutChannelPermission_UnknownRole(t *testing.T) { func TestPutChannelPermission_NonAdminForbidden(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) _ = createAdminUser(t, database) memberToken := createMemberUser(t, database) @@ -210,13 +210,104 @@ func TestPutChannelPermission_NonAdminForbidden(t *testing.T) { } } +// A MANAGE_CHANNELS holder without ADMINISTRATOR must not be able to grant a +// permission bit their own role lacks (e.g. MANAGE_SERVER) by writing it into +// a channel override — the escalation this override endpoint must refuse. +func TestPutChannelPermission_ModeratorCannotEscalate(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser") + + chID, err := database.CreateChannel(context.Background(), "escalate", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + // Target a role below the Moderator's own position (Member, position 40) + // so only the escalation guard, not the hierarchy guard, is exercised. + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/3", modToken, + map[string]any{"allow": permissions.ManageServer, "deny": 0}) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 3) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("forbidden grant persisted: (%#x, %#x)", allow, deny) + } +} + +// An ADMINISTRATOR-holding actor (e.g. Owner) can still grant any bit through +// a channel override, since ADMINISTRATOR bypasses the escalation guard. +func TestPutChannelPermission_AdministratorCanGrantAnyBit(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel(context.Background(), "admin-grant", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/3", token, + map[string]any{"allow": permissions.ManageServer, "deny": 0}) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + allow, _, err := database.GetChannelPermissions(context.Background(), chID, 3) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != permissions.ManageServer { + t.Errorf("allow = %#x, want %#x", allow, permissions.ManageServer) + } +} + +// The role-layer endpoint must refuse to write an override for a role at or +// above the actor's own position, even when the requested bits are within +// the actor's own mask — mirroring service.requireBelowActor. +func TestPutChannelPermission_RefusesEqualOrHigherRole(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser") + + chID, err := database.CreateChannel(context.Background(), "hierarchy", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + cases := []struct { + name string + roleID string + }{ + {"higher role (Admin, position 80)", "2"}, + {"own role (Moderator, position 60)", "10"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/"+tc.roleID, modToken, + map[string]any{"allow": permissions.ReadMessages, "deny": 0}) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + }) + } +} + // ─── DELETE /channels/{id}/permissions/{roleId} ────────────────────────────── func TestDeleteChannelPermission_ClearsOverride(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} inv := &mockPermInvalidator{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) chID, err := database.CreateChannel(context.Background(), "secret5", "text", "", "", 0) diff --git a/Server/admin/handlers_channel_user_perms_test.go b/Server/admin/handlers_channel_user_perms_test.go new file mode 100644 index 00000000..94c4e2cc --- /dev/null +++ b/Server/admin/handlers_channel_user_perms_test.go @@ -0,0 +1,384 @@ +package admin_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/owncord/server/admin" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// ─── PUT/DELETE /channels/{id}/user-permissions/{userId} ───────────────────── +// +// The per-user layer is gated on the same MANAGE_CHANNELS bit as the role +// layer, but invalidates only the TARGET's cached permissions — a per-user +// override cannot change anyone else's verdict. + +func seedOverrideTarget(t *testing.T, database *db.DB, username string) int64 { + t.Helper() + uid, err := database.CreateUser(context.Background(), username, "$2a$12$placeholder", 3) + if err != nil { + t.Fatalf("CreateUser %s: %v", username, err) + } + return uid +} + +func TestPutChannelUserPermission_PersistsInvalidatesAndAudits(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + inv := &mockPermInvalidator{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + target := seedOverrideTarget(t, database, "override-target") + + chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + body := map[string]any{"allow": permissions.ReadMessages, "deny": permissions.SendMessages} + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token, body) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + var resp db.ChannelUserOverride + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.UserID != target || resp.Username != "override-target" { + t.Errorf("response = %+v, want user %d/override-target", resp, target) + } + if resp.Allow != permissions.ReadMessages || resp.Deny != permissions.SendMessages { + t.Errorf("response masks = (%#x, %#x)", resp.Allow, resp.Deny) + } + + allow, deny, err := database.GetUserChannelPermissions(context.Background(), chID, target) + if err != nil { + t.Fatalf("GetUserChannelPermissions: %v", err) + } + if allow != permissions.ReadMessages || deny != permissions.SendMessages { + t.Errorf("persisted = (%#x, %#x)", allow, deny) + } + + // Only the target's cache is dropped, and never the whole cache. + if len(inv.invalidateUserIDs) != 1 || inv.invalidateUserIDs[0] != target { + t.Errorf("InvalidateUser calls = %v, want [%d]", inv.invalidateUserIDs, target) + } + if inv.invalidateAllN != 0 { + t.Errorf("InvalidateAll calls = %d, want 0", inv.invalidateAllN) + } + if len(hub.visibilityRefreshes) != 1 || hub.visibilityRefreshes[0].ID != chID { + t.Errorf("RefreshChannelVisibility not called for channel %d", chID) + } + + entries, err := database.GetAuditLog(context.Background(), 10, 0) + if err != nil { + t.Fatalf("GetAuditLog: %v", err) + } + found := false + for _, e := range entries { + if e.Action == "channel_user_perms_update" { + found = true + } + } + if !found { + t.Error("expected channel_user_perms_update audit entry") + } +} + +// A round trip through the endpoint must preserve the exact masks the matrix +// editor writes — one bit per row, in both directions at once. +func TestPutChannelUserPermission_MaskRoundTrip(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + target := seedOverrideTarget(t, database, "matrix-target") + + chID, err := database.CreateChannel(context.Background(), "matrix", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + allowMask := permissions.ReadMessages | permissions.AttachFiles | permissions.ConnectVoice | permissions.ShareScreen + denyMask := permissions.SendMessages | permissions.AddReactions | permissions.MentionEveryone | permissions.SpeakVoice + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token, + map[string]any{"allow": allowMask, "deny": denyMask}) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + get := doRequest(t, handler, http.MethodGet, "/channels/"+itoa(chID)+"/permissions", token, nil) + if get.Code != http.StatusOK { + t.Fatalf("GET status = %d; body: %s", get.Code, get.Body.String()) + } + var listing struct { + Users []db.ChannelUserOverride `json:"users"` + } + if err := json.Unmarshal(get.Body.Bytes(), &listing); err != nil { + t.Fatalf("unmarshal listing: %v", err) + } + if len(listing.Users) != 1 { + t.Fatalf("users = %d, want 1", len(listing.Users)) + } + if listing.Users[0].Allow != allowMask || listing.Users[0].Deny != denyMask { + t.Errorf("round trip = (%#x, %#x), want (%#x, %#x)", + listing.Users[0].Allow, listing.Users[0].Deny, allowMask, denyMask) + } +} + +func TestPutChannelUserPermission_MasksUnknownBits(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + target := seedOverrideTarget(t, database, "junk-target") + + chID, err := database.CreateChannel(context.Background(), "junk", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + // 0x8 is not a defined bit — it must be dropped, not persisted. + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token, + map[string]any{"allow": permissions.ReadMessages | 0x8, "deny": 0}) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + allow, _, err := database.GetUserChannelPermissions(context.Background(), chID, target) + if err != nil { + t.Fatalf("GetUserChannelPermissions: %v", err) + } + if allow != permissions.ReadMessages { + t.Errorf("allow = %#x, want %#x (unknown bits dropped)", allow, permissions.ReadMessages) + } +} + +func TestPutChannelUserPermission_UnknownUser(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel(context.Background(), "nope", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/user-permissions/9999", token, map[string]any{"allow": 0, "deny": 2}) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body: %s", w.Code, w.Body.String()) + } +} + +func TestPutChannelUserPermission_DMRejected(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + target := seedOverrideTarget(t, database, "dm-target") + + chID, err := database.CreateChannel(context.Background(), "dm-1-2", "dm", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token, map[string]any{"allow": 0, "deny": 2}) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String()) + } +} + +func TestChannelUserPermission_NonAdminForbidden(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _ = createAdminUser(t, database) + memberToken := createMemberUser(t, database) + target := seedOverrideTarget(t, database, "forbidden-target") + + chID, err := database.CreateChannel(context.Background(), "gated", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + for _, method := range []string{http.MethodPut, http.MethodDelete} { + w := doRequest(t, handler, method, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), memberToken, + map[string]any{"allow": 0, "deny": 2}) + if w.Code != http.StatusForbidden && w.Code != http.StatusUnauthorized { + t.Errorf("%s status = %d, want 403/401; body: %s", method, w.Code, w.Body.String()) + } + } + + // The refusal must not have written anything. + allow, deny, err := database.GetUserChannelPermissions(context.Background(), chID, target) + if err != nil { + t.Fatalf("GetUserChannelPermissions: %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("forbidden request persisted (%#x, %#x)", allow, deny) + } +} + +// A MANAGE_CHANNELS holder without ADMINISTRATOR must not be able to grant a +// permission bit their own role lacks (e.g. MANAGE_SERVER) to a member by +// writing it into a per-user channel override. +func TestPutChannelUserPermission_ModeratorCannotEscalate(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser") + target := seedOverrideTarget(t, database, "escalate-target") + + chID, err := database.CreateChannel(context.Background(), "escalate", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), modToken, + map[string]any{"allow": permissions.ManageServer, "deny": 0}) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetUserChannelPermissions(context.Background(), chID, target) + if err != nil { + t.Fatalf("GetUserChannelPermissions: %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("forbidden grant persisted: (%#x, %#x)", allow, deny) + } +} + +// A non-admin MANAGE_CHANNELS holder cannot write (or clear) a per-user +// override against a member whose role outranks their own, even for a bit they +// legitimately hold — the per-user layer is last in the resolution order, so +// without this guard a Moderator could deny a higher-ranked member channel +// access their role grants. +func TestPutChannelUserPermission_CannotTargetHigherRankedUser(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + // Actor: Moderator at position 60 holding MANAGE_CHANNELS + READ_MESSAGES. + _, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "mod-hier") + // Target holds a role ranked ABOVE the actor. + seniorID, _ := createRoleUser(t, database, 11, "Senior", permissions.ReadMessages, 80, "senior-hier") + + chID, err := database.CreateChannel(context.Background(), "hier", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + // deny READ_MESSAGES — a bit the Moderator holds, so the escalation guard + // passes and only the hierarchy guard can stop this. + put := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(seniorID), modToken, + map[string]any{"allow": 0, "deny": permissions.ReadMessages}) + if put.Code != http.StatusForbidden { + t.Fatalf("PUT status = %d, want 403; body: %s", put.Code, put.Body.String()) + } + del := doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(seniorID), modToken, nil) + if del.Code != http.StatusForbidden { + t.Fatalf("DELETE status = %d, want 403; body: %s", del.Code, del.Body.String()) + } + allow, deny, err := database.GetUserChannelPermissions(context.Background(), chID, seniorID) + if err != nil { + t.Fatalf("GetUserChannelPermissions: %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("override persisted despite hierarchy guard: (%#x, %#x)", allow, deny) + } +} + +// An ADMINISTRATOR-holding actor can still grant any bit through a per-user +// override, since ADMINISTRATOR bypasses the escalation guard. +func TestPutChannelUserPermission_AdministratorCanGrantAnyBit(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + target := seedOverrideTarget(t, database, "admin-grant-target") + + chID, err := database.CreateChannel(context.Background(), "admin-grant", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token, + map[string]any{"allow": permissions.ManageServer, "deny": 0}) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + allow, _, err := database.GetUserChannelPermissions(context.Background(), chID, target) + if err != nil { + t.Fatalf("GetUserChannelPermissions: %v", err) + } + if allow != permissions.ManageServer { + t.Errorf("allow = %#x, want %#x", allow, permissions.ManageServer) + } +} + +func TestDeleteChannelUserPermission_ClearsOverride(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + inv := &mockPermInvalidator{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + target := seedOverrideTarget(t, database, "clear-target") + + chID, err := database.CreateChannel(context.Background(), "clearme", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.UpsertChannelUserOverride(context.Background(), chID, target, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelUserOverride: %v", err) + } + + w := doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetUserChannelPermissions(context.Background(), chID, target) + if err != nil { + t.Fatalf("GetUserChannelPermissions: %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("override still present: (%#x, %#x)", allow, deny) + } + if len(inv.invalidateUserIDs) != 1 || inv.invalidateUserIDs[0] != target { + t.Errorf("InvalidateUser calls = %v, want [%d]", inv.invalidateUserIDs, target) + } + if len(hub.visibilityRefreshes) != 1 { + t.Errorf("RefreshChannelVisibility calls = %d, want 1", len(hub.visibilityRefreshes)) + } + + entries, err := database.GetAuditLog(context.Background(), 10, 0) + if err != nil { + t.Fatalf("GetAuditLog: %v", err) + } + found := false + for _, e := range entries { + if e.Action == "channel_user_perms_clear" { + found = true + } + } + if !found { + t.Error("expected channel_user_perms_clear audit entry") + } + + // Deleting again is idempotent. + w = doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token, nil) + if w.Code != http.StatusNoContent { + t.Errorf("second delete status = %d, want 204", w.Code) + } +} diff --git a/Server/admin/handlers_channels.go b/Server/admin/handlers_channels.go index cf41b99b..428fdba0 100644 --- a/Server/admin/handlers_channels.go +++ b/Server/admin/handlers_channels.go @@ -12,51 +12,25 @@ import ( "github.com/owncord/server/db" ) -// ─── Category-Type Validation ──────────────────────────────────────────────── +// ─── Channel Type Validation ───────────────────────────────────────────────── -// voiceCategoryNames is the set of canonical category names treated as voice -// sections. Matching is case-insensitive but requires an exact name match -// (not a substring) to prevent false positives like "Invoice Channels". -var voiceCategoryNames = []string{ - "Voice Channels", -} +// validChannelTypes is the set of channel types a create request may name. +// A channel's CATEGORY deliberately constrains nothing: categories are free +// text, and pinning "only voice channels live under a category whose name +// matches 'Voice Channels'" made every other category name a second-class one — +// a voice channel could not be created under "Gaming", and renaming the +// category silently changed what could be created there. Grouping is a display +// concern (the client groups by whatever category a channel carries), so the +// server validates the type alone. +var validChannelTypes = []string{"text", "voice", "announcement"} -// isVoiceCategory returns true if the category name is an exact -// (case-insensitive) match for a known voice category name. -func isVoiceCategory(category string) bool { - for _, name := range voiceCategoryNames { - if strings.EqualFold(category, name) { - return true - } - } - return false -} - -// allowedChannelTypes returns the set of channel types valid for a category. -func allowedChannelTypes(category string) []string { - if category == "" { - return []string{"text", "voice", "announcement"} - } - if isVoiceCategory(category) { - return []string{"voice"} - } - return []string{"text", "announcement"} -} - -// validateCategoryType checks that the channel type is allowed under the given -// category. Returns an error message if invalid, or empty string if OK. -func validateCategoryType(channelType, category string) string { - if category == "" { +// validateChannelType returns an error message when the type is not one of the +// three real channel types, or an empty string when it is. +func validateChannelType(channelType string) string { + if slices.Contains(validChannelTypes, channelType) { return "" } - allowed := allowedChannelTypes(category) - if slices.Contains(allowed, channelType) { - return "" - } - if isVoiceCategory(category) { - return "only voice channels can be created under a voice category" - } - return "voice channels can only be created under a voice category" + return "type must be one of text, voice, announcement" } // ─── Channel Handlers ──────────────────────────────────────────────────────── @@ -97,7 +71,7 @@ func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { req.Type = "text" } - if msg := validateCategoryType(req.Type, req.Category); msg != "" { + if msg := validateChannelType(req.Type); msg != "" { writeErr(w, http.StatusBadRequest, "INVALID_INPUT", msg) return } @@ -124,13 +98,62 @@ func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { } } +// Bounds for the numeric channel settings a PATCH may set. +// +// They are validated here rather than left to the database because SQLite +// would happily store a slow mode of six years or a user limit of -3, and the +// only place that would surface is a client rendering nonsense. The values +// match what the clients offer: Discord's 6-hour slow-mode ceiling, and a +// two-digit voice capacity (0 = unlimited in both voice cases). +const ( + maxSlowModeSeconds = 21600 + maxVoiceLimit = 99 +) + // updateChannelRequest is the JSON body for PATCH /admin/api/channels/{id}. type updateChannelRequest struct { Name string `json:"name"` Topic string `json:"topic"` + Category string `json:"category"` SlowMode int `json:"slow_mode"` Position int `json:"position"` Archived bool `json:"archived"` + // NSFW is stored, broadcast and audited; it changes no server-side content + // behaviour (see migration 025). Clients decide how to present it. + NSFW bool `json:"nsfw"` + // Voice capacity limits, enforced on voice join by the ws layer. + // 0 = unlimited. + VoiceMaxUsers int `json:"voice_max_users"` + VoiceMaxVideo int `json:"voice_max_video"` +} + +// validate reports the first out-of-range numeric field, or "" when the +// request is acceptable. Negative values are rejected rather than clamped: a +// caller sending -1 meant something, and silently storing 0 would hide it. +func (r updateChannelRequest) validate() string { + switch { + case r.SlowMode < 0 || r.SlowMode > maxSlowModeSeconds: + return fmt.Sprintf("slow_mode must be between 0 and %d seconds", maxSlowModeSeconds) + case r.VoiceMaxUsers < 0 || r.VoiceMaxUsers > maxVoiceLimit: + return fmt.Sprintf("voice_max_users must be between 0 and %d", maxVoiceLimit) + case r.VoiceMaxVideo < 0 || r.VoiceMaxVideo > maxVoiceLimit: + return fmt.Sprintf("voice_max_video must be between 0 and %d", maxVoiceLimit) + } + return "" +} + +// nsfwAuditSuffix names an NSFW transition in the audit detail, or returns "" +// when the flag did not move. An age-gate flag flipping is the one part of a +// channel edit an operator may need to answer for later, and "updated #foo" +// alone would not record it. +func nsfwAuditSuffix(before, after bool) string { + if before == after { + return "" + } + if after { + return " (marked NSFW)" + } + return " (unmarked NSFW)" } func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { @@ -153,26 +176,45 @@ func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { // Start from existing values so a partial body is safe. req := updateChannelRequest{ - Name: existing.Name, - Topic: existing.Topic, - SlowMode: existing.SlowMode, - Position: existing.Position, - Archived: existing.Archived, + Name: existing.Name, + Topic: existing.Topic, + Category: existing.Category, + SlowMode: existing.SlowMode, + Position: existing.Position, + Archived: existing.Archived, + NSFW: existing.NSFW, + VoiceMaxUsers: existing.VoiceMaxUsers, + VoiceMaxVideo: existing.VoiceMaxVideo, } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") return } - if err := database.AdminUpdateChannel(r.Context(), id, req.Name, req.Topic, req.SlowMode, req.Position, req.Archived); err != nil { + if msg := req.validate(); msg != "" { + writeErr(w, http.StatusBadRequest, "INVALID_INPUT", msg) + return + } + + if err := database.AdminUpdateChannel(r.Context(), id, db.ChannelUpdate{ + Name: req.Name, + Topic: req.Topic, + Category: strings.TrimSpace(req.Category), + SlowMode: req.SlowMode, + Position: req.Position, + Archived: req.Archived, + NSFW: req.NSFW, + VoiceMaxUsers: req.VoiceMaxUsers, + VoiceMaxVideo: req.VoiceMaxVideo, + }); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update channel") return } actor := actorFromContext(r) - slog.Info("channel updated", "actor_id", actor, "channel_id", id, "name", req.Name) + slog.Info("channel updated", "actor_id", actor, "channel_id", id, "name", req.Name, "nsfw", req.NSFW) db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_update", "channel", id, - fmt.Sprintf("updated #%s", req.Name)) + fmt.Sprintf("updated #%s%s", req.Name, nsfwAuditSuffix(existing.NSFW, req.NSFW))) updated, err := database.GetChannel(r.Context(), id) if err != nil || updated == nil { @@ -181,6 +223,12 @@ func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { } if hub != nil { hub.BroadcastChannelUpdate(updated) + // Archiving/unarchiving changes who sees the channel, not just its + // metadata — send targeted channel_create/channel_delete so + // connected clients re-sync without a reconnect. + if existing.Archived != updated.Archived { + hub.RefreshChannelVisibility(updated) + } } writeJSON(w, http.StatusOK, updated) } diff --git a/Server/admin/handlers_channels_test.go b/Server/admin/handlers_channels_test.go index e8d2c05f..dd461851 100644 --- a/Server/admin/handlers_channels_test.go +++ b/Server/admin/handlers_channels_test.go @@ -2,77 +2,72 @@ package admin_test import ( "encoding/json" + "fmt" "net/http" + "strings" "testing" "github.com/owncord/server/admin" + "github.com/owncord/server/db" ) -// ─── Category-Type Validation (via POST /channels) ────────────────────────── +// ─── Channel-type validation (via POST /channels) ─────────────────────────── +// +// Categories used to constrain the type: a voice channel could only be created +// under a category literally named "Voice Channels". That rule is gone — +// categories are free text and grouping is a display concern — so the tests +// below assert the inverse: EVERY type is creatable under ANY category, and the +// only thing a create request can get wrong is the type itself. -func TestCreateChannel_TextUnderTextCategory_OK(t *testing.T) { +func newChannelTestAPI(t *testing.T) (http.Handler, string, *db.DB) { + t.Helper() database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - token := createAdminUser(t, database) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + return handler, createAdminUser(t, database), database +} - body := map[string]any{ - "name": "general", - "type": "text", - "category": "Chat", +func TestCreateChannel_AnyTypeUnderAnyCategory(t *testing.T) { + cases := []struct { + name string + chType string + category string + }{ + {"text under a text-sounding category", "text", "Chat"}, + {"announcement under a text-sounding category", "announcement", "Text Channels"}, + {"voice under the legacy voice category", "voice", "Voice Channels"}, + {"voice under an arbitrary category", "voice", "Gaming"}, + {"text under the legacy voice category", "text", "Voice Channels"}, + {"text under an uppercase voice-sounding category", "text", "VOICE CHANNELS"}, + {"voice with no category at all", "voice", ""}, } - w := doRequest(t, handler, http.MethodPost, "/channels", token, body) - if w.Code != http.StatusCreated { - t.Errorf("text channel under Chat: status = %d, want 201; body: %s", w.Code, w.Body.String()) + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + handler, token, _ := newChannelTestAPI(t) + body := map[string]any{ + "name": "ch" + string(rune('a'+i)), + "type": tc.chType, + "category": tc.category, + } + w := doRequest(t, handler, http.MethodPost, "/channels", token, body) + if w.Code != http.StatusCreated { + t.Errorf("status = %d, want 201; body: %s", w.Code, w.Body.String()) + } + }) } } -func TestCreateChannel_AnnouncementUnderTextCategory_OK(t *testing.T) { - database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - token := createAdminUser(t, database) +func TestCreateChannel_UnknownTypeRejected(t *testing.T) { + handler, token, _ := newChannelTestAPI(t) body := map[string]any{ - "name": "announcements", - "type": "announcement", - "category": "Text Channels", - } - w := doRequest(t, handler, http.MethodPost, "/channels", token, body) - if w.Code != http.StatusCreated { - t.Errorf("announcement under Text Channels: status = %d, want 201; body: %s", w.Code, w.Body.String()) - } -} - -func TestCreateChannel_VoiceUnderVoiceCategory_OK(t *testing.T) { - database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - token := createAdminUser(t, database) - - body := map[string]any{ - "name": "lounge", - "type": "voice", - "category": "Voice Channels", - } - w := doRequest(t, handler, http.MethodPost, "/channels", token, body) - if w.Code != http.StatusCreated { - t.Errorf("voice under Voice Channels: status = %d, want 201; body: %s", w.Code, w.Body.String()) - } -} - -func TestCreateChannel_VoiceUnderTextCategory_Rejected(t *testing.T) { - database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - token := createAdminUser(t, database) - - body := map[string]any{ - "name": "bad-voice", - "type": "voice", + "name": "weird", + "type": "forum", "category": "Chat", } w := doRequest(t, handler, http.MethodPost, "/channels", token, body) if w.Code != http.StatusBadRequest { - t.Errorf("voice under Chat: status = %d, want 400; body: %s", w.Code, w.Body.String()) + t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String()) } - var resp map[string]string if err := json.Unmarshal(w.Body.Bytes(), &resp); err == nil { if resp["error"] != "INVALID_INPUT" { @@ -81,62 +76,305 @@ func TestCreateChannel_VoiceUnderTextCategory_Rejected(t *testing.T) { } } -func TestCreateChannel_TextUnderVoiceCategory_Rejected(t *testing.T) { - database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - token := createAdminUser(t, database) +// PATCH now accepts category, so a channel can be moved between categories +// without being recreated. An omitted category must keep the existing one — +// the handler seeds the request struct from the current row. +func TestPatchChannel_MovesCategory(t *testing.T) { + handler, token, _ := newChannelTestAPI(t) - body := map[string]any{ - "name": "bad-text", - "type": "text", - "category": "Voice Channels", + create := doRequest(t, handler, http.MethodPost, "/channels", token, map[string]any{ + "name": "lounge", "type": "voice", "category": "Gaming", + }) + if create.Code != http.StatusCreated { + t.Fatalf("create: status = %d; body: %s", create.Code, create.Body.String()) } - w := doRequest(t, handler, http.MethodPost, "/channels", token, body) + var created struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal created: %v", err) + } + + path := fmt.Sprintf("/channels/%d", created.ID) + moved := doRequest(t, handler, http.MethodPatch, path, token, map[string]any{ + "name": "lounge", "category": "Hangout", + }) + if moved.Code != http.StatusOK { + t.Fatalf("patch: status = %d; body: %s", moved.Code, moved.Body.String()) + } + var afterMove struct { + Category string `json:"category"` + } + if err := json.Unmarshal(moved.Body.Bytes(), &afterMove); err != nil { + t.Fatalf("unmarshal moved: %v", err) + } + if afterMove.Category != "Hangout" { + t.Errorf("category = %q, want Hangout", afterMove.Category) + } + + // A body without "category" must not blank it out. + kept := doRequest(t, handler, http.MethodPatch, path, token, map[string]any{ + "name": "lounge-2", + }) + if kept.Code != http.StatusOK { + t.Fatalf("patch without category: status = %d; body: %s", kept.Code, kept.Body.String()) + } + var afterKeep struct { + Category string `json:"category"` + } + if err := json.Unmarshal(kept.Body.Bytes(), &afterKeep); err != nil { + t.Fatalf("unmarshal kept: %v", err) + } + if afterKeep.Category != "Hangout" { + t.Errorf("category after omitted patch = %q, want Hangout", afterKeep.Category) + } +} + +// ─── Channel feature flags: nsfw + voice capacity limits ───────────────────── +// +// The three fields ride on the same PATCH as name/topic/category, so the tests +// below cover the three things that can go wrong with a field bolted onto a +// partial-body handler: it must round-trip, an omitted field must not clobber +// the stored value, and an out-of-range value must be refused rather than +// stored. + +// newChannel creates a channel through the API and returns its id. +func newChannel(t *testing.T, handler http.Handler, token, name, chType string) int64 { + t.Helper() + w := doRequest(t, handler, http.MethodPost, "/channels", token, map[string]any{ + "name": name, "type": chType, + }) + if w.Code != http.StatusCreated { + t.Fatalf("create %s: status = %d; body: %s", name, w.Code, w.Body.String()) + } + var created struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal created: %v", err) + } + return created.ID +} + +// channelFlags is the slice of the channel JSON these tests assert on. +type channelFlags struct { + NSFW bool `json:"nsfw"` + SlowMode int `json:"slow_mode"` + VoiceMaxUsers int `json:"voice_max_users"` + VoiceMaxVideo int `json:"voice_max_video"` +} + +func patchChannelFlags(t *testing.T, handler http.Handler, token string, id int64, body map[string]any) channelFlags { + t.Helper() + w := doRequest(t, handler, http.MethodPatch, fmt.Sprintf("/channels/%d", id), token, body) + if w.Code != http.StatusOK { + t.Fatalf("patch: status = %d; body: %s", w.Code, w.Body.String()) + } + var got channelFlags + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal patched: %v", err) + } + return got +} + +func TestPatchChannel_SetsFeatureFlags(t *testing.T) { + handler, token, _ := newChannelTestAPI(t) + id := newChannel(t, handler, token, "lounge", "voice") + + got := patchChannelFlags(t, handler, token, id, map[string]any{ + "nsfw": true, + "slow_mode": 30, + "voice_max_users": 5, + "voice_max_video": 2, + }) + want := channelFlags{NSFW: true, SlowMode: 30, VoiceMaxUsers: 5, VoiceMaxVideo: 2} + if got != want { + t.Errorf("flags after patch = %+v, want %+v", got, want) + } +} + +// An omitted field keeps its stored value — the handler seeds the request +// struct from the current row, which is the only thing that makes a partial +// PATCH body (the one every client sends) non-destructive. +func TestPatchChannel_OmittedFlagsPreserved(t *testing.T) { + handler, token, _ := newChannelTestAPI(t) + id := newChannel(t, handler, token, "lounge", "voice") + + patchChannelFlags(t, handler, token, id, map[string]any{ + "nsfw": true, "slow_mode": 15, "voice_max_users": 8, "voice_max_video": 3, + }) + + // A rename touches nothing else. + got := patchChannelFlags(t, handler, token, id, map[string]any{"name": "lounge-2"}) + want := channelFlags{NSFW: true, SlowMode: 15, VoiceMaxUsers: 8, VoiceMaxVideo: 3} + if got != want { + t.Errorf("flags after rename = %+v, want %+v (unchanged)", got, want) + } +} + +func TestPatchChannel_ClearsNSFW(t *testing.T) { + handler, token, _ := newChannelTestAPI(t) + id := newChannel(t, handler, token, "spicy", "text") + + patchChannelFlags(t, handler, token, id, map[string]any{"nsfw": true}) + if got := patchChannelFlags(t, handler, token, id, map[string]any{"nsfw": false}); got.NSFW { + t.Error("nsfw = true after clearing, want false") + } +} + +func TestPatchChannel_RejectsOutOfRangeValues(t *testing.T) { + cases := []struct { + name string + body map[string]any + }{ + {"slow mode above the 6-hour ceiling", map[string]any{"slow_mode": 21601}}, + {"negative slow mode", map[string]any{"slow_mode": -1}}, + {"user limit above 99", map[string]any{"voice_max_users": 100}}, + {"negative user limit", map[string]any{"voice_max_users": -1}}, + {"video limit above 99", map[string]any{"voice_max_video": 100}}, + {"negative video limit", map[string]any{"voice_max_video": -5}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + handler, token, _ := newChannelTestAPI(t) + id := newChannel(t, handler, token, "lounge", "voice") + + w := doRequest(t, handler, http.MethodPatch, fmt.Sprintf("/channels/%d", id), token, tc.body) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; 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 error body: %v", err) + } + if resp["error"] != "INVALID_INPUT" { + t.Errorf("error code = %q, want INVALID_INPUT", resp["error"]) + } + }) + } +} + +// The boundary values themselves are legal — an off-by-one in validate() that +// refused 21600 or 99 would silently cap what the clients offer. +func TestPatchChannel_AcceptsBoundaryValues(t *testing.T) { + handler, token, _ := newChannelTestAPI(t) + id := newChannel(t, handler, token, "lounge", "voice") + + got := patchChannelFlags(t, handler, token, id, map[string]any{ + "slow_mode": 21600, "voice_max_users": 99, "voice_max_video": 99, + }) + want := channelFlags{SlowMode: 21600, VoiceMaxUsers: 99, VoiceMaxVideo: 99} + if got != want { + t.Errorf("flags = %+v, want %+v", got, want) + } +} + +// A refused patch must not have written anything — validation runs before the +// update, so a rejected body cannot half-apply the fields that were in range. +func TestPatchChannel_RejectedPatchWritesNothing(t *testing.T) { + handler, token, _ := newChannelTestAPI(t) + id := newChannel(t, handler, token, "lounge", "voice") + + w := doRequest(t, handler, http.MethodPatch, fmt.Sprintf("/channels/%d", id), token, map[string]any{ + "name": "renamed", "nsfw": true, "voice_max_users": 500, + }) if w.Code != http.StatusBadRequest { - t.Errorf("text under Voice Channels: status = %d, want 400; body: %s", w.Code, w.Body.String()) + t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String()) } + + list := doRequest(t, handler, http.MethodGet, "/channels", token, nil) + var channels []struct { + ID int64 `json:"id"` + Name string `json:"name"` + NSFW bool `json:"nsfw"` + } + if err := json.Unmarshal(list.Body.Bytes(), &channels); err != nil { + t.Fatalf("unmarshal channels: %v", err) + } + for _, ch := range channels { + if ch.ID != id { + continue + } + if ch.Name != "lounge" || ch.NSFW { + t.Errorf("channel after refused patch = {name:%q nsfw:%v}, want {lounge false}", ch.Name, ch.NSFW) + } + return + } + t.Fatalf("channel %d missing from the list", id) } -func TestCreateChannel_EmptyCategory_Allowed(t *testing.T) { +// The broadcast must carry the new fields, not just the stored row: the +// desktop client updates its channel store from channel_update alone and would +// otherwise show a stale gate until the next reconnect. +func TestPatchChannel_BroadcastCarriesFeatureFlags(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + hub := &mockHub{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) - body := map[string]any{ - "name": "uncategorized", - "type": "voice", - "category": "", + id := newChannel(t, handler, token, "lounge", "voice") + patchChannelFlags(t, handler, token, id, map[string]any{ + "nsfw": true, "voice_max_users": 4, "voice_max_video": 1, + }) + + if len(hub.channelUpdates) == 0 { + t.Fatal("no channel_update broadcast") } - w := doRequest(t, handler, http.MethodPost, "/channels", token, body) - if w.Code != http.StatusCreated { - t.Errorf("voice with empty category: status = %d, want 201; body: %s", w.Code, w.Body.String()) + got := hub.channelUpdates[len(hub.channelUpdates)-1] + if !got.NSFW { + t.Error("broadcast NSFW = false, want true") + } + if got.VoiceMaxUsers != 4 || got.VoiceMaxVideo != 1 { + t.Errorf("broadcast voice limits = %d/%d, want 4/1", got.VoiceMaxUsers, got.VoiceMaxVideo) } } -func TestCreateChannel_CaseInsensitiveVoiceCategory(t *testing.T) { - database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - token := createAdminUser(t, database) +// Flipping the flag is the one part of a channel edit an operator may need to +// answer for later, so the audit detail names the transition. +func TestPatchChannel_AuditsNSFWTransition(t *testing.T) { + handler, token, database := newChannelTestAPI(t) + id := newChannel(t, handler, token, "spicy", "text") - // "VOICE" in uppercase should still be treated as a voice category - body := map[string]any{ - "name": "vc", - "type": "voice", - "category": "VOICE CHANNELS", - } - w := doRequest(t, handler, http.MethodPost, "/channels", token, body) - if w.Code != http.StatusCreated { - t.Errorf("voice under VOICE CHANNELS: status = %d, want 201; body: %s", w.Code, w.Body.String()) - } + patchChannelFlags(t, handler, token, id, map[string]any{"nsfw": true}) + patchChannelFlags(t, handler, token, id, map[string]any{"nsfw": false}) + // A patch that leaves the flag alone must not claim a transition. + patchChannelFlags(t, handler, token, id, map[string]any{"name": "spicy-2"}) - // Text under uppercase VOICE should be rejected - body2 := map[string]any{ - "name": "bad", - "type": "text", - "category": "VOICE CHANNELS", + entries, err := database.GetAuditLog(t.Context(), 50, 0) + if err != nil { + t.Fatalf("GetAuditLog: %v", err) } - w2 := doRequest(t, handler, http.MethodPost, "/channels", token, body2) - if w2.Code != http.StatusBadRequest { - t.Errorf("text under VOICE CHANNELS: status = %d, want 400; body: %s", w2.Code, w2.Body.String()) + var marked, unmarked, plain int + for _, e := range entries { + if e.Action != "channel_update" { + continue + } + switch { + case strings.Contains(e.Detail, "(marked NSFW)"): + marked++ + case strings.Contains(e.Detail, "(unmarked NSFW)"): + unmarked++ + default: + plain++ + } + } + if marked != 1 || unmarked != 1 || plain != 1 { + t.Errorf("audit details: marked=%d unmarked=%d plain=%d, want 1/1/1", marked, unmarked, plain) + } +} + +// MANAGE_CHANNELS gates the whole channel surface; a member without it cannot +// set the flags either. The desktop client hides the controls on the same bit, +// but the server is the authority. +func TestPatchChannel_FeatureFlagsRequireManageChannels(t *testing.T) { + handler, adminToken, database := newChannelTestAPI(t) + id := newChannel(t, handler, adminToken, "lounge", "voice") + + memberToken := createMemberUser(t, database) + w := doRequest(t, handler, http.MethodPatch, fmt.Sprintf("/channels/%d", id), memberToken, map[string]any{ + "nsfw": true, + }) + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403; body: %s", w.Code, w.Body.String()) } } diff --git a/Server/admin/handlers_roles.go b/Server/admin/handlers_roles.go new file mode 100644 index 00000000..9a206c0d --- /dev/null +++ b/Server/admin/handlers_roles.go @@ -0,0 +1,245 @@ +package admin + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/owncord/server/db" + "github.com/owncord/server/service" +) + +// ─── Role Handlers ─────────────────────────────────────────────────────────── +// +// The whole group sits behind requirePerm(MANAGE_ROLES); RoleService re-checks +// the bit and owns the hierarchy rules (manage only below your own position, +// never grant a bit you lack, Owner/default undeletable), so these handlers +// stay adapters: decode, call, invalidate, fan out. +// +// Fan-out after a mutation follows the two existing patterns exactly: +// - the permission cache is invalidated BEFORE the hub calls, as the +// channel-override handlers do, so the hub's per-client visibility lookups +// repopulate from post-change data; +// - visibility is re-synced with targeted channel_create/channel_delete +// (RefreshChannelVisibility), not a reconnect — a role's mask is the base +// for every channel, so the refresh covers all of them. + +// roleServiceUnavailable writes the fail-closed response used when the server +// was constructed without a RoleService. Refusing beats falling back to an +// unchecked UPDATE, mirroring the nil-ModerationService branches. +func roleServiceUnavailable(w http.ResponseWriter) { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "role service unavailable") +} + +// writeRoleErr maps RoleService errors onto admin API responses. Separate from +// writeModerationErr only because NOT_FOUND means "role", not "user". +func writeRoleErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, service.ErrForbidden): + writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error()) + case errors.Is(err, service.ErrNotFound): + writeErr(w, http.StatusNotFound, "NOT_FOUND", "role not found") + case errors.Is(err, service.ErrBadRequest): + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) + default: + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "role action failed") + } +} + +// roleRequest is the JSON body for POST /roles and PATCH /roles/{id}. Every +// field is a pointer so PATCH can tell "absent" from "set to zero" — a mask of +// 0 (a role that may do nothing) and an empty color are both legitimate values. +type roleRequest struct { + Name *string `json:"name"` + Color *string `json:"color"` + Permissions *int64 `json:"permissions"` + Position *int `json:"position"` +} + +func (r roleRequest) toInput() service.RoleInput { + return service.RoleInput{ + Name: r.Name, + Color: r.Color, + Permissions: r.Permissions, + Position: r.Position, + } +} + +// reorderRolesRequest is the JSON body for PATCH /roles/reorder: the ids of +// every role below the caller's own rank, highest first. +type reorderRolesRequest struct { + RoleIDs []int64 `json:"role_ids"` +} + +func handleListRoles(roles *service.RoleService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if roles == nil { + roleServiceUnavailable(w) + return + } + list, err := roles.ListRoles(r.Context(), actorFromContext(r)) + if err != nil { + writeRoleErr(w, err) + return + } + writeJSON(w, http.StatusOK, list) + } +} + +func handleCreateRole(database *db.DB, hub HubBroadcaster, roles *service.RoleService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if roles == nil { + roleServiceUnavailable(w) + return + } + var req roleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") + return + } + role, err := roles.CreateRole(r.Context(), actorFromContext(r), req.toInput()) + if err != nil { + writeRoleErr(w, err) + return + } + // A brand-new role has no members, so nothing's cached mask changed and + // no channel changed visibility — only the role list itself moved. + broadcastRoles(r, database, hub) + writeJSON(w, http.StatusCreated, role) + } +} + +func handlePatchRole(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator, roles *service.RoleService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if roles == nil { + roleServiceUnavailable(w) + return + } + id, err := pathInt64(r, "id") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id") + return + } + var req roleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") + return + } + + // Members are read before the update: after it they are the same set, + // but reading first keeps the invalidation correct even if a concurrent + // role assignment lands in between (the extra id is a wasted eviction, + // a missing one is a stale grant). + affected := roles.AffectedUserIDs(r.Context(), id) + + role, permsChanged, err := roles.UpdateRole(r.Context(), actorFromContext(r), id, req.toInput()) + if err != nil { + writeRoleErr(w, err) + return + } + + if permsChanged { + invalidateUsers(permInvalidator, affected) + // READ_MESSAGES may have moved in either direction, so every + // channel's audience for this role has to be re-derived. + if hub != nil { + hub.RefreshAllChannelVisibility() + } + } + broadcastRoles(r, database, hub) + writeJSON(w, http.StatusOK, role) + } +} + +func handleDeleteRole(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator, roles *service.RoleService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if roles == nil { + roleServiceUnavailable(w) + return + } + id, err := pathInt64(r, "id") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id") + return + } + _, fallback, moved, err := roles.DeleteRole(r.Context(), actorFromContext(r), id) + if err != nil { + writeRoleErr(w, err) + return + } + + // The service already dropped the moved members' cached masks; this + // covers the handler-side invalidator too (they are the same object in + // production, distinct in tests). + invalidateUsers(permInvalidator, moved) + if hub != nil { + // Same shape as PATCH /users/{id} role_change: member_update tells + // every client the user regrouped, and revokes the subscriptions + // the new role may not read. + for _, uid := range moved { + hub.BroadcastMemberUpdate(uid, fallback.Name) + } + hub.RefreshAllChannelVisibility() + } + broadcastRoles(r, database, hub) + w.WriteHeader(http.StatusNoContent) + } +} + +func handleReorderRoles(hub HubBroadcaster, permInvalidator PermissionInvalidator, roles *service.RoleService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if roles == nil { + roleServiceUnavailable(w) + return + } + var req reorderRolesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") + return + } + updated, err := roles.ReorderRoles(r.Context(), actorFromContext(r), req.RoleIDs) + if err != nil { + writeRoleErr(w, err) + return + } + // Positions carry no permission bits, so no channel changes visibility; + // but a cached role snapshot holds the old position, which the + // hierarchy checks read. + if permInvalidator != nil { + permInvalidator.InvalidateAll() + } + if hub != nil { + hub.BroadcastRolesUpdate(updated) + } + writeJSON(w, http.StatusOK, updated) + } +} + +// invalidateUsers drops the cached permissions of the given users, if an +// invalidator is wired. +func invalidateUsers(permInvalidator PermissionInvalidator, userIDs []int64) { + if permInvalidator == nil { + return + } + for _, uid := range userIDs { + permInvalidator.InvalidateUser(uid) + } +} + +// broadcastRoles re-reads the role list and pushes it to every client. Re-read +// rather than patched locally so the broadcast always reflects committed state, +// including any concurrent change. +func broadcastRoles(r *http.Request, database *db.DB, hub HubBroadcaster) { + if hub == nil || database == nil { + return + } + list, err := database.ListRoles(r.Context()) + if err != nil { + // The mutation already committed; clients converge on their next + // reconnect rather than seeing a failed request. + slog.Warn("admin: roles_update broadcast skipped, role list unreadable", "err", err) + return + } + hub.BroadcastRolesUpdate(list) +} diff --git a/Server/admin/handlers_roles_test.go b/Server/admin/handlers_roles_test.go new file mode 100644 index 00000000..5496ce30 --- /dev/null +++ b/Server/admin/handlers_roles_test.go @@ -0,0 +1,421 @@ +package admin_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/owncord/server/admin" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// The admin test schema seeds Owner (id 1, pos 100, ADMINISTRATOR), Admin +// (id 2, pos 80, everything but ADMINISTRATOR) and Member (id 3, pos 40, +// is_default). createAdminUser signs in as the Owner. + +// newRolesHandler wires the full admin API with a mock hub and a recording +// permission invalidator, and returns them alongside an Owner bearer token. +func newRolesHandler(t *testing.T, database *db.DB) (http.Handler, *mockHub, *mockPermInvalidator, string) { + t.Helper() + hub := &mockHub{} + inv := &mockPermInvalidator{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, + newTestModService(database), newTestRoleService(database)) + return handler, hub, inv, createAdminUser(t, database) +} + +// createUserWithRole creates a user holding an existing roleID and returns a +// bearer token for them. (perm_gates_test.go's createRoleUser also upserts the +// role; these tests want the schema's seeded roles left exactly as they are.) +func createUserWithRole(t *testing.T, database *db.DB, username string, roleID int) string { + t.Helper() + uid, err := database.CreateUser(context.Background(), username, "$2a$12$placeholder", roleID) + if err != nil { + t.Fatalf("CreateUser %s: %v", username, err) + } + token := "test-token-" + username + "-" + t.Name() + if _, err := database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession %s: %v", username, err) + } + return token +} + +// doRequestRaw sends a request with a raw (possibly malformed) body, which +// doRequest cannot express because it marshals its body argument. +func doRequestRaw(t *testing.T, handler http.Handler, method, path, token, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +// decodeRole unmarshals a role response body. +func decodeRole(t *testing.T, body []byte) db.Role { + t.Helper() + var role db.Role + if err := json.Unmarshal(body, &role); err != nil { + t.Fatalf("unmarshal role: %v (body %s)", err, body) + } + return role +} + +// ─── POST /roles ───────────────────────────────────────────────────────────── + +func TestAdminAPI_CreateRole_OK(t *testing.T) { + database := openAdminTestDB(t) + handler, hub, _, token := newRolesHandler(t, database) + + w := doRequest(t, handler, http.MethodPost, "/roles", token, map[string]any{ + "name": "Helper", + "color": "#12ab34", + "permissions": permissions.ReadMessages | permissions.SendMessages, + "position": 50, + }) + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body: %s", w.Code, w.Body.String()) + } + role := decodeRole(t, w.Body.Bytes()) + if role.Name != "Helper" || role.Position != 50 { + t.Errorf("role = %+v", role) + } + if role.Color == nil || *role.Color != "#12AB34" { + t.Errorf("color = %v, want #12AB34", role.Color) + } + // Every mutation ships the new list to connected clients. + if len(hub.rolesUpdates) != 1 { + t.Fatalf("BroadcastRolesUpdate called %d times, want 1", len(hub.rolesUpdates)) + } + if len(hub.rolesUpdates[0]) != 4 { + t.Errorf("broadcast carried %d roles, want the full list of 4", len(hub.rolesUpdates[0])) + } + // A new role has no members, so nothing changed visibility. + if hub.allVisibilityRefreshes != 0 { + t.Errorf("RefreshAllChannelVisibility called %d times on create, want 0", hub.allVisibilityRefreshes) + } +} + +func TestAdminAPI_CreateRole_DuplicateNameIsBadRequest(t *testing.T) { + database := openAdminTestDB(t) + handler, _, _, token := newRolesHandler(t, database) + + w := doRequest(t, handler, http.MethodPost, "/roles", token, map[string]any{"name": "mEmBeR"}) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String()) + } +} + +func TestAdminAPI_Roles_RequireManageRoles(t *testing.T) { + database := openAdminTestDB(t) + handler, _, _, _ := newRolesHandler(t, database) + // A role inside the admin perimeter but without MANAGE_ROLES. + if _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (id, name, permissions, position, is_default) VALUES (9, 'Janitor', ?, 50, 0)`, + permissions.ManageChannels, + ); err != nil { + t.Fatalf("seed role: %v", err) + } + token := createUserWithRole(t, database, "janitor", 9) + + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodGet, "/roles", nil}, + {http.MethodPost, "/roles", map[string]any{"name": "x"}}, + {http.MethodPatch, "/roles/3", map[string]any{"name": "x"}}, + {http.MethodPatch, "/roles/reorder", map[string]any{"role_ids": []int64{3}}}, + {http.MethodDelete, "/roles/3", nil}, + } { + w := doRequest(t, handler, tc.method, tc.path, token, tc.body) + if w.Code != http.StatusForbidden { + t.Errorf("%s %s: status = %d, want 403; body: %s", tc.method, tc.path, w.Code, w.Body.String()) + } + } +} + +func TestAdminAPI_Roles_Unauthenticated(t *testing.T) { + database := openAdminTestDB(t) + handler, _, _, _ := newRolesHandler(t, database) + + if w := doRequest(t, handler, http.MethodGet, "/roles", "", nil); w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestAdminAPI_Roles_ServiceUnavailableFailsClosed(t *testing.T) { + database := openAdminTestDB(t) + // nil RoleService: the routes must refuse rather than fall through to an + // unchecked write. + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, + newTestModService(database), nil) + token := createAdminUser(t, database) + + for _, tc := range []struct { + method, path string + body any + }{ + {http.MethodGet, "/roles", nil}, + {http.MethodPost, "/roles", map[string]any{"name": "x"}}, + {http.MethodPatch, "/roles/3", map[string]any{"name": "x"}}, + {http.MethodPatch, "/roles/reorder", map[string]any{"role_ids": []int64{3}}}, + {http.MethodDelete, "/roles/3", nil}, + } { + w := doRequest(t, handler, tc.method, tc.path, token, tc.body) + if w.Code != http.StatusInternalServerError { + t.Errorf("%s %s: status = %d, want 500", tc.method, tc.path, w.Code) + } + } +} + +// ─── GET /roles ────────────────────────────────────────────────────────────── + +func TestAdminAPI_ListRoles_CarriesMemberCounts(t *testing.T) { + database := openAdminTestDB(t) + handler, _, _, token := newRolesHandler(t, database) + createUserWithRole(t, database, "member1", 3) + createUserWithRole(t, database, "member2", 3) + + w := doRequest(t, handler, http.MethodGet, "/roles", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var list []struct { + ID int64 `json:"id"` + Position int `json:"position"` + MemberCount int `json:"member_count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(list) != 3 { + t.Fatalf("got %d roles, want 3", len(list)) + } + if list[0].Position < list[len(list)-1].Position { + t.Error("roles are not ordered by position descending") + } + counts := map[int64]int{} + for _, r := range list { + counts[r.ID] = r.MemberCount + } + if counts[3] != 2 { + t.Errorf("member count = %d, want 2", counts[3]) + } + if counts[1] != 1 { + t.Errorf("owner count = %d, want 1 (the acting admin)", counts[1]) + } +} + +// ─── PATCH /roles/{id} ─────────────────────────────────────────────────────── + +func TestAdminAPI_PatchRole_PermissionChangeInvalidatesAndResyncs(t *testing.T) { + database := openAdminTestDB(t) + handler, hub, inv, token := newRolesHandler(t, database) + createUserWithRole(t, database, "member1", 3) + + w := doRequest(t, handler, http.MethodPatch, "/roles/3", token, map[string]any{ + "permissions": permissions.ReadMessages, + }) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + // The one member of role 3 has their cached mask dropped … + if len(inv.invalidateUserIDs) != 1 || inv.invalidateUserIDs[0] == 0 { + t.Errorf("InvalidateUser calls = %v, want the single role member", inv.invalidateUserIDs) + } + // … and every channel's audience is recomputed, because READ_MESSAGES moved. + if hub.allVisibilityRefreshes != 1 { + t.Errorf("RefreshAllChannelVisibility called %d times, want 1", hub.allVisibilityRefreshes) + } + if len(hub.rolesUpdates) != 1 { + t.Errorf("roles_update broadcasts = %d, want 1", len(hub.rolesUpdates)) + } +} + +func TestAdminAPI_PatchRole_RenameOnlySkipsResync(t *testing.T) { + database := openAdminTestDB(t) + handler, hub, inv, token := newRolesHandler(t, database) + + w := doRequest(t, handler, http.MethodPatch, "/roles/3", token, map[string]any{"name": "Regulars"}) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if decodeRole(t, w.Body.Bytes()).Name != "Regulars" { + t.Errorf("name not applied: %s", w.Body.String()) + } + if hub.allVisibilityRefreshes != 0 { + t.Errorf("RefreshAllChannelVisibility called %d times for a rename, want 0", hub.allVisibilityRefreshes) + } + if len(inv.invalidateUserIDs) != 0 { + t.Errorf("InvalidateUser called %v for a rename, want none", inv.invalidateUserIDs) + } + if len(hub.rolesUpdates) != 1 { + t.Errorf("roles_update broadcasts = %d, want 1 — the name is in the client's role list", len(hub.rolesUpdates)) + } +} + +func TestAdminAPI_PatchRole_HierarchyDenied(t *testing.T) { + database := openAdminTestDB(t) + handler, _, _, _ := newRolesHandler(t, database) + adminToken := createUserWithRole(t, database, "admin2", 2) + + // The Admin actor (80) may not edit the Owner role (100) … + w := doRequest(t, handler, http.MethodPatch, "/roles/1", adminToken, map[string]any{"name": "Pwned"}) + if w.Code != http.StatusForbidden { + t.Errorf("edit owner role: status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + // … nor their own. + w = doRequest(t, handler, http.MethodPatch, "/roles/2", adminToken, map[string]any{"name": "Superadmin"}) + if w.Code != http.StatusForbidden { + t.Errorf("edit own role: status = %d, want 403", w.Code) + } + // … nor push a role up to their own rank. + w = doRequest(t, handler, http.MethodPatch, "/roles/3", adminToken, map[string]any{"position": 80}) + if w.Code != http.StatusForbidden { + t.Errorf("promote role to own rank: status = %d, want 403", w.Code) + } +} + +func TestAdminAPI_PatchRole_InvalidIDAndBody(t *testing.T) { + database := openAdminTestDB(t) + handler, _, _, token := newRolesHandler(t, database) + + if w := doRequest(t, handler, http.MethodPatch, "/roles/abc", token, map[string]any{}); w.Code != http.StatusBadRequest { + t.Errorf("non-numeric id: status = %d, want 400", w.Code) + } + if w := doRequest(t, handler, http.MethodPatch, "/roles/999", token, map[string]any{"name": "x"}); w.Code != http.StatusNotFound { + t.Errorf("missing role: status = %d, want 404", w.Code) + } + req := doRequestRaw(t, handler, http.MethodPatch, "/roles/3", token, "{not json") + if req.Code != http.StatusBadRequest { + t.Errorf("malformed body: status = %d, want 400", req.Code) + } +} + +// ─── DELETE /roles/{id} ────────────────────────────────────────────────────── + +func TestAdminAPI_DeleteRole_ReassignsAndBroadcasts(t *testing.T) { + database := openAdminTestDB(t) + handler, hub, inv, token := newRolesHandler(t, database) + ctx := context.Background() + + w := doRequest(t, handler, http.MethodPost, "/roles", token, map[string]any{ + "name": "Contractor", "position": 50, + }) + if w.Code != http.StatusCreated { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + created := decodeRole(t, w.Body.Bytes()) + uid, err := database.CreateUser(ctx, "contractor", "$2a$12$placeholder", int(created.ID)) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + hub.rolesUpdates = nil + + w = doRequest(t, handler, http.MethodDelete, "/roles/"+itoa(created.ID), token, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("delete: status = %d, want 204; body: %s", w.Code, w.Body.String()) + } + + user, err := database.GetUserByID(ctx, uid) + if err != nil || user == nil { + t.Fatalf("GetUserByID: %v", err) + } + if user.RoleID != 3 { + t.Errorf("member role after delete = %d, want the default role 3", user.RoleID) + } + if len(inv.invalidateUserIDs) == 0 { + t.Error("reassigned member's cached permissions were not invalidated") + } + if len(hub.memberUpdates) != 1 || hub.memberUpdates[0].userID != uid { + t.Errorf("member_update broadcasts = %+v, want one for the reassigned member", hub.memberUpdates) + } + if hub.memberUpdates[0].roleName != "Member" { + t.Errorf("member_update role = %q, want the default role's name", hub.memberUpdates[0].roleName) + } + if hub.allVisibilityRefreshes != 1 { + t.Errorf("RefreshAllChannelVisibility called %d times, want 1", hub.allVisibilityRefreshes) + } + if len(hub.rolesUpdates) != 1 { + t.Errorf("roles_update broadcasts = %d, want 1", len(hub.rolesUpdates)) + } +} + +func TestAdminAPI_DeleteRole_OwnerAndDefaultRefused(t *testing.T) { + database := openAdminTestDB(t) + handler, _, _, token := newRolesHandler(t, database) + + // Nothing outranks the Owner role, so the hierarchy check refuses first. + if w := doRequest(t, handler, http.MethodDelete, "/roles/1", token, nil); w.Code != http.StatusForbidden { + t.Errorf("delete owner role: status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + // The default role clears the hierarchy check and is stopped by is_default. + w := doRequest(t, handler, http.MethodDelete, "/roles/3", token, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("delete default role: status = %d, want 400; body: %s", w.Code, w.Body.String()) + } + if role, _ := database.GetRoleByID(context.Background(), 3); role == nil { + t.Fatal("the default role was deleted") + } +} + +// ─── PATCH /roles/reorder ──────────────────────────────────────────────────── + +func TestAdminAPI_ReorderRoles_NormalizesAndBroadcasts(t *testing.T) { + database := openAdminTestDB(t) + handler, hub, inv, token := newRolesHandler(t, database) + + w := doRequest(t, handler, http.MethodPatch, "/roles/reorder", token, map[string]any{ + "role_ids": []int64{3, 2}, + }) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var list []db.Role + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("unmarshal: %v", err) + } + positions := map[int64]int{} + for _, r := range list { + positions[r.ID] = r.Position + } + if positions[3] != 2 || positions[2] != 1 { + t.Errorf("positions = %v, want role 3 above role 2 (2 and 1)", positions) + } + if positions[1] != 100 { + t.Errorf("owner position = %d, want it untouched at 100", positions[1]) + } + // Positions feed the hierarchy checks, so the cached role snapshots go. + if inv.invalidateAllN == 0 { + t.Error("InvalidateAll was not called after a reorder") + } + if len(hub.rolesUpdates) != 1 { + t.Errorf("roles_update broadcasts = %d, want 1", len(hub.rolesUpdates)) + } + // "reorder" must not be parsed as a role id by the {id} route. + if hub.allVisibilityRefreshes != 0 { + t.Errorf("RefreshAllChannelVisibility called %d times for a reorder, want 0", hub.allVisibilityRefreshes) + } +} + +func TestAdminAPI_ReorderRoles_PartialListRefused(t *testing.T) { + database := openAdminTestDB(t) + handler, hub, _, token := newRolesHandler(t, database) + + w := doRequest(t, handler, http.MethodPatch, "/roles/reorder", token, map[string]any{ + "role_ids": []int64{3}, + }) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String()) + } + if len(hub.rolesUpdates) != 0 { + t.Error("a refused reorder must not broadcast") + } +} diff --git a/Server/admin/handlers_users.go b/Server/admin/handlers_users.go index af701fdd..a0ccaf4d 100644 --- a/Server/admin/handlers_users.go +++ b/Server/admin/handlers_users.go @@ -1,14 +1,13 @@ package admin import ( - "context" "encoding/json" "errors" - "fmt" - "log/slog" "net/http" + "time" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" "github.com/owncord/server/service" ) @@ -52,8 +51,16 @@ type patchUserRequest struct { RoleID *int64 `json:"role_id"` Banned *bool `json:"banned"` BanReason *string `json:"ban_reason"` + // BanDurationHours makes the ban temporary: it expires this many hours + // from now (login re-checks via IsEffectivelyBanned). Omitted or 0 = + // permanent. Only meaningful with banned=true. + BanDurationHours *int `json:"ban_duration_hours"` } +// maxBanDurationHours caps temporary bans at one year; anything longer is +// effectively permanent and should be issued as such. +const maxBanDurationHours = 24 * 365 + // writeModerationErr maps ModerationService errors onto admin API responses. func writeModerationErr(w http.ResponseWriter, err error) { switch { @@ -116,9 +123,19 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis if req.BanReason != nil { banReason = *req.BanReason } + var banExpires *time.Time + if req.BanDurationHours != nil && *req.BanDurationHours != 0 { + hours := *req.BanDurationHours + if hours < 0 || hours > maxBanDurationHours { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "ban_duration_hours must be between 1 and 8760") + return + } + t := time.Now().Add(time.Duration(hours) * time.Hour) + banExpires = &t + } var actionErr error if *req.Banned { - actionErr = mod.BanUser(r.Context(), actor, id, banReason, nil) + actionErr = mod.BanUser(r.Context(), actor, id, banReason, banExpires) } else { actionErr = mod.UnbanUser(r.Context(), actor, id) } @@ -132,16 +149,22 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis } if req.RoleID != nil { - if _, err := database.ExecContext(r.Context(), `UPDATE users SET role_id = ? WHERE id = ?`, *req.RoleID, id); err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update role") + // Routed through ModerationService, which enforces MANAGE_ROLES, + // the actor-outranks-target rule, and the assign-below-own-rank + // rule (without it any admin could promote anyone to Owner), and + // writes the audit row. + if mod == nil { + // Fail closed rather than fall back to an unchecked UPDATE. + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation service unavailable") + return + } + if err := mod.ChangeUserRole(r.Context(), actor, id, *req.RoleID); err != nil { + writeModerationErr(w, err) return } - slog.Info("role changed", "actor_id", actor, "target_user", user.Username, "new_role_id", *req.RoleID) if permInvalidator != nil { permInvalidator.InvalidateUser(id) } - db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "role_change", "user", id, - fmt.Sprintf("changed %s role to %d", user.Username, *req.RoleID)) if role, err := database.GetRoleByID(r.Context(), *req.RoleID); err == nil && role != nil { if hub != nil { hub.BroadcastMemberUpdate(id, role.Name) @@ -158,21 +181,49 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis } } -func handleForceLogout(database *db.DB) http.HandlerFunc { +// handleForceLogout revokes every session of the target user. The route is +// gated on KICK_MEMBERS; ModerationService additionally enforces the +// actor-outranks-target hierarchy and writes the audit row. +func handleForceLogout(mod *service.ModerationService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := pathInt64(r, "id") if err != nil { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id") return } + if mod == nil { + // Fail closed rather than cut sessions without a hierarchy check. + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation service unavailable") + return + } - if err := database.ForceLogoutUser(r.Context(), id); err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to logout user") + if err := mod.ForceLogout(r.Context(), actorFromContext(r), id); err != nil { + writeModerationErr(w, err) return } - actor := actorFromContext(r) - slog.Info("force logout", "actor_id", actor, "target_user_id", id) - db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "force_logout", "user", id, "all sessions terminated") w.WriteHeader(http.StatusNoContent) } } + +// handleGetMe describes the calling principal so the admin panel can hide the +// surfaces its role cannot use. Perimeter-level: every authenticated principal +// may read its own permissions. +func handleGetMe() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, userOK := r.Context().Value(adminUserKey).(*db.User) + role, roleOK := r.Context().Value(adminRoleKey).(*db.Role) + if !userOK || user == nil || !roleOK || role == nil { + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated") + return + } + writeJSON(w, http.StatusOK, adminMeResponse{ + ID: user.ID, + Username: user.Username, + RoleID: role.ID, + RoleName: role.Name, + RolePosition: role.Position, + Permissions: role.Permissions, + IsOwner: role.Position >= permissions.OwnerRolePosition, + }) + } +} diff --git a/Server/admin/helpers.go b/Server/admin/helpers.go index 1553cba2..c1f2b45b 100644 --- a/Server/admin/helpers.go +++ b/Server/admin/helpers.go @@ -63,3 +63,15 @@ func actorFromContext(r *http.Request) int64 { } return user.ID } + +// actorRoleFromContext returns the authenticated principal's *db.Role stored +// in the request context by adminAuthMiddleware. Returns nil if called +// outside that middleware (should not happen in production) so callers can +// fail closed. +func actorRoleFromContext(r *http.Request) *db.Role { + role, ok := r.Context().Value(adminRoleKey).(*db.Role) + if !ok { + return nil + } + return role +} diff --git a/Server/admin/logstream_apitoken_test.go b/Server/admin/logstream_apitoken_test.go index 96cbf39e..0807268a 100644 --- a/Server/admin/logstream_apitoken_test.go +++ b/Server/admin/logstream_apitoken_test.go @@ -22,7 +22,7 @@ func TestAdminAPI_LogStreamTicketFlow_APIToken(t *testing.T) { database := openAdminTestDB(t) logBuf := admin.NewRingBuffer(8) logBuf.Write(admin.LogEntry{Timestamp: "2026-07-31T10:00:00Z", Level: "INFO", Message: "hello from ring", Source: "server"}) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database)) // An admin user authenticated only by an API token — no session row exists. uid, err := database.CreateUser(context.Background(), "apitokenadmin", "$2a$12$placeholder", 1) diff --git a/Server/admin/middleware.go b/Server/admin/middleware.go index da025709..d62250f9 100644 --- a/Server/admin/middleware.go +++ b/Server/admin/middleware.go @@ -12,16 +12,24 @@ import ( // ─── Middleware ─────────────────────────────────────────────────────────────── -// RequireAdminAuth is the exported form of adminAuthMiddleware. External -// packages (e.g. api/router.go for the plugin admin handler) reuse it so the -// session/permission gate stays in one place. +// RequireAdminAuth is the exported admin gate for surfaces outside this +// package (api/router.go's plugin admin handler). Those routes stay +// ADMINISTRATOR-only, so it chains the perimeter check with an explicit +// ADMINISTRATOR requirement rather than exposing the widened perimeter. func RequireAdminAuth(database *db.DB) func(http.Handler) http.Handler { - return adminAuthMiddleware(database) + perimeter := adminAuthMiddleware(database) + administrator := requirePerm(permissions.Administrator) + return func(next http.Handler) http.Handler { + return perimeter(administrator(next)) + } } -// adminAuthMiddleware validates the Bearer token and requires ADMINISTRATOR. -// On success it stores the *db.User and *db.Session in the request context so -// downstream handlers can retrieve them without re-querying the database. +// adminAuthMiddleware validates the Bearer token and requires at least one +// moderation-capable bit (permissions.AdminPerimeter) — not ADMINISTRATOR, so +// a Moderator role can reach the routes its bits allow. Individual route +// groups re-check the specific bit they need via requirePerm. +// On success it stores the *db.User, *db.Role and *db.Session in the request +// context so downstream handlers can retrieve them without re-querying. func adminAuthMiddleware(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) { @@ -33,8 +41,9 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler { hash := auth.HashToken(token) // Resolve the bearer token: login session first, then API token. An - // API token whose user carries the ADMINISTRATOR bit authenticates - // here too, so /admin/api/* works for headless clients. + // API token inherits its owning user's role, so a token whose user + // clears the perimeter authenticates here too and /admin/api/* + // works for headless clients. user, role, sess, err := auth.ResolveTokenHash(r.Context(), database, hash) if err != nil { switch { @@ -62,12 +71,13 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler { return } - if !permissions.HasAdmin(role.Permissions) { - writeErr(w, http.StatusForbidden, "FORBIDDEN", "administrator permission required") + if !permissions.HasAnyPerm(role.Permissions, permissions.AdminPerimeter) { + writeErr(w, http.StatusForbidden, "FORBIDDEN", "moderation permission required") return } ctx := context.WithValue(r.Context(), adminUserKey, user) + ctx = context.WithValue(ctx, adminRoleKey, role) ctx = context.WithValue(ctx, adminSessionKey, sess) // nil for API-token principals; consumers guard nil ctx = context.WithValue(ctx, adminTokenHashKey, hash) next.ServeHTTP(w, r.WithContext(ctx)) @@ -75,6 +85,27 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } } +// requirePerm gates a route group on a single server-wide permission bit. +// ADMINISTRATOR bypasses via permissions.HasServerPerm. The role comes from +// the request context (set by adminAuthMiddleware), so no extra query runs. +func requirePerm(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(adminRoleKey).(*db.Role) + if !ok || role == nil { + writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated") + return + } + if !permissions.HasServerPerm(role.Permissions, perm) { + writeErr(w, http.StatusForbidden, "FORBIDDEN", + permissions.Name(perm)+" permission required") + return + } + next.ServeHTTP(w, r) + }) + } +} + // ownerOnlyMiddleware wraps a handler to require Owner role (position == 100). // It reads the user from context (set by adminAuthMiddleware) rather than // re-authenticating, avoiding redundant DB queries and session-expiry gaps. diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index d55c7aca..dd1a5ad5 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -55,7 +55,10 @@ CREATE TABLE IF NOT EXISTS users ( banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT, - identity_public_key TEXT + identity_public_key TEXT, + display_name TEXT, + about TEXT, + custom_status TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -90,7 +93,9 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, - voice_max_video INTEGER NOT NULL DEFAULT 0 + voice_max_video INTEGER NOT NULL DEFAULT 0, + nsfw INTEGER NOT NULL DEFAULT 0, + is_group INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -101,8 +106,15 @@ CREATE TABLE IF NOT EXISTS messages ( pinned INTEGER NOT NULL DEFAULT 0, timestamp TEXT NOT NULL DEFAULT (datetime('now')), reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, - edited_at TEXT + edited_at TEXT, + mentions_everyone INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS message_mentions ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, mentioned_user_id) +); + CREATE TABLE IF NOT EXISTS invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, @@ -250,7 +262,7 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) { // role_id has been set to a nonexistent value returns 401. func TestAdminAuthMiddleware_RoleNotFound(t *testing.T) { database := openWhiteboxTestDB(t) - handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil) + handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil) uid, err := database.CreateUser(context.Background(), "noroleuser", "$2a$12$x", 1) if err != nil { @@ -404,6 +416,8 @@ func (m *mockHubWB) BroadcastChannelDelete(channelID int64) {} func (m *mockHubWB) BroadcastMemberBan(userID int64) {} func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {} func (m *mockHubWB) RefreshChannelVisibility(ch *db.Channel) {} +func (m *mockHubWB) RefreshAllChannelVisibility() {} +func (m *mockHubWB) BroadcastRolesUpdate(roles []*db.Role) {} func (m *mockHubWB) ClientCount() int { return 0 } // isolateSpawnedTestBinary makes it safe for a test to re-exec the test binary diff --git a/Server/admin/middleware_coverage_test.go b/Server/admin/middleware_coverage_test.go index ab9fc599..ea8fb5ee 100644 --- a/Server/admin/middleware_coverage_test.go +++ b/Server/admin/middleware_coverage_test.go @@ -19,7 +19,7 @@ import ( // session has expired is rejected with 401. func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) // Create a user and session, then manually expire the session by setting // expires_at to a past timestamp via the exported Exec helper. @@ -54,7 +54,7 @@ func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) { // access immediately, not only when the session expires. func TestAdminAuthMiddleware_BannedAdmin(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) if w := doRequest(t, handler, http.MethodGet, "/stats", token, nil); w.Code != http.StatusOK { @@ -78,7 +78,7 @@ func TestAdminAuthMiddleware_BannedAdmin(t *testing.T) { // Authorization header returns 401. func TestAdminAuthMiddleware_MissingBearer(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodGet, "/stats", "", nil) @@ -91,7 +91,7 @@ func TestAdminAuthMiddleware_MissingBearer(t *testing.T) { // sessions table returns 401. func TestAdminAuthMiddleware_InvalidToken(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodGet, "/stats", "completely-invalid-token", nil) diff --git a/Server/admin/perm_gates_test.go b/Server/admin/perm_gates_test.go new file mode 100644 index 00000000..9cd24ff3 --- /dev/null +++ b/Server/admin/perm_gates_test.go @@ -0,0 +1,402 @@ +package admin_test + +// Route-level permission gates. The /admin/api perimeter admits any role +// holding a moderation bit; each group then re-checks the specific bit, so a +// Moderator can manage channels without being able to read settings, the audit +// log, or the owner-only routes. + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/owncord/server/admin" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// moderatorMask is migration 001's seeded Moderator role: MANAGE_MESSAGES, +// MANAGE_CHANNELS, KICK_MEMBERS, BAN_MEMBERS (bits 16-19) and everything below. +const moderatorMask = int64(0x000FFFFF) + +// createRoleUser upserts a role and a user holding it, returning the user id +// and a bearer token for that user's session. +func createRoleUser(t *testing.T, database *db.DB, roleID int64, name string, perms int64, position int, username string) (int64, string) { + t.Helper() + if _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (?, ?, NULL, ?, ?, 0) + ON CONFLICT(id) DO UPDATE SET + name=excluded.name, permissions=excluded.permissions, position=excluded.position`, + roleID, name, perms, position, + ); err != nil { + t.Fatalf("seed role %s: %v", name, err) + } + uid, err := database.CreateUser(context.Background(), username, "$2a$12$placeholder", int(roleID)) + if err != nil { + t.Fatalf("CreateUser %s: %v", username, err) + } + token := username + "-token-" + t.Name() + if _, err := database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession %s: %v", username, err) + } + return uid, token +} + +// newModeratorHandler builds the admin API with a Moderator-role principal. +func newModeratorHandler(t *testing.T) (http.Handler, *db.DB, string) { + t.Helper() + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser") + return handler, database, token +} + +// ─── Perimeter ──────────────────────────────────────────────────────────────── + +func TestPerimeter_ModeratorAdmitted(t *testing.T) { + handler, _, token := newModeratorHandler(t) + + // Perimeter-level routes: reachable with any moderation bit. + for _, path := range []string{"/stats", "/users", "/me"} { + if w := doRequest(t, handler, http.MethodGet, path, token, nil); w.Code != http.StatusOK { + t.Errorf("GET %s = %d, want 200; body: %s", path, w.Code, w.Body.String()) + } + } +} + +func TestPerimeter_NoModerationBitsRejected(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + // MANAGE_MESSAGES alone is not a perimeter bit — it has no admin route. + _, token := createRoleUser(t, database, 11, "Helper", permissions.ManageMessages, 50, "helperuser") + + if w := doRequest(t, handler, http.MethodGet, "/stats", token, nil); w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } +} + +// ─── MANAGE_CHANNELS ────────────────────────────────────────────────────────── + +func TestChannelRoutes_ModeratorAllowed(t *testing.T) { + handler, _, token := newModeratorHandler(t) + + if w := doRequest(t, handler, http.MethodGet, "/channels", token, nil); w.Code != http.StatusOK { + t.Fatalf("GET /channels = %d, want 200; body: %s", w.Code, w.Body.String()) + } + w := doRequest(t, handler, http.MethodPost, "/channels", token, map[string]any{"name": "mod-made", "type": "text"}) + if w.Code != http.StatusCreated { + t.Fatalf("POST /channels = %d, want 201; body: %s", w.Code, w.Body.String()) + } + var created db.Channel + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal channel: %v", err) + } + if w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(created.ID), token, + map[string]any{"name": "mod-renamed"}); w.Code != http.StatusOK { + t.Errorf("PATCH /channels = %d, want 200; body: %s", w.Code, w.Body.String()) + } + // Channel-permission overrides ride the same gate. + if w := doRequest(t, handler, http.MethodGet, "/channels/"+itoa(created.ID)+"/permissions", token, nil); w.Code != http.StatusOK { + t.Errorf("GET channel permissions = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(created.ID), token, nil); w.Code != http.StatusNoContent { + t.Errorf("DELETE /channels = %d, want 204; body: %s", w.Code, w.Body.String()) + } +} + +// ─── BAN_MEMBERS reaches PATCH /users/{id} ─────────────────────────────────── + +// The ban path is authorized inside ModerationService, so the route must stay +// perimeter-level: gating it on ADMINISTRATOR (or on MANAGE_ROLES) would put +// banning out of a Moderator's reach entirely. +func TestPatchUserBan_ModeratorAllowed(t *testing.T) { + handler, database, token := newModeratorHandler(t) + targetUID, _ := database.CreateUser(context.Background(), "spammer", "hash", 3) + + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, + map[string]any{"banned": true, "ban_reason": "spam"}) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + user, _ := database.GetUserByID(context.Background(), targetUID) + if !user.Banned { + t.Error("target should be banned") + } +} + +func TestChannelRoutes_WithoutManageChannelsForbidden(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _, token := createRoleUser(t, database, 12, "Auditor", permissions.ViewAuditLog, 50, "auditoruser") + + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/channels"}, + {http.MethodPost, "/channels"}, + {http.MethodPatch, "/channels/1"}, + {http.MethodDelete, "/channels/1"}, + {http.MethodGet, "/channels/1/permissions"}, + } { + if w := doRequest(t, handler, tc.method, tc.path, token, nil); w.Code != http.StatusForbidden { + t.Errorf("%s %s = %d, want 403; body: %s", tc.method, tc.path, w.Code, w.Body.String()) + } + } +} + +// ─── VIEW_AUDIT_LOG / MANAGE_SERVER ────────────────────────────────────────── + +func TestAuditAndSettings_ModeratorForbidden(t *testing.T) { + handler, _, token := newModeratorHandler(t) + + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/audit-log"}, + {http.MethodGet, "/settings"}, + {http.MethodPatch, "/settings"}, + {http.MethodPost, "/logs/ticket"}, + } { + if w := doRequest(t, handler, tc.method, tc.path, token, nil); w.Code != http.StatusForbidden { + t.Errorf("%s %s = %d, want 403; body: %s", tc.method, tc.path, w.Code, w.Body.String()) + } + } +} + +func TestAuditAndSettings_BitHoldersAllowed(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _, auditToken := createRoleUser(t, database, 12, "Auditor", permissions.ViewAuditLog, 50, "auditoruser") + _, cfgToken := createRoleUser(t, database, 13, "Configurator", permissions.ManageServer, 50, "cfguser") + + if w := doRequest(t, handler, http.MethodGet, "/audit-log", auditToken, nil); w.Code != http.StatusOK { + t.Errorf("GET /audit-log = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if w := doRequest(t, handler, http.MethodGet, "/settings", cfgToken, nil); w.Code != http.StatusOK { + t.Errorf("GET /settings = %d, want 200; body: %s", w.Code, w.Body.String()) + } + // Each bit gates only its own group. + if w := doRequest(t, handler, http.MethodGet, "/settings", auditToken, nil); w.Code != http.StatusForbidden { + t.Errorf("auditor GET /settings = %d, want 403", w.Code) + } + if w := doRequest(t, handler, http.MethodGet, "/audit-log", cfgToken, nil); w.Code != http.StatusForbidden { + t.Errorf("configurator GET /audit-log = %d, want 403", w.Code) + } +} + +// ─── Owner-only routes stay owner-only ─────────────────────────────────────── + +func TestOwnerOnlyRoutes_ModeratorForbidden(t *testing.T) { + handler, _, token := newModeratorHandler(t) + + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/tokens"}, + {http.MethodPost, "/tokens"}, + {http.MethodGet, "/backups"}, + {http.MethodPost, "/backup"}, + {http.MethodGet, "/updates"}, + {http.MethodPost, "/updates/apply"}, + } { + if w := doRequest(t, handler, tc.method, tc.path, token, nil); w.Code != http.StatusForbidden { + t.Errorf("%s %s = %d, want 403; body: %s", tc.method, tc.path, w.Code, w.Body.String()) + } + } +} + +// ─── KICK_MEMBERS (force logout) ───────────────────────────────────────────── + +func TestForceLogout_RequiresKickMembers(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _, token := createRoleUser(t, database, 14, "ChannelMod", permissions.ManageChannels, 60, "chanmoduser") + + targetUID, _ := database.CreateUser(context.Background(), "victim", "hash", 3) + if _, err := database.CreateSession(context.Background(), targetUID, "victim-hash-perm", "web", "1.2.3.4"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + sessions, _ := database.GetUserSessions(context.Background(), targetUID) + if len(sessions) != 1 { + t.Errorf("sessions = %d, want 1 (refused call must not cut sessions)", len(sessions)) + } +} + +func TestForceLogout_HierarchyEnforced(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + _, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser") + + // Owner (role 1, position 100) outranks the moderator. + ownerUID, err := database.CreateUser(context.Background(), "theowner", "hash", 1) + if err != nil { + t.Fatalf("CreateUser owner: %v", err) + } + if _, err := database.CreateSession(context.Background(), ownerUID, "owner-hash-hier", "web", "1.2.3.4"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + if w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(ownerUID)+"/sessions", token, nil); w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + sessions, _ := database.GetUserSessions(context.Background(), ownerUID) + if len(sessions) != 1 { + t.Errorf("owner sessions = %d, want 1", len(sessions)) + } + + // A lower-ranked member is fair game. + memberUID, _ := database.CreateUser(context.Background(), "amember", "hash", 3) + if _, err := database.CreateSession(context.Background(), memberUID, "member-hash-hier", "web", "1.2.3.4"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + if w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(memberUID)+"/sessions", token, nil); w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) + } +} + +// ─── MANAGE_ROLES (role assignment) ────────────────────────────────────────── + +func TestPatchUserRole_RequiresManageRoles(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + // The seeded Moderator mask stops at bit 19 — no MANAGE_ROLES (bit 24). + _, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser") + targetUID, _ := database.CreateUser(context.Background(), "promoteme", "hash", 3) + + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{"role_id": 2}) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + user, _ := database.GetUserByID(context.Background(), targetUID) + if user.RoleID != 3 { + t.Errorf("role_id = %d, want 3 (unchanged)", user.RoleID) + } +} + +func TestPatchUserRole_CannotPromoteToOwner(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + // Role 2 "Admin" (position 80) holds MANAGE_ROLES but is below Owner. + _, token := createRoleUser(t, database, 2, "Admin", 0x3FFFFFFF, 80, "adminuser2") + targetUID, _ := database.CreateUser(context.Background(), "wannabeowner", "hash", 3) + + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, + map[string]any{"role_id": permissions.OwnerRoleID}) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + user, _ := database.GetUserByID(context.Background(), targetUID) + if user.RoleID != 3 { + t.Errorf("role_id = %d, want 3 (unchanged)", user.RoleID) + } +} + +func TestPatchUserRole_ModeratorCannotDemoteAdmin(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + // A moderator that does hold MANAGE_ROLES still cannot touch a higher rank. + _, token := createRoleUser(t, database, 10, "Moderator", moderatorMask|permissions.ManageRoles, 60, "moduser") + adminUID, err := database.CreateUser(context.Background(), "sitting-admin", "hash", 2) + if err != nil { + t.Fatalf("CreateUser admin: %v", err) + } + + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(adminUID), token, map[string]any{"role_id": 3}) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + user, _ := database.GetUserByID(context.Background(), adminUID) + if user.RoleID != 2 { + t.Errorf("role_id = %d, want 2 (unchanged)", user.RoleID) + } +} + +// ─── RequireAdminAuth (plugin admin routes) ────────────────────────────────── + +// The exported gate wraps surfaces outside this package (api/router.go mounts +// the plugin admin handler behind it). Widening the panel perimeter must not +// widen those: they stay ADMINISTRATOR-only. +func TestRequireAdminAuth_StaysAdministratorOnly(t *testing.T) { + database := openAdminTestDB(t) + reached := false + guarded := admin.RequireAdminAuth(database)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + })) + _, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser") + ownerToken := createAdminUser(t, database) + + if w := doRequest(t, guarded, http.MethodGet, "/plugins", modToken, nil); w.Code != http.StatusForbidden { + t.Errorf("moderator = %d, want 403; body: %s", w.Code, w.Body.String()) + } + if reached { + t.Error("handler reached by a non-administrator") + } + if w := doRequest(t, guarded, http.MethodGet, "/plugins", ownerToken, nil); w.Code != http.StatusOK { + t.Errorf("owner = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if !reached { + t.Error("handler not reached by the owner") + } +} + +// ─── GET /me ───────────────────────────────────────────────────────────────── + +func TestGetMe_ReportsCallerPermissions(t *testing.T) { + handler, _, token := newModeratorHandler(t) + + w := doRequest(t, handler, http.MethodGet, "/me", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var me struct { + Username string `json:"username"` + RoleName string `json:"role_name"` + RolePosition int `json:"role_position"` + Permissions int64 `json:"permissions"` + IsOwner bool `json:"is_owner"` + } + if err := json.Unmarshal(w.Body.Bytes(), &me); err != nil { + t.Fatalf("unmarshal me: %v", err) + } + if me.Username != "moduser" || me.RoleName != "Moderator" { + t.Errorf("me = %+v, want moduser/Moderator", me) + } + if me.Permissions != moderatorMask { + t.Errorf("permissions = %#x, want %#x", me.Permissions, moderatorMask) + } + if me.RolePosition != 60 || me.IsOwner { + t.Errorf("role_position = %d, is_owner = %v; want 60/false", me.RolePosition, me.IsOwner) + } +} + +func TestGetMe_OwnerFlagged(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodGet, "/me", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var me struct { + IsOwner bool `json:"is_owner"` + } + if err := json.Unmarshal(w.Body.Bytes(), &me); err != nil { + t.Fatalf("unmarshal me: %v", err) + } + if !me.IsOwner { + t.Error("is_owner = false for the Owner role") + } +} diff --git a/Server/admin/perm_grid_test.go b/Server/admin/perm_grid_test.go new file mode 100644 index 00000000..bd7992b8 --- /dev/null +++ b/Server/admin/perm_grid_test.go @@ -0,0 +1,149 @@ +package admin_test + +import ( + "os" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/owncord/server/permissions" +) + +// The admin panel's role editor renders one checkbox per permission bit from a +// PERM_GROUPS literal in the static HTML. Nothing compiles that literal, so a +// bit added to permissions.AllPerms without being added there becomes silently +// ungrantable through the panel — the role editor would quietly clear it on +// every save, because collectRolePerms rebuilds the mask from the boxes it +// rendered. These tests are the only thing tying the two together. + +var permGroupsBlockRe = regexp.MustCompile(`(?s)const PERM_GROUPS=\[(.*?)\n\];`) + +// permGridBits extracts the bit values the panel's permission grid renders. +func permGridBits(t *testing.T) []int64 { + t.Helper() + source, err := os.ReadFile("static/index.html") + if err != nil { + t.Fatalf("read admin panel: %v", err) + } + block := permGroupsBlockRe.FindSubmatch(source) + if block == nil { + t.Fatal("PERM_GROUPS literal not found in static/index.html") + } + // Each entry is [0x…,'Label','Description']. + entryRe := regexp.MustCompile(`\[(0x[0-9a-fA-F]+),'`) + matches := entryRe.FindAllSubmatch(block[1], -1) + if len(matches) == 0 { + t.Fatal("PERM_GROUPS contains no permission entries") + } + bits := make([]int64, 0, len(matches)) + for _, m := range matches { + bit, err := strconv.ParseInt(strings.TrimPrefix(string(m[1]), "0x"), 16, 64) + if err != nil { + t.Fatalf("unparseable bit %q: %v", m[1], err) + } + bits = append(bits, bit) + } + return bits +} + +func TestAdminPanelPermGridCoversEveryPermissionBit(t *testing.T) { + var mask int64 + for _, bit := range permGridBits(t) { + mask |= bit + } + if mask != permissions.AllPerms { + missing := permissions.AllPerms &^ mask + extra := mask &^ permissions.AllPerms + t.Errorf("panel permission grid mask = %#x, want %#x (missing %#x, undefined %#x)", + mask, permissions.AllPerms, missing, extra) + } +} + +func TestAdminPanelPermGridHasNoDuplicateOrCompositeBits(t *testing.T) { + seen := make(map[int64]bool) + for _, bit := range permGridBits(t) { + // A checkbox must map to exactly one bit: collectRolePerms ORs the + // checked values together, so a composite entry would grant several + // permissions from one box and could not express clearing just one. + if bit&(bit-1) != 0 { + t.Errorf("permission grid entry %#x sets more than one bit", bit) + } + if seen[bit] { + t.Errorf("permission grid lists bit %#x (%s) twice", bit, permissions.Name(bit)) + } + seen[bit] = true + } + if len(seen) != len(permGridBits(t)) { + t.Errorf("permission grid has %d entries but %d distinct bits", len(permGridBits(t)), len(seen)) + } +} + +// ─── Override matrix ───────────────────────────────────────────────────────── + +var overrideBitsBlockRe = regexp.MustCompile(`(?s)const OVERRIDE_BITS=\[(.*?)\n\];`) + +// overrideMatrixBits extracts the bits the channel-permission matrix editor +// renders one tri-state row for. +func overrideMatrixBits(t *testing.T) []int64 { + t.Helper() + source, err := os.ReadFile("static/index.html") + if err != nil { + t.Fatalf("read admin panel: %v", err) + } + block := overrideBitsBlockRe.FindSubmatch(source) + if block == nil { + t.Fatal("OVERRIDE_BITS literal not found in static/index.html") + } + entryRe := regexp.MustCompile(`\[(0x[0-9a-fA-F]+),'`) + matches := entryRe.FindAllSubmatch(block[1], -1) + if len(matches) == 0 { + t.Fatal("OVERRIDE_BITS contains no entries") + } + bits := make([]int64, 0, len(matches)) + for _, m := range matches { + bit, err := strconv.ParseInt(strings.TrimPrefix(string(m[1]), "0x"), 16, 64) + if err != nil { + t.Fatalf("unparseable bit %q: %v", m[1], err) + } + bits = append(bits, bit) + } + return bits +} + +// The matrix covers exactly the bits a CHANNEL override can meaningfully carry. +// Server-wide bits are deliberately absent: an override on MANAGE_ROLES would +// write a mask nothing ever resolves. +func TestAdminPanelOverrideMatrixCoversChannelScopedBits(t *testing.T) { + want := permissions.ReadMessages | permissions.SendMessages | permissions.AttachFiles | + permissions.AddReactions | permissions.ManageMessages | permissions.MentionEveryone | + permissions.ConnectVoice | permissions.SpeakVoice | permissions.UseVideo | + permissions.ShareScreen + + var mask int64 + for _, bit := range overrideMatrixBits(t) { + mask |= bit + } + if mask != want { + t.Errorf("override matrix mask = %#x, want %#x (missing %#x, extra %#x)", + mask, want, want&^mask, mask&^want) + } +} + +func TestAdminPanelOverrideMatrixHasSingleDefinedBits(t *testing.T) { + seen := make(map[int64]bool) + for _, bit := range overrideMatrixBits(t) { + // collectOverrideMasks ORs each checked row's bit into one of the two + // masks, so a composite row could not express clearing just one bit. + if bit&(bit-1) != 0 { + t.Errorf("override matrix entry %#x sets more than one bit", bit) + } + if bit&permissions.AllPerms != bit { + t.Errorf("override matrix entry %#x is not a defined permission bit", bit) + } + if seen[bit] { + t.Errorf("override matrix lists bit %#x (%s) twice", bit, permissions.Name(bit)) + } + seen[bit] = true + } +} diff --git a/Server/admin/setup_handler_test.go b/Server/admin/setup_handler_test.go index 8d98143b..f450fa42 100644 --- a/Server/admin/setup_handler_test.go +++ b/Server/admin/setup_handler_test.go @@ -14,7 +14,7 @@ import ( func TestSetupStatus_NeedsSetup(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) rr := doRequest(t, handler, "GET", "/setup/status", "", nil) if rr.Code != http.StatusOK { @@ -35,7 +35,7 @@ func TestSetupStatus_NeedsSetup(t *testing.T) { func TestSetupStatus_NoSetupNeeded(t *testing.T) { database := openAdminTestDB(t) createAdminUser(t, database) // Create a user first - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) rr := doRequest(t, handler, "GET", "/setup/status", "", nil) if rr.Code != http.StatusOK { @@ -55,7 +55,7 @@ func TestSetupStatus_NoSetupNeeded(t *testing.T) { func TestSetup_CreatesOwner(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "myadmin", @@ -100,7 +100,7 @@ func TestSetup_CreatesOwner(t *testing.T) { func TestSetup_BlockedAfterFirstUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) // First setup succeeds. rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ @@ -123,7 +123,7 @@ func TestSetup_BlockedAfterFirstUser(t *testing.T) { func TestSetup_WeakPassword(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "admin", @@ -136,7 +136,7 @@ func TestSetup_WeakPassword(t *testing.T) { func TestSetup_MissingFields(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "", @@ -151,7 +151,7 @@ func TestSetup_MissingFields(t *testing.T) { // server and asserts that exactly one owner is created (BUG-119). func TestSetup_ConcurrentRace(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) const goroutines = 20 results := make(chan int, goroutines) @@ -204,7 +204,7 @@ func TestSetup_ConcurrentRace(t *testing.T) { // on an empty allowlist, and a foreign origin still does not. func TestSetup_SameOriginAllowedWithEmptyAllowlist(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) body, err := json.Marshal(map[string]string{"username": "owner", "password": "correct-horse"}) if err != nil { @@ -226,7 +226,7 @@ func TestSetup_SameOriginAllowedWithEmptyAllowlist(t *testing.T) { func TestSetup_ForeignOriginStillBlocked(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) body, err := json.Marshal(map[string]string{"username": "owner", "password": "correct-horse"}) if err != nil { diff --git a/Server/admin/setup_wizard_test.go b/Server/admin/setup_wizard_test.go index 4e20e4cf..0cd23839 100644 --- a/Server/admin/setup_wizard_test.go +++ b/Server/admin/setup_wizard_test.go @@ -36,7 +36,7 @@ func wizardRunningCfg() *config.Config { // that signals restarted (buffered) instead of respawning the process. func wizardHandler(t *testing.T, database *db.DB, cfgPath string, restarted chan string) http.Handler { t.Helper() - return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), + return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), admin.SetupOptions{ ConfigPath: cfgPath, RunningCfg: wizardRunningCfg(), diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html index c1bc8f9d..fbead1eb 100644 --- a/Server/admin/static/index.html +++ b/Server/admin/static/index.html @@ -111,6 +111,14 @@ .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:focus{border-color:var(--accent)} .form-textarea{resize:vertical;min-height:80px} + .role-swatch{width:14px;height:14px;border-radius:50%;flex-shrink:0;border:1px solid var(--border-strong)} + .perm-group{margin-bottom:14px} + .perm-group-title{font-size:11px;font-weight:700;color:var(--text-faint);letter-spacing:.04em;text-transform:uppercase;margin-bottom:6px} + .perm-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 12px} + .perm-item{display:flex;align-items:flex-start;gap:7px;font-size:13px;color:var(--text-muted);cursor:pointer} + .perm-item input{margin-top:2px;flex-shrink:0} + .perm-item.locked{opacity:.45;cursor:not-allowed} + @media(max-width:600px){.perm-grid{grid-template-columns:1fr}} .toggle{width:40px;height:22px;border-radius:11px;background:var(--border-strong);cursor:pointer;position:relative;transition:background .2s;flex-shrink:0;padding:0;appearance:none;-webkit-appearance:none;border:none} .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} @@ -294,13 +302,18 @@ const I={ lock:'', plugins:'', upload:'', + shield:'', + arrowUp:'', + arrowDown:'', + smile:'', }; /* ═══ State ═══ */ const PAGE_SIZE=50; const state={section:'dashboard',token:localStorage.getItem('admin_token')||'', + me:null, usersPage:1,auditPage:1,auditSearch:'',auditActionFilter:'all',auditCache:[],settingsChanged:false,backupRunning:false,updateApplying:false, - cachedStats:null,cachedUpdate:null,channelCache:{},pluginRuntime:'unknown',pluginBusy:false, + cachedStats:null,cachedUpdate:null,channelCache:{},roleList:[],pluginRuntime:'unknown',pluginBusy:false, logEntries:[],logLevels:{DEBUG:true,INFO:true,WARN:true,ERROR:true}, logSearch:'',logAutoScroll:true,logPaused:false,logEventSource:null,logReconnectTimer:null,logConnectSeq:0,logMaxLines:2000}; @@ -312,7 +325,7 @@ function handleSessionExpired(){ state.logConnectSeq++; if(state.logEventSource){state.logEventSource.close();state.logEventSource=null} if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null} - state.token='';localStorage.removeItem('admin_token'); + state.token='';state.me=null;localStorage.removeItem('admin_token'); const err=document.getElementById('loginErr');if(err)err.textContent='Your session expired — sign in again.'; showOverlay('loginOverlay'); } @@ -328,6 +341,23 @@ async function api(method,path,body){ return data; } +/* ═══ Permissions ═══ */ +/* The panel perimeter admits any role holding one moderation bit, so what a + principal may actually do varies. GET /admin/api/me reports the caller's + role mask; tabs and row actions hide what it cannot use. Hiding is an + affordance only — every route re-checks the bit server-side. */ +const PERM={MANAGE_CHANNELS:0x20000,KICK_MEMBERS:0x40000,BAN_MEMBERS:0x80000, + MUTE_MEMBERS:0x100000,MANAGE_ROLES:0x1000000,MANAGE_SERVER:0x2000000, + VIEW_AUDIT_LOG:0x8000000,ADMINISTRATOR:0x40000000}; +function can(bit){ + const p=(state.me&&state.me.permissions)||0; + if((p&PERM.ADMINISTRATOR)!==0)return true; + return (p&bit)===bit; +} +/* Owner-only routes (tokens, backups, updates) gate on role position, not on + a bit, so the mask alone cannot answer this. */ +function isOwner(){return !!(state.me&&state.me.is_owner)} + /* ═══ Utilities ═══ */ function esc(s){if(s===null||s===undefined)return'';return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')} /* Escape for embedding inside a single-quoted JS string in an inline onclick @@ -337,8 +367,24 @@ function jsq(s){return esc(String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'"))} function fmtBytes(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';if(b<1073741824)return(b/1048576).toFixed(1)+' MB';return(b/1073741824).toFixed(2)+' GB'} function actionBadge(a){if(!a)return'badge-muted';if(a.includes('ban')||a.includes('kick')||a.includes('delete'))return'badge-red';if(a.includes('create'))return'badge-green';if(a.includes('update'))return'badge-yellow';return'badge-accent'} function actionColor(a){if(!a)return'var(--accent)';if(a.includes('ban')||a.includes('kick')||a.includes('delete'))return'var(--red)';if(a.includes('create'))return'var(--green)';if(a.includes('update'))return'var(--yellow)';return'var(--accent)'} -function roleColor(rid){return{1:'var(--role-owner)',2:'var(--role-admin)',3:'var(--role-mod)'}[rid]||'var(--role-member)'} -function roleName(rid){return{1:'Owner',2:'Admin',3:'Moderator',4:'Member'}[rid]||'Member'} +/* Roles are createable now, so the four seeded ids are a fallback, not the set. + Anything role-shaped prefers the live list (state.roleList, filled by the + Roles section and by openEditUser) and only then the seeded map — otherwise a + custom role renders as "Member" in its own colour. */ +function roleFromCache(rid){return (state.roleList||[]).find(r=>r.id===rid)||null} +function roleColor(rid){ + const r=roleFromCache(rid); + if(r&&r.color)return r.color; + return{1:'var(--role-owner)',2:'var(--role-admin)',3:'var(--role-mod)'}[rid]||'var(--role-member)'; +} +/* name is the server-supplied role_name where the caller has one (the users + list ships it); it wins over any cache because it is always current. */ +function roleName(rid,name){ + if(name)return name; + const r=roleFromCache(rid); + if(r)return r.name; + return{1:'Owner',2:'Admin',3:'Moderator',4:'Member'}[rid]||'Member'; +} function showToast(msg,type='success'){ const t=document.getElementById('toast'); @@ -365,7 +411,28 @@ function showApp(){hideAll();document.getElementById('adminShell').classList.rem async function checkAuth(){ try{const r=await fetch('/admin/api/setup/status');const d=await r.json();if(d.needs_setup){wizInit(d.defaults);showOverlay('setupOverlay');return}}catch(e){console.error('setup check:',e)} if(!state.token){showOverlay('loginOverlay');return} - try{await api('GET','/stats');showApp();renderNav();renderContent()}catch(e){showOverlay('loginOverlay')} + try{await enterApp()}catch(e){showOverlay('loginOverlay')} +} + +/* Loads the caller's permissions before the first render — the nav is built + from them, so rendering earlier would flash tabs the principal cannot open. + Throws on an unusable session so callers fall back to the login overlay. */ +/* A #section fragment deep-links the panel: the desktop client's "Audit Log" + entry opens /admin#audit, so the operator lands on the log rather than on the + dashboard with a tab still to find. Applied before the permission fallback + below, so a fragment naming a section the principal may not open falls back + to the dashboard exactly like a stale stored section does. */ +function sectionFromHash(){ + const id=(location.hash||'').replace(/^#/,''); + return NAV.some(n=>n.id===id)?id:''; +} + +async function enterApp(){ + state.me=await api('GET','/me'); + const deepLink=sectionFromHash(); + if(deepLink)state.section=deepLink; + if(!sectionAllowed(state.section))state.section='dashboard'; + showApp();renderNav();renderContent(); } /* ═══ First-Run Setup Wizard ═══ */ @@ -577,7 +644,7 @@ function beginRestartWait(url){ },4000); } -document.getElementById('setupContinueBtn').onclick=()=>{showApp();renderNav();renderContent()}; +document.getElementById('setupContinueBtn').onclick=()=>{enterApp().catch(()=>showOverlay('loginOverlay'))}; function copyInvite(){navigator.clipboard.writeText(document.getElementById('inviteCode').textContent).then(()=>showToast('Copied!','info')).catch(()=>showToast('Copy failed','error'))} document.getElementById('loginBtn').onclick=async()=>{ @@ -589,7 +656,7 @@ document.getElementById('loginBtn').onclick=async()=>{ if(btn.disabled)return; btn.disabled=true; try{const r=await fetch('/api/v1/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});const d=await r.json();if(!r.ok)throw new Error(d.message||'Login failed'); - state.token=d.token;localStorage.setItem('admin_token',state.token);showApp();renderNav();renderContent(); + state.token=d.token;localStorage.setItem('admin_token',state.token);await enterApp(); }catch(e){err.textContent=e.message} finally{btn.disabled=false} }; @@ -598,26 +665,59 @@ document.getElementById('loginBtn').onclick=async()=>{ ['loginUser','loginPass'].forEach(id=>document.getElementById(id).addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('loginBtn').click()})); /* ═══ Nav ═══ */ +/* `allowed` mirrors the server-side gate on each section's routes; omitted + means perimeter-level (any principal the panel let in). */ const NAV=[ {section:'Management'}, {id:'dashboard',label:'Dashboard',icon:I.dashboard}, {id:'users',label:'Users',icon:I.users}, - {id:'channels',label:'Channels',icon:I.channels}, + {id:'channels',label:'Channels',icon:I.channels,allowed:()=>can(PERM.MANAGE_CHANNELS)}, + {id:'roles',label:'Roles',icon:I.shield,allowed:()=>can(PERM.MANAGE_ROLES)}, + {id:'emoji',label:'Emoji',icon:I.smile,allowed:()=>can(PERM.MANAGE_SERVER)}, {sep:true}, {section:'Configuration'}, - {id:'audit',label:'Audit Log',icon:I.audit}, - {id:'tokens',label:'API Tokens',icon:I.lock}, - {id:'plugins',label:'Plugins',icon:I.plugins}, - {id:'logs',label:'Server Logs',icon:I.logs}, - {id:'settings',label:'Settings',icon:I.settings,unsaved:()=>state.settingsChanged}, - {id:'backups',label:'Backups',icon:I.backup}, - {id:'updates',label:'Updates',icon:I.updates}, + {id:'audit',label:'Audit Log',icon:I.audit,allowed:()=>can(PERM.VIEW_AUDIT_LOG)}, + {id:'tokens',label:'API Tokens',icon:I.lock,allowed:isOwner}, + {id:'plugins',label:'Plugins',icon:I.plugins,allowed:()=>can(PERM.ADMINISTRATOR)}, + {id:'logs',label:'Server Logs',icon:I.logs,allowed:()=>can(PERM.ADMINISTRATOR)}, + {id:'settings',label:'Settings',icon:I.settings,unsaved:()=>state.settingsChanged,allowed:()=>can(PERM.MANAGE_SERVER)}, + {id:'backups',label:'Backups',icon:I.backup,allowed:isOwner}, + {id:'updates',label:'Updates',icon:I.updates,allowed:isOwner}, {sep:true}, {id:'logout',label:'Sign Out',icon:I.logout,danger:true}, ]; +/* True when the principal may open the section. Unknown ids are refused so a + stale localStorage/section value can't route into a hidden page. */ +function sectionAllowed(id){ + const n=NAV.find(x=>x.id===id); + if(!n)return false; + return !n.allowed||n.allowed(); +} + +/* Drops section labels with no visible item under them and separators that + would end up leading, trailing, or doubled once entries are filtered out. */ +function visibleNav(){ + const kept=NAV.filter(n=>n.section||n.sep||!n.allowed||n.allowed()); + const out=[]; + for(let i=0;i{ + document.getElementById('sidebarNav').innerHTML=visibleNav().map(n=>{ if(n.section)return''; if(n.sep)return''; const active=state.section===n.id?'active':''; @@ -629,6 +729,7 @@ function renderNav(){ } function navigateTo(id){ + if(!sectionAllowed(id)){showToast('You do not have permission to open that section','error');return} try{ if(state.section==='logs'&&id!=='logs'){state.logConnectSeq++;if(state.logEventSource){state.logEventSource.close();state.logEventSource=null}if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null}} state.section=id;renderNav();renderContent(); @@ -639,12 +740,12 @@ function navigateTo(id){ } } -function doLogout(){state.logConnectSeq++;if(state.logEventSource){state.logEventSource.close();state.logEventSource=null}if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null}state.token='';localStorage.removeItem('admin_token');showOverlay('loginOverlay')} +function doLogout(){state.logConnectSeq++;if(state.logEventSource){state.logEventSource.close();state.logEventSource=null}if(state.logReconnectTimer){clearTimeout(state.logReconnectTimer);state.logReconnectTimer=null}state.token='';state.me=null;localStorage.removeItem('admin_token');showOverlay('loginOverlay')} /* ═══ Content Router ═══ */ function renderContent(){ const c=document.getElementById('content');if(!c)return;c.scrollTop=0; - const r={dashboard:renderDashboard,users:renderUsers,channels:renderChannels,audit:renderAudit,tokens:renderTokens,plugins:renderPlugins,logs:renderLogs,settings:renderSettings,backups:renderBackups,updates:renderUpdates}; + const r={dashboard:renderDashboard,users:renderUsers,channels:renderChannels,roles:renderRoles,emoji:renderEmoji,audit:renderAudit,tokens:renderTokens,plugins:renderPlugins,logs:renderLogs,settings:renderSettings,backups:renderBackups,updates:renderUpdates}; c.innerHTML='
Loading...
'; const fn=r[state.section]; if(typeof fn!=='function'){console.error('[Admin] No render function for section: '+state.section);c.innerHTML='
Error

Unknown section: '+esc(state.section)+'

';return} @@ -666,7 +767,9 @@ function renderContent(){ /* ═══ Dashboard ═══ */ async function renderDashboard(){ try{state.cachedStats=await api('GET','/stats')}catch(e){return'
Dashboard

Failed to load stats: '+esc(e.message)+'

'} - try{state.cachedUpdate=await api('GET','/updates')}catch(e){/* the banner is optional; the Updates page reports the failure */} + /* Update checks are owner-only; skip the call for everyone else instead of + spending a guaranteed 403 on every dashboard load. */ + if(isOwner()){try{state.cachedUpdate=await api('GET','/updates')}catch(e){/* the banner is optional; the Updates page reports the failure */}} const s=state.cachedStats;const u=state.cachedUpdate; let html='
Dashboard
Server overview and statistics
'; if(u&&u.update_available)html+='
'+I.updates+'
Update Available: '+esc(u.latest)+'
Current: '+esc(u.current)+' —
'; @@ -676,8 +779,8 @@ async function renderDashboard(){ html+='
Channels
'+I.channels+'
'+(s.channel_count||0)+'
active
'; html+='
Database
'+I.backup+'
'+fmtBytes(s.db_size_bytes||0)+'
SQLite
'; html+=''; - // Recent audit - try{ + // Recent audit — VIEW_AUDIT_LOG only. + if(can(PERM.VIEW_AUDIT_LOG))try{ const entries=await api('GET','/audit-log?limit=5&offset=0'); if(entries&&entries.length){ html+='

Recent Activity

'; @@ -703,7 +806,7 @@ async function renderUsers(){ const statusDot=banned?'banned':status;const statusLabel=banned?'Banned':status==='online'?'Online':'Offline'; const initial=uname?uname[0].toUpperCase():'?'; html+='
'+initial+'
'+esc(uname)+'
'; - html+=''+roleName(rid)+''; + html+=''+esc(roleName(rid,u.role_name||u.RoleName))+''; html+=''+statusLabel+''; // The ban reason is collected on ban and stored server-side; showing it // here is the only place an admin can read back why someone was banned. @@ -714,10 +817,12 @@ async function renderUsers(){ :'No'; html+=''+bannedCell+''; html+='
'; - html+=''; - html+=''; - if(banned)html+=''; - else html+=''; + if(can(PERM.MANAGE_ROLES))html+=''; + if(can(PERM.KICK_MEMBERS))html+=''; + if(can(PERM.BAN_MEMBERS)){ + if(banned)html+=''; + else html+=''; + } html+='
'; }); html+='
'; @@ -729,8 +834,26 @@ async function renderUsers(){ return html; } -function openEditUser(uid,uname,currentRole){ - openModal(''); +/* Seeded roles with their hierarchy positions — the fallback used only when the + live list cannot be read. Role CRUD means the real set is whatever /roles + returns, so assigning a custom role must not depend on this literal. */ +const ROLE_CHOICES=[{id:1,name:'Owner',position:100},{id:2,name:'Admin',position:80},{id:3,name:'Moderator',position:60},{id:4,name:'Member',position:40}]; + +/* The picker needs every assignable role, not the four seeded ones. The button + that opens this is gated on MANAGE_ROLES, which is exactly what GET /roles + requires, so the fetch is authorized whenever the modal is reachable; a + failure degrades to the seeded list rather than blocking the edit. */ +async function openEditUser(uid,uname,currentRole){ + const myPos=(state.me&&state.me.role_position)||0; + let roles; + try{roles=await api('GET','/roles');state.roleList=roles||[]} + catch(e){roles=ROLE_CHOICES} + /* The server refuses to assign a role positioned at or above the actor's + own, so anything higher is dropped rather than offered as a guaranteed + 403. The current role is always listed so the select can show it. */ + const opts=roles.filter(r=>r.position'').join(''); + openModal(''); } async function saveUserRole(uid){ @@ -752,11 +875,11 @@ async function unbanUser(uid){ } async function forceLogout(uid){ - openModal(''); + openModal(''); } async function confirmForceLogout(uid){ - try{await api('DELETE','/users/'+uid+'/sessions');closeModal();showToast('Sessions terminated');renderContent()}catch(e){showToast(e.message,'error')} + try{await api('DELETE','/users/'+uid+'/sessions');closeModal();showToast('Forced logout: all sessions terminated');renderContent()}catch(e){showToast(e.message,'error')} } /* ═══ Channels ═══ */ @@ -764,6 +887,10 @@ async function renderChannels(){ let channels; try{channels=await api('GET','/channels')}catch(e){return'
Channels

'+esc(e.message)+'

'} const chIcon=t=>t==='voice'?I.voice:t==='announcement'?I.megaphone:I.channels; + /* Categories are free text — a channel of any type may live under any one of + them. Collect the ones already in use so the create/edit forms can offer + them as a datalist instead of hardcoding names nobody has to use. */ + const catSet={}; let html='
Channels
'+channels.length+' channels
'; html+='
'; html+='
'; @@ -777,14 +904,25 @@ async function renderChannels(){ html+=''; const lockBtn=type==='dm'?'':''; state.channelCache[id]=ch; + if(cat)catSet[cat]=true; html+=''; }); html+='
ChannelTypeCategoryArchivedActions
'+(archived?'Yes':'No')+'
'+lockBtn+'
'; + state.channelCategories=Object.keys(catSet).sort(); return html; } +/* of the categories currently in use. Purely a suggestion list — + typing a brand-new name is the supported way to create a category. */ +function categoryDatalist(listId){ + const cats=state.channelCategories||[]; + let html=''; + cats.forEach(c=>{html+=''}); + return html+''; +} + function openChannelModal(){ - openModal(''); + openModal(''); } async function createChannel(){ @@ -793,23 +931,43 @@ async function createChannel(){ try{await api('POST','/channels',body);closeModal();showToast('Channel created');renderContent()}catch(e){showToast(e.message,'error')} } -/* PATCH /channels/{id} accepts name, topic, slow_mode, position and archived — - the modal used to offer only the name, so the Archived column in the table - was read-only state with no control behind it. */ +/* PATCH /channels/{id} accepts name, topic, category, slow_mode, position, + archived, nsfw and the two voice capacity limits — the modal used to offer + only the name, so the Archived column in the table was read-only state with + no control behind it. + + NSFW is a flag and nothing more: the server stores, broadcasts and audits it + but applies no content behaviour to a flagged channel. Clients decide what to + do with it (the desktop client shows a per-session age gate). + + The voice limits are only rendered for a voice channel. They are stored on + any type, but on a text channel they are values nothing will ever read, and + offering them there would imply an enforcement that does not exist. */ function openChannelEditModal(id){ const ch=state.channelCache[id]||{}; const name=ch.name||ch.Name||''; const topic=ch.topic||ch.Topic||''; + const cat=ch.category||ch.Category||''; const slow=ch.slow_mode||ch.SlowMode||0; const pos=ch.position||ch.Position||0; const archived=ch.archived||ch.Archived||false; + const nsfw=ch.nsfw||ch.NSFW||false; + const type=ch.type||ch.Type||'text'; + const maxUsers=ch.voice_max_users||ch.VoiceMaxUsers||0; + const maxVideo=ch.voice_max_video||ch.VoiceMaxVideo||0; + const voiceRows=type!=='voice'?'': + '
' + +'
'; openModal('' +'' +''); } @@ -820,10 +978,20 @@ async function saveChannelEdit(id){ const body={ name, topic:document.getElementById('chEditTopic').value.trim(), + category:document.getElementById('chEditCat').value.trim(), slow_mode:parseInt(document.getElementById('chEditSlow').value,10)||0, position:parseInt(document.getElementById('chEditPos').value,10)||0, archived:document.getElementById('chEditArchived').classList.contains('on'), + nsfw:document.getElementById('chEditNsfw').classList.contains('on'), }; + /* Only present for a voice channel. Omitting them entirely (rather than + sending 0) is what keeps a text-channel edit from clobbering limits a + channel might carry from an earlier life as a voice channel — the handler + starts from the stored values for every field the body leaves out. */ + const maxUsersEl=document.getElementById('chEditMaxUsers'); + const maxVideoEl=document.getElementById('chEditMaxVideo'); + if(maxUsersEl){body.voice_max_users=parseInt(maxUsersEl.value,10)||0} + if(maxVideoEl){body.voice_max_video=parseInt(maxVideoEl.value,10)||0} try{await api('PATCH','/channels/'+id,body);closeModal();showToast('Channel updated');renderContent()}catch(e){showToast(e.message,'error')} } @@ -835,47 +1003,379 @@ async function confirmDeleteChannel(id){ try{await api('DELETE','/channels/'+id);closeModal();showToast('Channel deleted');renderContent()}catch(e){showToast(e.message,'error')} } -/* ═══ Channel Access (private channels) ═══ */ +/* ═══ Channel permissions (override matrix) ═══ */ +/* Two editors over the same two endpoints, because they answer two different + questions. The quick "Can access" list is the 90% case — hide this channel + from a role — and still writes exactly the mask it always did. The matrix + below it is the honest one: pick a role OR a single member, then set each + relevant bit to allow / inherit / deny, which is what the API has always + accepted and what the resolution order (base -> role override -> user + override) actually resolves. */ const DENY_PRIVATE=0x202; /* READ_MESSAGES | CONNECT_VOICE */ const ADMIN_BIT=0x40000000; +/* The bits worth overriding PER CHANNEL. Server-wide bits (Manage Roles, Ban + Members, …) are deliberately absent: they answer to the server, not to one + channel, so offering them here would write masks nothing ever reads. */ +const OVERRIDE_BITS=[ + [0x2,'Read Messages'], + [0x1,'Send Messages'], + [0x20,'Attach Files'], + [0x40,'Add Reactions'], + [0x10000,'Manage Messages'], + [0x200000,'Mention @everyone'], + [0x200,'Connect'], + [0x400,'Speak'], + [0x800,'Video'], + [0x1000,'Share Screen'], +]; + +/* Tri-state per bit: 'allow' sets the bit in the allow mask, 'deny' sets it in + the deny mask, 'inherit' sets it in neither. An override row whose two masks + are both zero is deleted rather than stored — an all-inherit row is the same + thing as no row, and keeping it would leave phantom entries in the listing. */ +function overrideStateOf(allow,deny,bit){ + if((allow&bit)===bit)return 'allow'; + if((deny&bit)===bit)return 'deny'; + return 'inherit'; +} + async function openChannelPermsModal(id,name){ - let data; - try{data=await api('GET','/channels/'+id+'/permissions')}catch(e){showToast(e.message,'error');return} - const roles=data.roles||[]; - state.permChannelRoles=roles; - let rows=''; - roles.forEach(role=>{ + let data,users; + try{ + data=await api('GET','/channels/'+id+'/permissions'); + users=await api('GET','/users?limit=500&offset=0'); + }catch(e){showToast(e.message,'error');return} + state.permChannel={id:id,name:name,roles:data.roles||[],users:data.users||[],allUsers:users||[]}; + renderChannelPermsModal(); +} + +function renderChannelPermsModal(){ + const pc=state.permChannel;if(!pc)return; + let quick=''; + pc.roles.forEach(role=>{ const isAdmin=(role.permissions&ADMIN_BIT)!==0; const canAccess=isAdmin||((role.deny&0x2)===0); - rows+='
' + quick+='
' +''+esc(role.role_name)+'' +(isAdmin ?'always has access' :'') +'
'; }); - openModal('' - +'' - +''); + + let opts=''; + pc.roles.forEach(r=>{opts+=''}); + opts+=''; + /* The member list is paginated, so a member who already has an override could + fall outside the page and become uneditable. Union the two lists — the + override rows carry the username the picker needs. */ + const picked=[];const seen={}; + pc.users.forEach(o=>{seen[o.user_id]=true;picked.push({id:o.user_id,username:o.username,has:true})}); + pc.allUsers.forEach(u=>{if(!seen[u.id])picked.push({id:u.id,username:u.username,has:false})}); + picked.sort((a,b)=>String(a.username).localeCompare(String(b.username))); + picked.forEach(u=>{ + opts+=''; + }); + opts+=''; + + openModal('' + +'' + +''); + renderPermMatrix(); } -async function saveChannelPerms(id){ - const roles=state.permChannelRoles||[]; +/* Reads the current masks for the selected target and paints one tri-state row + per bit. A member with no override row starts all-inherit. */ +function renderPermMatrix(){ + const pc=state.permChannel;if(!pc)return; + const box=document.getElementById('permMatrix');if(!box)return; + const sel=document.getElementById('permTarget'); + const val=sel?sel.value:''; + if(!val){box.innerHTML='

Pick a role or member above to edit its per-channel bits.

';return} + const kind=val.charAt(0),tid=parseInt(val.slice(2),10); + let allow=0,deny=0,adminNote=''; + if(kind==='r'){ + const role=pc.roles.find(r=>r.role_id===tid); + if(role){allow=role.allow;deny=role.deny;if((role.permissions&ADMIN_BIT)!==0)adminNote='This role holds Administrator — every override below is bypassed.'} + }else{ + const o=pc.users.find(u=>u.user_id===tid); + if(o){allow=o.allow;deny=o.deny} + } + let html=''; + if(adminNote)html+='

'+esc(adminNote)+'

'; + html+=''; + OVERRIDE_BITS.forEach(b=>{ + const bit=b[0],label=b[1],st=overrideStateOf(allow,deny,bit); + html+=''; + ['allow','inherit','deny'].forEach(k=>{ + html+=''; + }); + html+=''; + }); + html+='
PermissionAllowInheritDeny
'+esc(label)+'
'; + html+='
'; + box.innerHTML=html; +} + +/* Collects the tri-state rows back into the two masks the API takes. */ +function collectOverrideMasks(){ + let allow=0,deny=0; + document.querySelectorAll('#permMatrix input[data-ovrbit]:checked').forEach(el=>{ + const bit=parseInt(el.getAttribute('data-ovrbit'),10); + if(el.value==='allow')allow|=bit; + else if(el.value==='deny')deny|=bit; + }); + return {allow:allow,deny:deny}; +} + +function permTargetPath(){ + const pc=state.permChannel; + const sel=document.getElementById('permTarget'); + const val=sel?sel.value:''; + if(!pc||!val)return null; + const kind=val.charAt(0),tid=parseInt(val.slice(2),10); + return '/channels/'+pc.id+(kind==='r'?'/permissions/':'/user-permissions/')+tid; +} + +async function clearPermOverride(){ + const path=permTargetPath(); + if(!path){showToast('Pick a role or member first','error');return} try{ - for(const role of roles){ + await api('DELETE',path); + closeModal();showToast('Override cleared');renderContent(); + }catch(e){showToast(e.message,'error')} +} + +async function saveChannelPerms(){ + const pc=state.permChannel;if(!pc)return; + try{ + /* Quick toggles first: same masks this panel has always written. */ + for(const role of pc.roles){ if((role.permissions&ADMIN_BIT)!==0)continue; const box=document.getElementById('permRole'+role.role_id); if(!box)continue; const wasHidden=(role.deny&0x2)!==0; - if(!box.checked)await api('PUT','/channels/'+id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE}); - else if(wasHidden)await api('DELETE','/channels/'+id+'/permissions/'+role.role_id); + if(!box.checked)await api('PUT','/channels/'+pc.id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE}); + else if(wasHidden)await api('DELETE','/channels/'+pc.id+'/permissions/'+role.role_id); } - closeModal();showToast('Channel access updated');renderContent(); + /* Then the matrix, if a target is selected. An all-inherit row is a delete: + storing (0,0) would leave a row that resolves to nothing. */ + const path=permTargetPath(); + if(path){ + const masks=collectOverrideMasks(); + if(masks.allow===0&&masks.deny===0)await api('DELETE',path); + else await api('PUT',path,masks); + } + closeModal();showToast('Channel permissions updated');renderContent(); }catch(e){showToast(e.message,'error')} } +/* ═══ Roles ═══ */ +/* Roles are real CRUD now, not four seeded rows. Everything here is gated on + MANAGE_ROLES, and the server additionally enforces the hierarchy: you may + only touch roles strictly BELOW your own position, and may never grant a bit + your own role lacks. The UI mirrors both rules so a doomed request is not + offered — but the server is the authority, and a 403 surfaces as a toast. */ + +/* Permission checkboxes, grouped exactly as docs/schema.md's "Permission + groups" section groups the bitfield. Keep the two in step: the doc is the + reference an operator reads next to this grid, and every one of the 19 + defined bits must appear in exactly one group or it becomes ungrantable + here. */ +const PERM_GROUPS=[ + {title:'General',bits:[ + [0x20000,'Manage Channels','Create, edit and delete channels and their overrides'], + [0x1000000,'Manage Roles','Create, edit, delete and assign roles below your own'], + [0x4000000,'Manage Invites','Create and revoke invite codes'], + [0x2000000,'Manage Server','Read and change server settings'], + [0x8000000,'View Audit Log','Read the action history'], + [0x40000000,'Administrator','Bypasses every permission check'], + ]}, + {title:'Text',bits:[ + [0x2,'Read Messages','View messages in text channels'], + [0x1,'Send Messages','Post messages in text channels'], + [0x20,'Attach Files','Upload file attachments'], + [0x40,'Add Reactions','React to messages with emoji'], + [0x200000,'Mention @everyone','Give @everyone/@here real mention semantics'], + [0x10000,'Manage Messages','Delete others’ messages, pin and purge'], + ]}, + {title:'Voice',bits:[ + [0x200,'Connect','Join voice channels'], + [0x400,'Speak','Transmit audio in voice channels'], + [0x800,'Video','Enable the camera in voice channels'], + [0x1000,'Share Screen','Share the screen in voice channels'], + ]}, + {title:'Moderation',bits:[ + [0x40000,'Kick Members','Force-logout a lower-ranked member'], + [0x80000,'Ban Members','Ban and unban lower-ranked members'], + [0x100000,'Mute Members','Server mute, deafen, move and disconnect in voice'], + ]}, +]; + +/* My own position, from GET /me — the hierarchy boundary every row respects. */ +function myPosition(){return (state.me&&state.me.role_position)||0} +/* True when the signed-in principal may manage this role at all. */ +function canManageRole(role){return role.positionRoles

'+esc(e.message)+'

'} + state.roleList=roles||[]; + let html='
Roles
'+state.roleList.length+' roles, highest rank first. You can only manage roles below your own.
'; + html+='
'; + html+='
'; + if(!state.roleList.length)html+=''; + /* Only the manageable slice can be reordered — the reorder endpoint takes + exactly the roles below the caller, so the arrows move within that slice. */ + const movable=state.roleList.filter(canManageRole); + state.roleList.forEach(role=>{ + const mine=canManageRole(role); + const mIdx=movable.findIndex(r=>r.id===role.id); + const swatch=''; + html+=''; + html+=''; + html+=''; + html+=''; + }); + html+='
RoleMembersPositionActions
No roles
'+swatch+''+esc(role.name)+''; + if(role.is_default)html+='default'; + if(!mine)html+='above you'; + html+='
'+(role.member_count||0)+''+role.position+'
'; + if(mine){ + const upDisabled=mIdx<=0?'disabled style="opacity:.3"':''; + const downDisabled=(mIdx<0||mIdx>=movable.length-1)?'disabled style="opacity:.3"':''; + html+=''; + html+=''; + html+=''; + if(role.is_default)html+=''; + else html+=''; + }else{ + html+='read-only'; + } + html+='
'; + return html; +} + +/* Swap a role with its neighbour and send the whole manageable order. The + endpoint normalizes positions, so the client never computes them. */ +async function moveRole(id,delta){ + const movable=state.roleList.filter(canManageRole); + const i=movable.findIndex(r=>r.id===id); + const j=i+delta; + if(i<0||j<0||j>=movable.length)return; + const ids=movable.map(r=>r.id); + ids[i]=movable[j].id;ids[j]=movable[i].id; + try{await api('PATCH','/roles/reorder',{role_ids:ids});showToast('Roles reordered');renderContent()} + catch(e){showToast(e.message,'error')} +} + +/* Shared create/edit modal. id === null creates. */ +function openRoleModal(id){ + const role=id===null?null:state.roleList.find(r=>r.id===id); + if(id!==null&&!role){showToast('Role not found','error');return} + const name=role?role.name:''; + const color=(role&&role.color)?role.color:''; + const perms=role?role.permissions:0; + /* A new role defaults to just below the caller, which is what the server + does for an omitted position — shown so the number is never a surprise. */ + const position=role?role.position:Math.max(0,myPosition()-1); + + let grid=''; + PERM_GROUPS.forEach(g=>{ + grid+='
'+esc(g.title)+'
'; + g.bits.forEach(b=>{ + const bit=b[0],label=b[1],desc=b[2]; + const granted=(perms&bit)===bit; + /* A bit the caller does not hold can only be left as it is: checked and + locked when the role already has it (removing is a de-escalation the + server allows, but the panel keeps the rule to one sentence), unchecked + and locked otherwise. */ + const locked=!canGrantBit(bit); + const title=locked?'Your own role does not have this permission':desc; + grid+=''; + }); + grid+='
'; + }); + + openModal('' + +'' + +''); +} + +/* Collect the checked bits. Disabled boxes still report their state, so a bit + the caller cannot grant is preserved rather than silently stripped. */ +function collectRolePerms(){ + let mask=0; + document.querySelectorAll('#modalInner input[data-permbit]').forEach(box=>{ + if(box.checked)mask|=parseInt(box.getAttribute('data-permbit'),10); + }); + return mask; +} + +async function saveRole(id){ + const name=document.getElementById('roleName').value.trim(); + if(!name){showToast('Name is required','error');return} + const noColor=document.getElementById('roleNoColor').checked; + const body={ + name:name, + color:noColor?'':document.getElementById('roleColor').value, + permissions:collectRolePerms(), + position:parseInt(document.getElementById('rolePos').value,10)||0, + }; + try{ + if(id===null)await api('POST','/roles',body); + else await api('PATCH','/roles/'+id,body); + closeModal();showToast(id===null?'Role created':'Role updated');renderContent(); + }catch(e){showToast(e.message,'error')} +} + +function openDeleteRole(id){ + const role=state.roleList.find(r=>r.id===id); + if(!role){showToast('Role not found','error');return} + const fallback=state.roleList.find(r=>r.is_default); + const fallbackName=fallback?fallback.name:'the default role'; + const count=role.member_count||0; + const members=count===0 + ?'No members hold this role.' + :''+count+' member'+(count===1?'':'s')+' will be moved to '+esc(fallbackName)+'.'; + openModal('' + +'' + +''); +} + +async function confirmDeleteRole(id){ + try{await api('DELETE','/roles/'+id);closeModal();showToast('Role deleted');renderContent()} + catch(e){showToast(e.message,'error')} +} + /* ═══ Audit Log ═══ */ async function renderAudit(){ const offset=(state.auditPage-1)*PAGE_SIZE; @@ -1209,6 +1709,98 @@ async function revokeToken(id){ try{await api('DELETE','/tokens/'+id);closeModal();showToast('Token revoked');renderContent()}catch(e){showToast(e.message,'error')} } +/* ═══ Emoji ═══ */ +/* Custom emoji live on the ordinary member API (/api/v1/emoji) rather than + under /admin/api: the desktop client reads the same list, and MANAGE_SERVER + is enforced by the route itself. The panel's session token authenticates + there unchanged, so this needs its own fetch helper — like pluginApi. */ +async function emojiApi(method,path,opts){ + const init={method,headers:{'Authorization':'Bearer '+state.token}}; + if(opts&&opts.body!==undefined)init.body=opts.body; + const res=await fetch('/api/v1/emoji'+path,init); + if(res.status===401){handleSessionExpired();throw new Error('Your session expired — sign in again.')} + if(res.status===204)return null; + const text=await res.text(); + let data=null; + if(text){try{data=JSON.parse(text)}catch(e){data=null}} + if(!res.ok)throw new Error((data&&(data.message||data.error))||text.trim()||res.statusText); + return data; +} + +/* The image route needs the Authorization header, which cannot send. + Each thumbnail is therefore fetched with the token and swapped in as a blob: + URL once the section has been written into the DOM. */ +async function loadEmojiThumbnails(){ + const imgs=document.querySelectorAll('img[data-emoji-url]'); + for(const img of imgs){ + try{ + const res=await fetch(img.getAttribute('data-emoji-url'),{headers:{'Authorization':'Bearer '+state.token}}); + if(!res.ok)continue; + const blob=await res.blob(); + img.src=URL.createObjectURL(blob); + img.addEventListener('load',()=>URL.revokeObjectURL(img.src),{once:true}); + }catch(e){/* a thumbnail that will not load is not worth an error toast */} + } +} + +async function renderEmoji(){ + let list; + try{list=await emojiApi('GET','/')}catch(e){return'
Emoji

'+esc(e.message)+'

'} + if(!Array.isArray(list))list=[]; + + let html='
Emoji
Server-wide custom emoji, usable as :shortcode: in messages and reactions
'; + html+='

Upload

'; + html+='
PNG, JPEG, GIF or WebP. Up to 512 KB and 128×128 pixels. Shortcodes are 2-32 characters of a-z, 0-9 or underscore.
'; + html+='
'; + html+=''; + html+=''; + html+=''; + html+='
'; + + html+='

Installed ('+list.length+')

'; + html+=''; + if(!list.length)html+=''; + else list.forEach(function(e){ + html+=''; + html+=''; + html+=''; + }); + html+='
PreviewShortcodeActions
No custom emoji yet
'+esc(e.shortcode)+':'+esc(e.shortcode)+':
'; + setTimeout(loadEmojiThumbnails,0); + return html; +} + +async function uploadEmoji(){ + const codeInput=document.getElementById('emojiShortcode'); + const fileInput=document.getElementById('emojiFile'); + const shortcode=(codeInput&&codeInput.value||'').trim(); + const file=fileInput&&fileInput.files&&fileInput.files[0]; + if(!shortcode){showToast('Enter a shortcode first','error');return} + if(!file){showToast('Choose an image first','error');return} + const fd=new FormData(); + fd.append('shortcode',shortcode); + fd.append('file',file); + try{ + /* No explicit Content-Type: the browser must set the multipart boundary. */ + await emojiApi('POST','/',{body:fd}); + showToast('Added :'+shortcode.toLowerCase()+':'); + renderContent(); + }catch(e){showToast(e.message,'error')} +} + +function confirmDeleteEmoji(id,shortcode){ + openModal(''); +} + +async function deleteEmoji(id){ + try{ + await emojiApi('DELETE','/'+id); + closeModal(); + showToast('Emoji deleted'); + renderContent(); + }catch(e){showToast(e.message,'error')} +} + /* ═══ Plugins ═══ */ /* The plugin lifecycle API lives under /api/v1/admin/plugins (same admin auth and IP gate, different prefix), so it needs its own fetch helper rather than diff --git a/Server/admin/types.go b/Server/admin/types.go index 0f1b7ec2..befd0329 100644 --- a/Server/admin/types.go +++ b/Server/admin/types.go @@ -14,6 +14,10 @@ type adminContextKey int const ( // adminUserKey is the context key for the authenticated *db.User. adminUserKey adminContextKey = iota + // adminRoleKey is the context key for the authenticated principal's + // *db.Role. Set by adminAuthMiddleware so requirePerm and the /me handler + // need no second query. + adminRoleKey // adminSessionKey is the context key for the authenticated *db.Session. adminSessionKey // adminTokenHashKey is the context key for the hash (string) of the bearer @@ -52,6 +56,16 @@ type HubBroadcaster interface { // messages after a channel permission override change so each connected // client's sidebar reflects its new visibility without a reconnect. RefreshChannelVisibility(ch *db.Channel) + // RefreshAllChannelVisibility is RefreshChannelVisibility across every + // non-DM channel. A role's permission mask is the base every channel's + // effective permission derives from, so a role edit can change visibility + // of all of them at once — unlike a channel_overrides edit, which touches + // exactly one. + RefreshAllChannelVisibility() + // BroadcastRolesUpdate ships the full role list to every connected client + // after a role mutation, so name colors and permission-gated affordances + // converge without a reconnect. + BroadcastRolesUpdate(roles []*db.Role) ClientCount() int } @@ -82,6 +96,23 @@ type adminUserResponse struct { BanExpires *string `json:"ban_expires,omitempty"` } +// ─── adminMeResponse ──────────────────────────────────────────────────────── + +// adminMeResponse describes the calling principal to the admin panel so it can +// hide the surfaces the principal's role cannot use. It is an affordance hint +// only — every route re-checks the bit server-side. +type adminMeResponse struct { + ID int64 `json:"id"` + Username string `json:"username"` + RoleID int64 `json:"role_id"` + RoleName string `json:"role_name"` + RolePosition int `json:"role_position"` + Permissions int64 `json:"permissions"` + // IsOwner mirrors ownerOnlyMiddleware: owner-only routes gate on position, + // not on a permission bit, so the panel cannot derive this from the mask. + IsOwner bool `json:"is_owner"` +} + // toAdminUserResponse converts a db.UserWithRole to the safe response shape. func toAdminUserResponse(u db.UserWithRole) adminUserResponse { return adminUserResponse{ diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index fa8b5710..c3ef2006 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -35,7 +35,7 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -74,7 +74,7 @@ func TestAdminAPI_CheckUpdate_IncompleteReleaseNotInstallable(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -107,7 +107,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -124,7 +124,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) { func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodGet, "/updates", "", nil) if w.Code != http.StatusUnauthorized { @@ -134,7 +134,7 @@ func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) { func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) // Create admin user (not owner - role 2) adminUID, _ := database.CreateUser(context.Background(), "adminonly2", "hash", 2) @@ -154,7 +154,7 @@ func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) { 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, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -167,7 +167,7 @@ func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) { // 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, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -199,7 +199,7 @@ func TestAdminAPI_ApplyUpdate_NoUpdateAvailable(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -230,7 +230,7 @@ func TestAdminAPI_ApplyUpdate_CheckFails(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -258,7 +258,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) { u.SetBaseURL(mockGH.URL) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -279,7 +279,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) { // 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, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database)) w := doRequest(t, handler, http.MethodPost, "/updates/apply", "", nil) if w.Code != http.StatusUnauthorized { @@ -346,7 +346,7 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) { // the important thing is that the code path is executed. database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index 74dd3f65..17580273 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -42,9 +42,17 @@ type loginRequest struct { // 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"` + ID int64 `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar,omitempty"` + // DisplayName and About are always present (null = unset) so the settings + // form can tell "cleared" from "the server does not know this field". + DisplayName *string `json:"display_name"` + About *string `json:"about"` + // CustomStatus is the user's own free-text status line. + CustomStatus *string `json:"custom_status"` + // Status is the user's own true status, invisible included. This response + // only ever describes the caller, so there is nothing to hide from them. Status string `json:"status"` RoleID int64 `json:"role_id"` TOTPEnabled bool `json:"totp_enabled"` @@ -466,6 +474,14 @@ func handleLogout(database *db.DB) http.HandlerFunc { return } + // A custom status is a "what I am doing right now" note. Leaving it + // standing after the user signed out states something about them that + // is no longer true, so logout clears it — unlike the chosen presence + // status, which is a preference and deliberately survives. + if err := database.UpdateUserCustomStatus(context.WithoutCancel(r.Context()), sess.UserID, nil); err != nil { + slog.Warn("failed to clear custom status on logout", "user_id", sess.UserID, "err", err) + } + slog.Info("user logged out", "user_id", sess.UserID) db.WriteAudit(context.WithoutCancel(r.Context()), database, sess.UserID, "user_logout", "user", sess.UserID, "") @@ -580,13 +596,16 @@ func toUserResponse(u *db.User) *userResponse { avatar = *u.Avatar } resp := &userResponse{ - ID: u.ID, - Username: u.Username, - Avatar: avatar, - Status: u.Status, - RoleID: u.RoleID, - TOTPEnabled: u.TOTPSecret != nil, - CreatedAt: u.CreatedAt, + ID: u.ID, + Username: u.Username, + Avatar: avatar, + DisplayName: u.DisplayName, + About: u.About, + CustomStatus: u.CustomStatus, + Status: u.Status, + RoleID: u.RoleID, + TOTPEnabled: u.TOTPSecret != nil, + CreatedAt: u.CreatedAt, } return resp } diff --git a/Server/api/avatar_handler_test.go b/Server/api/avatar_handler_test.go new file mode 100644 index 00000000..fb637830 --- /dev/null +++ b/Server/api/avatar_handler_test.go @@ -0,0 +1,327 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/service" + "github.com/owncord/server/storage" + "github.com/owncord/server/ws" +) + +// Avatar upload. The interesting properties are the ones a URL-only avatar +// field never had to answer: what the server accepts as an image, where the +// bytes end up, and — the one that would otherwise be a privacy bug — who is +// allowed to fetch them back. + +// buildAvatarRouter mounts the profile routes (with storage, so the upload +// route exists) and the upload routes (so the file can be fetched back) +// against one database. +func buildAvatarRouter(database *db.DB, store *storage.Storage) http.Handler { + r := chi.NewRouter() + limiter := auth.NewRateLimiter() + svc := service.New(database, limiter) + api.MountProfileRoutes(r, database, svc, store, limiter, nil, nil) + api.MountUploadRoutes(r, database, store, limiter, nil, svc.Permissions) + return r +} + +func doAvatarUpload(t *testing.T, router http.Handler, token, filename string, content []byte) *httptest.ResponseRecorder { + t.Helper() + body, contentType := makeMultipartFile(t, "file", filename, content) + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/avatar", body) + req.Header.Set("Content-Type", contentType) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +func TestUploadAvatar_StoresAndSetsAvatarURL(t *testing.T) { + database := newUploadTestDB(t) + store := newUploadTestStorage(t) + router := buildAvatarRouter(database, store) + token := uploadCreateToken(t, database, "pfp_owner", 4) + + rr := doAvatarUpload(t, router, token, "me.png", makePNGBytes(t, 64, 64)) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body = %s", rr.Code, rr.Body.String()) + } + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + id, _ := resp["id"].(string) + if id == "" { + t.Fatal("response carried no file id") + } + if resp["url"] != service.AvatarFileURL(id) { + t.Errorf("url = %v, want %q", resp["url"], service.AvatarFileURL(id)) + } + if resp["mime"] != "image/png" { + t.Errorf("mime = %v, want image/png", resp["mime"]) + } + + // The column must now point at the served file — that is what makes the + // avatar both renderable and readable. + user, err := database.GetUserByUsername(context.Background(), "pfp_owner") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if user.Avatar == nil || *user.Avatar != service.AvatarFileURL(id) { + t.Fatalf("stored avatar = %v, want %q", user.Avatar, service.AvatarFileURL(id)) + } +} + +func TestUploadAvatar_IsReadableByOtherUsersWhileInUse(t *testing.T) { + database := newUploadTestDB(t) + store := newUploadTestStorage(t) + router := buildAvatarRouter(database, store) + ownerToken := uploadCreateToken(t, database, "avatar_owner", 4) + otherToken := uploadCreateToken(t, database, "avatar_peer", 4) + + rr := doAvatarUpload(t, router, ownerToken, "me.png", makePNGBytes(t, 32, 32)) + if rr.Code != http.StatusCreated { + t.Fatalf("upload status = %d; body = %s", rr.Code, rr.Body.String()) + } + var resp map[string]any + _ = json.NewDecoder(rr.Body).Decode(&resp) + id, _ := resp["id"].(string) + + // An unlinked attachment is normally uploader-only. An avatar has to be + // visible to the people who see the messages it sits beside. + if got := doServeFile(t, router, id, otherToken, nil); got.Code != http.StatusOK { + t.Fatalf("peer fetch status = %d, want 200; body = %s", got.Code, got.Body.String()) + } + + // Replacing the avatar revokes that: the old file goes back to being a + // private unlinked attachment. + rr2 := doAvatarUpload(t, router, ownerToken, "me2.png", makePNGBytes(t, 33, 33)) + if rr2.Code != http.StatusCreated { + t.Fatalf("second upload status = %d; body = %s", rr2.Code, rr2.Body.String()) + } + if got := doServeFile(t, router, id, otherToken, nil); got.Code != http.StatusForbidden { + t.Errorf("peer fetch of replaced avatar = %d, want 403", got.Code) + } + // The uploader can still reach their own old file. + if got := doServeFile(t, router, id, ownerToken, nil); got.Code != http.StatusOK { + t.Errorf("uploader fetch of replaced avatar = %d, want 200", got.Code) + } +} + +func TestUploadAvatar_RejectsNonImageAndOversizedDimensions(t *testing.T) { + database := newUploadTestDB(t) + store := newUploadTestStorage(t) + router := buildAvatarRouter(database, store) + token := uploadCreateToken(t, database, "avatar_bad", 4) + + // Sniffed from the bytes, never from the filename or the client's header. + if rr := doAvatarUpload(t, router, token, "me.png", []byte("this is plain text, not a PNG")); rr.Code != http.StatusBadRequest { + t.Errorf("text-as-png status = %d, want 400", rr.Code) + } + // GIF is a real image and still refused: an animated avatar in every + // message row is a distraction the renderer cannot opt out of. + gif := []byte("GIF89a") + if rr := doAvatarUpload(t, router, token, "me.gif", gif); rr.Code != http.StatusBadRequest { + t.Errorf("gif status = %d, want 400", rr.Code) + } + // Too many pixels for any surface that renders it. + if rr := doAvatarUpload(t, router, token, "huge.png", makePNGBytes(t, 2000, 100)); rr.Code != http.StatusBadRequest { + t.Errorf("oversized status = %d, want 400", rr.Code) + } + + // None of the rejections may have moved the column. + user, _ := database.GetUserByUsername(context.Background(), "avatar_bad") + if user != nil && user.Avatar != nil && *user.Avatar != "" { + t.Errorf("a rejected upload set the avatar to %q", *user.Avatar) + } +} + +func TestUploadAvatar_RequiresAuthAndAFile(t *testing.T) { + database := newUploadTestDB(t) + store := newUploadTestStorage(t) + router := buildAvatarRouter(database, store) + token := uploadCreateToken(t, database, "avatar_auth", 4) + + if rr := doAvatarUpload(t, router, "", "me.png", makePNGBytes(t, 8, 8)); rr.Code != http.StatusUnauthorized { + t.Errorf("anonymous status = %d, want 401", rr.Code) + } + // Right form, wrong field name. + body, contentType := makeMultipartFile(t, "avatar", "me.png", makePNGBytes(t, 8, 8)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/avatar", body) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("wrong field status = %d, want 400", rr.Code) + } +} + +func TestUploadAvatar_NotMountedWithoutStorage(t *testing.T) { + database := newUploadTestDB(t) + r := chi.NewRouter() + limiter := auth.NewRateLimiter() + api.MountProfileRoutes(r, database, service.New(database, limiter), nil, limiter, nil, nil) + token := uploadCreateToken(t, database, "no_storage", 4) + + if rr := doAvatarUpload(t, r, token, "me.png", makePNGBytes(t, 8, 8)); rr.Code == http.StatusCreated { + t.Error("avatar upload must not be served when there is no storage backend") + } +} + +// ─── PATCH /users/me: display name and about ───────────────────────────────── + +func TestUpdateProfile_SetsDisplayNameAndAbout(t *testing.T) { + database := newAuthTestDB(t) + router := buildProfileRouter(database) + token := profileCreateToken(t, database, "bio_user", 4) + + rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]any{ + "username": "bio_user", + "display_name": "Bio User", + "about": "writes tests", + }) + 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) + if resp["display_name"] != "Bio User" { + t.Errorf("display_name = %v", resp["display_name"]) + } + if resp["about"] != "writes tests" { + t.Errorf("about = %v", resp["about"]) + } + + // Omitting them leaves them alone. + rr = patchJSON(t, router, "/api/v1/users/me", token, map[string]any{"username": "bio_user"}) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + resp = map[string]any{} + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp["display_name"] != "Bio User" || resp["about"] != "writes tests" { + t.Errorf("a username-only PATCH cleared fields: %v / %v", resp["display_name"], resp["about"]) + } + + // An explicit empty string clears them. + rr = patchJSON(t, router, "/api/v1/users/me", token, map[string]any{ + "username": "bio_user", "display_name": "", "about": "", + }) + resp = map[string]any{} + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp["display_name"] != nil || resp["about"] != nil { + t.Errorf("expected cleared, got %v / %v", resp["display_name"], resp["about"]) + } +} + +func TestUpdateProfile_RejectsBadDisplayName(t *testing.T) { + database := newAuthTestDB(t) + router := buildProfileRouter(database) + token := profileCreateToken(t, database, "dn_user", 4) + + // A right-to-left override makes a name render as something other than + // what it says — the same spoof auth.ValidateUsername rejects. + rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]any{ + "username": "dn_user", "display_name": "ada\u202egnp.exe", + }) + if rr.Code != http.StatusBadRequest { + t.Errorf("bidi-override display_name status = %d, want 400", rr.Code) + } + + rr = patchJSON(t, router, "/api/v1/users/me", token, map[string]any{ + "username": "dn_user", "display_name": strings.Repeat("a", 33), + }) + if rr.Code != http.StatusBadRequest { + t.Errorf("overlong display_name status = %d, want 400", rr.Code) + } + + rr = patchJSON(t, router, "/api/v1/users/me", token, map[string]any{ + "username": "dn_user", "about": strings.Repeat("b", 301), + }) + if rr.Code != http.StatusBadRequest { + t.Errorf("overlong about status = %d, want 400", rr.Code) + } +} + +func TestUpdateProfile_BroadcastCarriesEveryProfileField(t *testing.T) { + database := newAuthTestDB(t) + r := chi.NewRouter() + limiter := auth.NewRateLimiter() + spy := &userUpdateSpy{} + api.MountProfileRoutes(r, database, service.New(database, limiter), nil, limiter, nil, spy) + token := profileCreateToken(t, database, "bc_user", 4) + + rr := patchJSON(t, r, "/api/v1/users/me", token, map[string]any{ + "username": "bc_user", "display_name": "Broadcaster", "about": "hi", + }) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d; body = %s", rr.Code, rr.Body.String()) + } + if len(spy.got) != 1 { + t.Fatalf("broadcasts = %d, want 1", len(spy.got)) + } + u := spy.got[0] + // user_update replaces the client's copy wholesale, so a broadcast that + // omits a field would silently blank it everywhere. + if u.DisplayName == nil || *u.DisplayName != "Broadcaster" { + t.Errorf("broadcast display_name = %v", u.DisplayName) + } + if u.About == nil || *u.About != "hi" { + t.Errorf("broadcast about = %v", u.About) + } + if u.Username != "bc_user" { + t.Errorf("broadcast username = %q", u.Username) + } +} + +func TestLogout_ClearsCustomStatus(t *testing.T) { + database := newAuthTestDB(t) + router := buildAuthRouter(database, auth.NewRateLimiter()) + token := profileCreateToken(t, database, "logout_status", 4) + + user, err := database.GetUserByUsername(context.Background(), "logout_status") + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + text := "in a meeting" + if err := database.UpdateUserCustomStatus(context.Background(), user.ID, &text); err != nil { + t.Fatalf("UpdateUserCustomStatus: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", bytes.NewReader(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.StatusNoContent { + t.Fatalf("logout status = %d, want 204; body = %s", rr.Code, rr.Body.String()) + } + + after, _ := database.GetUserByID(context.Background(), user.ID) + if after.CustomStatus != nil { + t.Errorf("custom_status = %q, want cleared on logout", *after.CustomStatus) + } +} + +// userUpdateSpy captures the user_update broadcasts the profile routes emit. +type userUpdateSpy struct { + got []ws.UserUpdate +} + +func (s *userUpdateSpy) BroadcastUserUpdate(u ws.UserUpdate) { + s.got = append(s.got, u) +} diff --git a/Server/api/channel_around_test.go b/Server/api/channel_around_test.go new file mode 100644 index 00000000..fd927383 --- /dev/null +++ b/Server/api/channel_around_test.go @@ -0,0 +1,399 @@ +package api_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/owncord/server/db" +) + +// ─── GET /api/v1/channels/{id}/messages/around/{messageId} ─────────────────── +// +// The around window is what turns a "jump to this message" affordance (search +// hit, pinned entry, reply reference, permalink) into something that works for +// a message outside the client's loaded history. Its contract has three parts +// worth pinning: the same read gate as history, a window actually centred on +// the target, and honest has-more flags at the edges of a channel. + +type aroundResponse struct { + Messages []struct { + ID int64 `json:"id"` + Content string `json:"content"` + } `json:"messages"` + HasMoreBefore bool `json:"has_more_before"` + HasMoreAfter bool `json:"has_more_after"` +} + +func aroundPath(channelID, messageID int64, query string) string { + p := fmt.Sprintf("/api/v1/channels/%d/messages/around/%d", channelID, messageID) + if query != "" { + p += "?" + query + } + return p +} + +// seedAroundChannel creates a channel owned by username and fills it with n +// messages, returning the channel id and the message ids in ascending order. +func seedAroundChannel(t *testing.T, database *db.DB, username string, n int) (int64, []int64) { + t.Helper() + user, err := database.GetUserByUsername(context.Background(), username) + if err != nil { + t.Fatalf("GetUserByUsername(%q): %v", username, err) + } + chID, err := database.CreateChannel(context.Background(), "around-"+username, "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + ids := make([]int64, 0, n) + for i := range n { + id, msgErr := database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("m%d", i), nil) + if msgErr != nil { + t.Fatalf("CreateMessage %d: %v", i, msgErr) + } + ids = append(ids, id) + } + return chID, ids +} + +func decodeAround(t *testing.T, rr *httptest.ResponseRecorder) aroundResponse { + t.Helper() + var resp aroundResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode around response: %v (body: %s)", err, rr.Body.String()) + } + return resp +} + +func TestMessagesAround_Unauthenticated(t *testing.T) { + router := buildChannelRouter(newChannelTestDB(t)) + rr := chGet(t, router, aroundPath(1, 1, ""), "") + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +func TestMessagesAround_InvalidMessageID(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "aroundbadid", 1) + chID, _ := seedAroundChannel(t, database, "aroundbadid", 1) + + for _, raw := range []string{"abc", "0", "-3"} { + path := fmt.Sprintf("/api/v1/channels/%d/messages/around/%s", chID, raw) + rr := chGet(t, router, path, token) + if rr.Code != http.StatusBadRequest { + t.Errorf("message id %q: status = %d, want 400; body: %s", raw, rr.Code, rr.Body.String()) + } + } +} + +func TestMessagesAround_InvalidLimit(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "aroundbadlimit", 1) + chID, ids := seedAroundChannel(t, database, "aroundbadlimit", 3) + + rr := chGet(t, router, aroundPath(chID, ids[1], "limit=abc"), token) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestMessagesAround_ChannelNotFound(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "aroundnochan", 1) + + rr := chGet(t, router, aroundPath(9999, 1, ""), token) + if rr.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestMessagesAround_MessageInAnotherChannel(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "aroundcross", 1) + _, idsA := seedAroundChannel(t, database, "aroundcross", 2) + chB, _ := seedAroundChannel(t, database, "aroundcross", 2) + + rr := chGet(t, router, aroundPath(chB, idsA[0], ""), token) + if rr.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestMessagesAround_DeletedMessageIsNotFound(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "arounddeleted", 1) + user, _ := database.GetUserByUsername(context.Background(), "arounddeleted") + chID, ids := seedAroundChannel(t, database, "arounddeleted", 3) + + if err := database.DeleteMessage(context.Background(), ids[1], user.ID, false); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + + rr := chGet(t, router, aroundPath(chID, ids[1], ""), token) + if rr.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestMessagesAround_CentersTheWindow(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "aroundcenter", 1) + chID, ids := seedAroundChannel(t, database, "aroundcenter", 40) + + target := ids[20] + rr := chGet(t, router, aroundPath(chID, target, "limit=10"), token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + resp := decodeAround(t, rr) + + if len(resp.Messages) != 10 { + t.Fatalf("window size = %d, want 10", len(resp.Messages)) + } + // limit 10 → 5 older, the centre, 4 newer. + if resp.Messages[5].ID != target { + t.Errorf("centre at index 5 = %d, want %d", resp.Messages[5].ID, target) + } + for i := 1; i < len(resp.Messages); i++ { + if resp.Messages[i-1].ID >= resp.Messages[i].ID { + t.Fatalf("window is not ascending at index %d: %v then %v", + i, resp.Messages[i-1].ID, resp.Messages[i].ID) + } + } + if !resp.HasMoreBefore || !resp.HasMoreAfter { + t.Errorf("has_more_before = %v, has_more_after = %v; want both true mid-channel", + resp.HasMoreBefore, resp.HasMoreAfter) + } +} + +func TestMessagesAround_NearChannelStart(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "aroundstart", 1) + chID, ids := seedAroundChannel(t, database, "aroundstart", 30) + + rr := chGet(t, router, aroundPath(chID, ids[0], "limit=10"), token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + resp := decodeAround(t, rr) + + if len(resp.Messages) == 0 || resp.Messages[0].ID != ids[0] { + t.Fatalf("first entry = %v, want the centre %d at the head", resp.Messages, ids[0]) + } + if resp.HasMoreBefore { + t.Error("has_more_before = true at the first message of the channel") + } + if !resp.HasMoreAfter { + t.Error("has_more_after = false with 29 newer messages") + } + // Only the after half is available, so the window is shorter than limit. + if len(resp.Messages) != 5 { + t.Errorf("window size = %d, want 5 (centre + 4 newer)", len(resp.Messages)) + } +} + +func TestMessagesAround_NearChannelEnd(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "aroundend", 1) + chID, ids := seedAroundChannel(t, database, "aroundend", 30) + + last := ids[len(ids)-1] + rr := chGet(t, router, aroundPath(chID, last, "limit=10"), token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + resp := decodeAround(t, rr) + + if resp.HasMoreAfter { + t.Error("has_more_after = true at the newest message of the channel") + } + if !resp.HasMoreBefore { + t.Error("has_more_before = false with 29 older messages") + } + tail := resp.Messages[len(resp.Messages)-1] + if tail.ID != last { + t.Errorf("last entry = %d, want the centre %d", tail.ID, last) + } + if len(resp.Messages) != 6 { + t.Errorf("window size = %d, want 6 (5 older + centre)", len(resp.Messages)) + } +} + +func TestMessagesAround_ShortChannelReturnsEverything(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "aroundshort", 1) + chID, ids := seedAroundChannel(t, database, "aroundshort", 3) + + rr := chGet(t, router, aroundPath(chID, ids[1], "limit=50"), token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + resp := decodeAround(t, rr) + + if len(resp.Messages) != 3 { + t.Fatalf("window size = %d, want all 3 messages", len(resp.Messages)) + } + if resp.HasMoreBefore || resp.HasMoreAfter { + t.Errorf("has_more_before = %v, has_more_after = %v; want both false", + resp.HasMoreBefore, resp.HasMoreAfter) + } +} + +func TestMessagesAround_SkipsDeletedNeighbours(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "aroundtomb", 1) + user, _ := database.GetUserByUsername(context.Background(), "aroundtomb") + chID, ids := seedAroundChannel(t, database, "aroundtomb", 5) + + // Soft-delete a neighbour: history omits deleted rows, so the window must + // too — otherwise the client renders a tombstone it never asked for. + if err := database.DeleteMessage(context.Background(), ids[0], user.ID, false); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + + rr := chGet(t, router, aroundPath(chID, ids[2], "limit=50"), token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + resp := decodeAround(t, rr) + + if len(resp.Messages) != 4 { + t.Fatalf("window size = %d, want 4 (5 minus the deleted one)", len(resp.Messages)) + } + for _, m := range resp.Messages { + if m.ID == ids[0] { + t.Errorf("deleted message %d present in the window", ids[0]) + } + } +} + +func TestMessagesAround_DMNonParticipantIsNotFound(t *testing.T) { + database := newPinTestDB(t) + router := buildChannelRouter(database) + + chTestCreateToken(t, database, "arounddm1", 4) + chTestCreateToken(t, database, "arounddm2", 4) + outsiderToken := chTestCreateToken(t, database, "arounddmout", 4) + + user1, _ := database.GetUserByUsername(context.Background(), "arounddm1") + user2, _ := database.GetUserByUsername(context.Background(), "arounddm2") + dmCh, _, err := database.GetOrCreateDMChannel(context.Background(), user1.ID, user2.ID) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, user1.ID, "private", nil) + + rr := chGet(t, router, aroundPath(dmCh.ID, msgID, ""), outsiderToken) + if rr.Code != http.StatusNotFound { + t.Errorf("outsider status = %d, want 404; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestMessagesAround_DMParticipantAllowed(t *testing.T) { + database := newPinTestDB(t) + router := buildChannelRouter(database) + + token1 := chTestCreateToken(t, database, "arounddmok1", 4) + chTestCreateToken(t, database, "arounddmok2", 4) + user1, _ := database.GetUserByUsername(context.Background(), "arounddmok1") + user2, _ := database.GetUserByUsername(context.Background(), "arounddmok2") + dmCh, _, err := database.GetOrCreateDMChannel(context.Background(), user1.ID, user2.ID) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, user1.ID, "private", nil) + + rr := chGet(t, router, aroundPath(dmCh.ID, msgID, ""), token1) + if rr.Code != http.StatusOK { + t.Fatalf("participant status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + resp := decodeAround(t, rr) + if len(resp.Messages) != 1 || resp.Messages[0].ID != msgID { + t.Errorf("window = %v, want just the DM message %d", resp.Messages, msgID) + } +} + +func TestMessagesAround_NoReadPermissionIsForbidden(t *testing.T) { + database := newPinTestDB(t) + router := buildChannelRouter(database) + + // Role 4 (Member) with READ_MESSAGES denied on this channel by override. + ownerToken := chTestCreateToken(t, database, "aroundowner", 1) + deniedToken := chTestCreateToken(t, database, "arounddenied", 4) + chID, ids := seedAroundChannel(t, database, "aroundowner", 3) + + if _, err := database.ExecContext(context.Background(), + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2147483647)`, + chID, + ); err != nil { + t.Fatalf("insert override: %v", err) + } + + rr := chGet(t, router, aroundPath(chID, ids[1], ""), deniedToken) + if rr.Code != http.StatusForbidden { + t.Errorf("denied member status = %d, want 403; body: %s", rr.Code, rr.Body.String()) + } + // The owner still gets the window — the override, not the endpoint, is the gate. + rr = chGet(t, router, aroundPath(chID, ids[1], ""), ownerToken) + if rr.Code != http.StatusOK { + t.Errorf("owner status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestMessagesAround_EnrichesReactionsAndMentions(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "aroundrich", 1) + user, _ := database.GetUserByUsername(context.Background(), "aroundrich") + chID, ids := seedAroundChannel(t, database, "aroundrich", 3) + + if err := database.AddReaction(context.Background(), ids[1], user.ID, "👍"); err != nil { + t.Fatalf("AddReaction: %v", err) + } + + rr := chGet(t, router, aroundPath(chID, ids[1], ""), token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + + var raw struct { + Messages []struct { + ID int64 `json:"id"` + Reactions []struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Me bool `json:"me"` + } `json:"reactions"` + Attachments []any `json:"attachments"` + Mentions []int64 `json:"mentions"` + } `json:"messages"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode: %v", err) + } + for _, m := range raw.Messages { + if m.Attachments == nil || m.Mentions == nil { + t.Errorf("message %d has null attachments/mentions; the enrichment path was skipped", m.ID) + } + if m.ID != ids[1] { + continue + } + if len(m.Reactions) != 1 || m.Reactions[0].Emoji != "👍" || !m.Reactions[0].Me { + t.Errorf("centre reactions = %v, want one 👍 with me=true", m.Reactions) + } + } +} diff --git a/Server/api/channel_handler.go b/Server/api/channel_handler.go index 78e23cb5..9f1dcaa7 100644 --- a/Server/api/channel_handler.go +++ b/Server/api/channel_handler.go @@ -2,9 +2,11 @@ package api import ( "context" + "encoding/json" "errors" "log/slog" "net/http" + "net/url" "strconv" "strings" "time" @@ -49,14 +51,24 @@ func searchRateLimitMiddleware(limiter *auth.RateLimiter, limit int, window time } } +// PurgeBroadcaster is the interface needed to fan a bulk delete out over +// WebSocket from a REST handler. Satisfied by *ws.Hub. +type PurgeBroadcaster interface { + BroadcastChatBulkDeleted(channelID int64, messageIDs []int64) +} + // MountChannelRoutes registers all channel-related routes onto r. // All routes require authentication. The limiter is used to rate-limit -// expensive endpoints like search. -func MountChannelRoutes(r chi.Router, database *db.DB, svc *service.Services, limiter *auth.RateLimiter, trustedProxies []string) { +// expensive endpoints like search. broadcaster may be nil, in which case a +// purge still commits but no chat_bulk_deleted event is emitted. +func MountChannelRoutes(r chi.Router, database *db.DB, svc *service.Services, limiter *auth.RateLimiter, trustedProxies []string, broadcaster PurgeBroadcaster) { r.Route("/api/v1/channels", func(r chi.Router) { r.Use(AuthMiddleware(database)) r.Get("/", handleListChannels(svc)) r.Get("/{id}/messages", handleGetMessages(svc)) + r.Get("/{id}/messages/around/{messageId}", handleGetMessagesAround(svc)) + r.Post("/{id}/messages/purge", handlePurgeMessages(svc, broadcaster)) + r.Get("/{id}/messages/{messageId}/reactions/{emoji}/users", handleGetReactionUsers(svc)) r.Get("/{id}/pins", handleGetPins(svc)) r.Post("/{id}/pins/{messageId}", handleSetPinned(svc, true)) r.Delete("/{id}/pins/{messageId}", handleSetPinned(svc, false)) @@ -118,19 +130,9 @@ func handleGetMessages(svc *service.Services) http.HandlerFunc { 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 + limit, ok := parseLimitParam(w, r) + if !ok { + return } msgs, hasMore, err := svc.Messages.GetMessages(r.Context(), user.ID, channelID, before, limit) @@ -147,6 +149,143 @@ func handleGetMessages(svc *service.Services) http.HandlerFunc { } } +// handleGetMessagesAround returns the window of messages centred on a message, +// oldest-first. Used to jump to a message (search hit, pinned entry, reply +// reference, permalink) that is not in the client's loaded history. +func handleGetMessagesAround(svc *service.Services) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + channelID, ok := parseIDParam(w, r, "id") + if !ok { + return + } + messageID, ok := parseIDParam(w, r, "messageId") + if !ok { + return + } + + user, _ := r.Context().Value(UserKey).(*db.User) + if user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", Message: "authentication required", + }) + return + } + + limit, ok := parseLimitParam(w, r) + if !ok { + return + } + + window, err := svc.Messages.GetMessagesAround(r.Context(), user.ID, channelID, messageID, limit) + if err != nil { + writeServiceError(r.Context(), w, err) + return + } + writeJSON(w, http.StatusOK, window) + } +} + +// handleGetReactionUsers returns the users who reacted to a message with a +// given emoji, capped server-side at 100. The emoji arrives percent-encoded in +// the path; chi routes on RawPath when it differs from Path, so the param must +// be unescaped here rather than taken verbatim. +func handleGetReactionUsers(svc *service.Services) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + channelID, ok := parseIDParam(w, r, "id") + if !ok { + return + } + messageID, ok := parseIDParam(w, r, "messageId") + if !ok { + return + } + + user, _ := r.Context().Value(UserKey).(*db.User) + if user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", Message: "authentication required", + }) + return + } + + emoji := chi.URLParam(r, "emoji") + if decoded, decErr := url.PathUnescape(emoji); decErr == nil { + emoji = decoded + } + + users, err := svc.Messages.GetReactionUsers(r.Context(), user.ID, channelID, messageID, emoji) + if err != nil { + writeServiceError(r.Context(), w, err) + return + } + + type response struct { + Users []db.ReactionUser `json:"users"` + } + writeJSON(w, http.StatusOK, response{Users: users}) + } +} + +// purgeRequest is the JSON body for POST /api/v1/channels/{id}/messages/purge. +// Before is optional; 0 means "start from the newest message". +type purgeRequest struct { + Limit int `json:"limit"` + Before int64 `json:"before"` +} + +// purgeResponse reports what the purge actually deleted, which can be fewer +// than Limit rows when the channel holds less history. +type purgeResponse struct { + ChannelID int64 `json:"channel_id"` + IDs []int64 `json:"ids"` + Count int `json:"count"` +} + +// handlePurgeMessages bulk soft-deletes the newest messages in a channel and +// broadcasts a single chat_bulk_deleted event. +func handlePurgeMessages(svc *service.Services, broadcaster PurgeBroadcaster) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + channelID, ok := parseIDParam(w, r, "id") + if !ok { + return + } + + user, _ := r.Context().Value(UserKey).(*db.User) + if user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", Message: "authentication required", + }) + return + } + + var req purgeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "invalid request body", + }) + return + } + + result, err := svc.Messages.PurgeMessages(r.Context(), user.ID, channelID, req.Limit, req.Before) + if err != nil { + writeServiceError(r.Context(), w, err) + return + } + + // A purge that matched nothing is still a success, but there is no + // state change to announce. + if broadcaster != nil && len(result.MessageIDs) > 0 { + broadcaster.BroadcastChatBulkDeleted(result.ChannelID, result.MessageIDs) + } + + writeJSON(w, http.StatusOK, purgeResponse{ + ChannelID: result.ChannelID, + IDs: result.MessageIDs, + Count: len(result.MessageIDs), + }) + } +} + // handleSearch performs a full-text search across messages. func handleSearch(svc *service.Services) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -178,19 +317,9 @@ func handleSearch(svc *service.Services) http.HandlerFunc { 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 + limit, ok := parseLimitParam(w, r) + if !ok { + return } results, err := svc.Messages.SearchMessages(r.Context(), user.ID, q, channelID, limit) @@ -297,6 +426,24 @@ func writeServiceError(ctx context.Context, w http.ResponseWriter, err error) { } } +// parseLimitParam reads the shared `limit` query parameter, defaulting to +// defaultMessageLimit and clamping at maxMessageLimit. Writes a 400 response +// and returns false when the value is present but not a positive integer. +func parseLimitParam(w http.ResponseWriter, r *http.Request) (int, bool) { + raw := r.URL.Query().Get("limit") + if raw == "" { + return defaultMessageLimit, true + } + v, err := strconv.Atoi(raw) + if err != nil || v < 1 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "limit must be a positive integer", + }) + return 0, false + } + return min(v, maxMessageLimit), true +} + // 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) { diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go index a8dd681e..956f5500 100644 --- a/Server/api/channel_handler_test.go +++ b/Server/api/channel_handler_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "testing/fstest" @@ -46,7 +47,10 @@ CREATE TABLE IF NOT EXISTS users ( banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT, - identity_public_key TEXT + identity_public_key TEXT, + display_name TEXT, + about TEXT, + custom_status TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -73,7 +77,9 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, - voice_max_video INTEGER NOT NULL DEFAULT 0 + voice_max_video INTEGER NOT NULL DEFAULT 0, + nsfw INTEGER NOT NULL DEFAULT 0, + is_group INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channel_overrides ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -83,6 +89,14 @@ CREATE TABLE IF NOT EXISTS channel_overrides ( deny INTEGER NOT NULL DEFAULT 0, UNIQUE(channel_id, role_id) ); + +CREATE TABLE IF NOT EXISTS channel_user_overrides ( + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (channel_id, user_id) +); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, @@ -92,8 +106,15 @@ CREATE TABLE IF NOT EXISTS messages ( edited_at TEXT, deleted INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, - timestamp TEXT NOT NULL DEFAULT (datetime('now')) + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + mentions_everyone INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS message_mentions ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, mentioned_user_id) +); + CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, id DESC); CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( @@ -163,6 +184,16 @@ CREATE TABLE IF NOT EXISTS dm_participants ( ); CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id); +CREATE TABLE IF NOT EXISTS audit_log ( + 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')) +); + CREATE TABLE IF NOT EXISTS dm_open_state ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, @@ -192,7 +223,7 @@ func buildChannelRouter(database *db.DB) http.Handler { limiter := auth.NewRateLimiter() st := database svc := service.New(st, limiter) - api.MountChannelRoutes(r, database, svc, limiter, nil) + api.MountChannelRoutes(r, database, svc, limiter, nil, nil) return r } @@ -612,7 +643,7 @@ func TestSearch_TrustedProxyRateLimitUsesForwardedIP(t *testing.T) { r := chi.NewRouter() limiter := auth.NewRateLimiter() svc := service.New(database, limiter) - api.MountChannelRoutes(r, database, svc, limiter, []string{"127.0.0.0/8"}) + api.MountChannelRoutes(r, database, svc, limiter, []string{"127.0.0.0/8"}, nil) token := chTestCreateToken(t, database, "proxysearch", 1) for i := range 30 { @@ -936,3 +967,260 @@ func TestSetPinned_Idempotent(t *testing.T) { t.Errorf("idempotent pin status = %d, want 204; body: %s", rr.Code, rr.Body.String()) } } + +// ─── POST /api/v1/channels/{id}/messages/purge ────────────────────────────── + +// recordingPurgeBroadcaster captures the chat_bulk_deleted fan-out so tests can +// assert one event carries every purged id. +type recordingPurgeBroadcaster struct { + calls []purgeBroadcast +} + +type purgeBroadcast struct { + channelID int64 + ids []int64 +} + +func (b *recordingPurgeBroadcaster) BroadcastChatBulkDeleted(channelID int64, ids []int64) { + b.calls = append(b.calls, purgeBroadcast{channelID: channelID, ids: ids}) +} + +// buildPurgeRouter wires the channel routes with a recording broadcaster onto a +// DB that has the DM and audit tables the purge path touches. +func buildPurgeRouter(t *testing.T) (http.Handler, *db.DB, *recordingPurgeBroadcaster) { + t.Helper() + database := newPinTestDB(t) + broadcaster := &recordingPurgeBroadcaster{} + r := chi.NewRouter() + limiter := auth.NewRateLimiter() + svc := service.New(database, limiter) + api.MountChannelRoutes(r, database, svc, limiter, nil, broadcaster) + return r, database, broadcaster +} + +// chPurge posts a purge body and returns the recorder. +func chPurge(t *testing.T, router http.Handler, channelID int64, token, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/api/v1/channels/%d/messages/purge", channelID), strings.NewReader(body)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// seedPurgeChannel creates a text channel with n messages authored by username. +func seedPurgeChannel(t *testing.T, database *db.DB, username string, n int) (int64, []int64) { + t.Helper() + user, err := database.GetUserByUsername(context.Background(), username) + if err != nil || user == nil { + t.Fatalf("GetUserByUsername(%q): %v", username, err) + } + chID, err := database.CreateChannel(context.Background(), "purge-"+username, "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + ids := make([]int64, 0, n) + for i := range n { + id, msgErr := database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("m%d", i), nil) + if msgErr != nil { + t.Fatalf("CreateMessage: %v", msgErr) + } + ids = append(ids, id) + } + return chID, ids +} + +type purgeResponseBody struct { + ChannelID int64 `json:"channel_id"` + IDs []int64 `json:"ids"` + Count int `json:"count"` +} + +func TestPurgeMessages_ModeratorSucceedsAndBroadcastsOnce(t *testing.T) { + router, database, broadcaster := buildPurgeRouter(t) + token := chTestCreateToken(t, database, "purgemod", 3) // Moderator: MANAGE_MESSAGES + chID, ids := seedPurgeChannel(t, database, "purgemod", 5) + + rr := chPurge(t, router, chID, token, `{"limit":3}`) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + + var body purgeResponseBody + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Count != 3 || len(body.IDs) != 3 { + t.Fatalf("count = %d, ids = %v, want 3 of each", body.Count, body.IDs) + } + if body.IDs[0] != ids[4] { + t.Errorf("ids[0] = %d, want the newest message %d", body.IDs[0], ids[4]) + } + + // One chat_bulk_deleted event, not three chat_deleted ones. + if len(broadcaster.calls) != 1 { + t.Fatalf("broadcast calls = %d, want exactly 1", len(broadcaster.calls)) + } + if broadcaster.calls[0].channelID != chID { + t.Errorf("broadcast channel = %d, want %d", broadcaster.calls[0].channelID, chID) + } + if len(broadcaster.calls[0].ids) != 3 { + t.Errorf("broadcast ids = %v, want 3 entries", broadcaster.calls[0].ids) + } + + // Tombstones: the rows survive, flagged deleted. + for _, id := range body.IDs { + msg, _ := database.GetMessage(context.Background(), id) + if msg == nil || !msg.Deleted { + t.Errorf("message %d is not a surviving tombstone", id) + } + } + // The two oldest are untouched. + for _, id := range ids[:2] { + msg, _ := database.GetMessage(context.Background(), id) + if msg.Deleted { + t.Errorf("message %d outside the purge window was deleted", id) + } + } +} + +func TestPurgeMessages_MemberForbidden(t *testing.T) { + router, database, broadcaster := buildPurgeRouter(t) + token := chTestCreateToken(t, database, "purgemember", 4) // Member: no MANAGE_MESSAGES + chID, ids := seedPurgeChannel(t, database, "purgemember", 3) + + rr := chPurge(t, router, chID, token, `{"limit":3}`) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", rr.Code, rr.Body.String()) + } + if len(broadcaster.calls) != 0 { + t.Errorf("denied purge still broadcast: %v", broadcaster.calls) + } + for _, id := range ids { + msg, _ := database.GetMessage(context.Background(), id) + if msg.Deleted { + t.Errorf("denied purge deleted message %d", id) + } + } +} + +func TestPurgeMessages_DMForbidden(t *testing.T) { + router, database, broadcaster := buildPurgeRouter(t) + token := chTestCreateToken(t, database, "purgedmmod", 1) // Owner — every bit set + user, _ := database.GetUserByUsername(context.Background(), "purgedmmod") + dmID, _ := database.CreateChannel(context.Background(), "dm", "dm", "", "", 0) + if _, err := database.ExecContext(context.Background(), + `INSERT INTO dm_participants (channel_id, user_id) VALUES (?, ?)`, dmID, user.ID); err != nil { + t.Fatalf("seed dm participant: %v", err) + } + msgID, _ := database.CreateMessage(context.Background(), dmID, user.ID, "private", nil) + + rr := chPurge(t, router, dmID, token, `{"limit":10}`) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 for a DM; body: %s", rr.Code, rr.Body.String()) + } + if len(broadcaster.calls) != 0 { + t.Errorf("DM purge broadcast: %v", broadcaster.calls) + } + msg, _ := database.GetMessage(context.Background(), msgID) + if msg.Deleted { + t.Error("DM message was purged") + } +} + +func TestPurgeMessages_LimitClampedToHundred(t *testing.T) { + router, database, _ := buildPurgeRouter(t) + token := chTestCreateToken(t, database, "purgeclamp", 3) + chID, _ := seedPurgeChannel(t, database, "purgeclamp", 105) + + rr := chPurge(t, router, chID, token, `{"limit":1000}`) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + var body purgeResponseBody + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Count != 100 { + t.Fatalf("count = %d, want the clamp of 100", body.Count) + } +} + +func TestPurgeMessages_BadRequests(t *testing.T) { + router, database, _ := buildPurgeRouter(t) + token := chTestCreateToken(t, database, "purgebad", 3) + chID, _ := seedPurgeChannel(t, database, "purgebad", 2) + + cases := []struct { + name string + body string + }{ + {"zero limit", `{"limit":0}`}, + {"negative limit", `{"limit":-5}`}, + {"malformed json", `{"limit":`}, + {"negative before", `{"limit":5,"before":-1}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rr := chPurge(t, router, chID, token, tc.body) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body: %s", rr.Code, rr.Body.String()) + } + }) + } +} + +func TestPurgeMessages_Unauthenticated(t *testing.T) { + router, database, _ := buildPurgeRouter(t) + chTestCreateToken(t, database, "purgeanon", 3) + chID, _ := seedPurgeChannel(t, database, "purgeanon", 2) + + rr := chPurge(t, router, chID, "", `{"limit":2}`) + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestPurgeMessages_EmptyChannelDoesNotBroadcast(t *testing.T) { + router, database, broadcaster := buildPurgeRouter(t) + token := chTestCreateToken(t, database, "purgeempty", 3) + chID, _ := seedPurgeChannel(t, database, "purgeempty", 0) + + rr := chPurge(t, router, chID, token, `{"limit":50}`) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + var body purgeResponseBody + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Count != 0 || body.IDs == nil { + t.Errorf("count = %d, ids = %v, want 0 and a non-null array", body.Count, body.IDs) + } + if len(broadcaster.calls) != 0 { + t.Errorf("a no-op purge broadcast: %v", broadcaster.calls) + } +} + +func TestPurgeMessages_NilBroadcasterStillPurges(t *testing.T) { + database := newPinTestDB(t) + router := buildChannelRouter(database) // mounted with a nil broadcaster + token := chTestCreateToken(t, database, "purgenilbc", 3) + chID, ids := seedPurgeChannel(t, database, "purgenilbc", 2) + + rr := chPurge(t, router, chID, token, `{"limit":2}`) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + for _, id := range ids { + msg, _ := database.GetMessage(context.Background(), id) + if !msg.Deleted { + t.Errorf("message %d not purged", id) + } + } +} diff --git a/Server/api/channel_reaction_users_test.go b/Server/api/channel_reaction_users_test.go new file mode 100644 index 00000000..6e6affd2 --- /dev/null +++ b/Server/api/channel_reaction_users_test.go @@ -0,0 +1,168 @@ +package api_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// ─── GET /api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users ── +// +// The who-reacted list is a separate endpoint rather than inline user_ids on +// every reaction summary, so the contract worth pinning is: the same read gate +// as history, and a path emoji that survives percent-encoding. + +type reactionUsersResponse struct { + Users []struct { + ID int64 `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar"` + } `json:"users"` +} + +func reactionUsersPath(channelID, messageID int64, emoji string) string { + return fmt.Sprintf("/api/v1/channels/%d/messages/%d/reactions/%s/users", + channelID, messageID, url.PathEscape(emoji)) +} + +// seedReactedMessage creates a channel with one message and has each of the +// named users react to it with emoji. Returns the channel and message ids. +func seedReactedMessage(t *testing.T, database *db.DB, chName string, author string, emoji string, reactors ...string) (int64, int64) { + t.Helper() + user, err := database.GetUserByUsername(context.Background(), author) + if err != nil { + t.Fatalf("GetUserByUsername(%q): %v", author, err) + } + chID, err := database.CreateChannel(context.Background(), chName, "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + msgID, err := database.CreateMessage(context.Background(), chID, user.ID, "react to me", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + for _, name := range reactors { + u, uErr := database.GetUserByUsername(context.Background(), name) + if uErr != nil { + t.Fatalf("GetUserByUsername(%q): %v", name, uErr) + } + if rErr := database.AddReaction(context.Background(), msgID, u.ID, emoji); rErr != nil { + t.Fatalf("AddReaction(%q): %v", name, rErr) + } + } + return chID, msgID +} + +func decodeReactionUsers(t *testing.T, rr *httptest.ResponseRecorder) reactionUsersResponse { + t.Helper() + var resp reactionUsersResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v (body: %s)", err, rr.Body.String()) + } + return resp +} + +func TestReactionUsers_Unauthenticated(t *testing.T) { + router := buildChannelRouter(newChannelTestDB(t)) + rr := chGet(t, router, reactionUsersPath(1, 1, "👍"), "") + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +// The emoji reaches the handler percent-encoded; chi routes on RawPath, so the +// handler must unescape it or every non-ASCII emoji looks like a different one. +func TestReactionUsers_PercentEncodedEmojiResolves(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "ru-author", 4) + _ = chTestCreateToken(t, database, "ru-bob", 4) + chID, msgID := seedReactedMessage(t, database, "ru-chan", "ru-author", "👍", "ru-author", "ru-bob") + + path := reactionUsersPath(chID, msgID, "👍") + if path == fmt.Sprintf("/api/v1/channels/%d/messages/%d/reactions/👍/users", chID, msgID) { + t.Fatal("test precondition: the emoji should be percent-encoded in the path") + } + + rr := chGet(t, router, path, token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + resp := decodeReactionUsers(t, rr) + if len(resp.Users) != 2 { + t.Fatalf("len(users) = %d, want 2 (%+v)", len(resp.Users), resp.Users) + } + if resp.Users[0].Username != "ru-author" || resp.Users[1].Username != "ru-bob" { + t.Errorf("usernames = [%s %s], want [ru-author ru-bob]", + resp.Users[0].Username, resp.Users[1].Username) + } +} + +func TestReactionUsers_EmojiWithNoReactorsIsEmptyList(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "ru-empty", 4) + chID, msgID := seedReactedMessage(t, database, "ru-emptychan", "ru-empty", "👍", "ru-empty") + + rr := chGet(t, router, reactionUsersPath(chID, msgID, "🎉"), token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + resp := decodeReactionUsers(t, rr) + if len(resp.Users) != 0 { + t.Errorf("len(users) = %d, want 0", len(resp.Users)) + } + // null would not be iterable client-side. + if !json.Valid(rr.Body.Bytes()) || rr.Body.String() == "" { + t.Fatalf("invalid body: %s", rr.Body.String()) + } +} + +func TestReactionUsers_DeniedChannelIsForbidden(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "ru-denied", 4) + chID, msgID := seedReactedMessage(t, database, "ru-deniedchan", "ru-denied", "👍", "ru-denied") + denyReadMessages(t, database, chID, permissions.MemberRoleID) + + rr := chGet(t, router, reactionUsersPath(chID, msgID, "👍"), token) + if rr.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestReactionUsers_MessageFromAnotherChannelIsNotFound(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "ru-cross", 4) + _, msgID := seedReactedMessage(t, database, "ru-crossA", "ru-cross", "👍", "ru-cross") + otherID, _ := database.CreateChannel(context.Background(), "ru-crossB", "text", "", "", 1) + + rr := chGet(t, router, reactionUsersPath(otherID, msgID, "👍"), token) + if rr.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestReactionUsers_InvalidIDs(t *testing.T) { + database := newChannelTestDB(t) + router := buildChannelRouter(database) + token := chTestCreateToken(t, database, "ru-badid", 4) + + for _, path := range []string{ + "/api/v1/channels/abc/messages/1/reactions/%F0%9F%91%8D/users", + "/api/v1/channels/1/messages/0/reactions/%F0%9F%91%8D/users", + } { + rr := chGet(t, router, path, token) + if rr.Code != http.StatusBadRequest { + t.Errorf("%s: status = %d, want 400", path, rr.Code) + } + } +} diff --git a/Server/api/constants.go b/Server/api/constants.go index 07eae056..f0a96111 100644 --- a/Server/api/constants.go +++ b/Server/api/constants.go @@ -106,6 +106,11 @@ const ( // uploadRateLimitPerMinute is the maximum file uploads per user per minute. uploadRateLimitPerMinute = 10 + + // emojiUploadRateLimitPerMinute is the maximum custom-emoji uploads per + // MANAGE_SERVER holder per minute. Lower than the attachment limit: every + // accepted upload fans an emoji_update out to every connected session. + emojiUploadRateLimitPerMinute = 10 ) // ─── Timeouts & TTLs ──────────────────────────────────────────────────────── @@ -148,6 +153,50 @@ const ( // maxAvatarURLLen is the maximum length of a user avatar URL. maxAvatarURLLen = 512 + // maxEmojiFileBytes is the largest custom-emoji image accepted (512 KiB). + // An emoji renders at 22px inline and 48px jumbo, so anything approaching + // this is already far more data than the pixels can use. + maxEmojiFileBytes = 512 << 10 + + // maxEmojiDimension caps an emoji's width and height in pixels. Discord + // normalizes to 128px; matching it means an emoji uploaded for OwnCord + // looks the same as the one it was copied from, jumbo included. + maxEmojiDimension = 128 + + // emojiMaxBodySize bounds the whole multipart request. The image cap plus + // the form's own framing (boundaries, headers, the shortcode field) — + // generous enough that a legitimate 512 KiB upload never trips it. + emojiMaxBodySize = 1 << 20 + + // emojiMultipartMemoryLimit is the in-memory limit for parsing an emoji + // upload. Above maxEmojiFileBytes, so a valid emoji never spills to disk. + emojiMultipartMemoryLimit = 1 << 20 + + // maxAvatarFileBytes is the largest avatar image accepted (1 MiB). An + // avatar renders at 40px in a message row and 64px in the profile popup, + // so this is already generous; the client downscales before uploading and + // the cap is what stops it being used as free file hosting. + maxAvatarFileBytes = 1 << 20 + + // maxAvatarDimension caps an avatar's stored width and height. Bigger than + // any surface renders it, so a retina display still has pixels to spare + // while a 6000px camera JPEG is refused rather than shipped to every + // client that sees the user post. + maxAvatarDimension = 1024 + + // avatarMaxBodySize bounds the whole multipart avatar request: the image + // cap plus room for the form's boundaries and headers. + avatarMaxBodySize = 2 << 20 + + // avatarMultipartMemoryLimit is the in-memory limit for parsing an avatar + // upload. Above maxAvatarFileBytes, so a valid avatar never spills to disk. + avatarMultipartMemoryLimit = 2 << 20 + + // avatarUploadRateLimitPerMinute is the maximum avatar uploads per user per + // minute. Lower than the attachment limit: every accepted upload fans a + // user_update out to every connected session and orphans the previous file. + avatarUploadRateLimitPerMinute = 5 + // maxRequestIDLen bounds a client-supplied X-Request-Id. chi's // middleware.RequestID adopts that header verbatim, and the value then // reaches every log record for the request (logctx, requestLogger, diff --git a/Server/api/coverage_push_test.go b/Server/api/coverage_push_test.go index 97ee1ed3..bc8630ee 100644 --- a/Server/api/coverage_push_test.go +++ b/Server/api/coverage_push_test.go @@ -751,7 +751,7 @@ func buildCombinedRouter(t *testing.T) (http.Handler, *auth.RateLimiter, string) r := chi.NewRouter() svc := service.New(database, limiter) api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey) - api.MountProfileRoutes(r, database, svc, limiter, nil, nil) + api.MountProfileRoutes(r, database, svc, nil, limiter, nil, nil) api.MountInviteRoutes(r, database, svc) token := loginAndGetToken(t, r, database, "combined1", 2) diff --git a/Server/api/dm_group_handler_test.go b/Server/api/dm_group_handler_test.go new file mode 100644 index 00000000..e708e5ce --- /dev/null +++ b/Server/api/dm_group_handler_test.go @@ -0,0 +1,452 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/owncord/server/db" +) + +// ─── helpers ──────────────────────────────────────────────────────────────── + +func dmPatch(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPatch, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// decodeDMInfo decodes a db.DMChannelInfo response body. +func decodeDMInfo(t *testing.T, rr *httptest.ResponseRecorder) db.DMChannelInfo { + t.Helper() + var info db.DMChannelInfo + if err := json.Unmarshal(rr.Body.Bytes(), &info); err != nil { + t.Fatalf("decode DMChannelInfo: %v (body=%s)", err, rr.Body.String()) + } + return info +} + +// groupFixture stands up three users and returns their tokens. +func groupFixture(t *testing.T) (*db.DB, http.Handler, *mockBroadcaster, []string) { + t.Helper() + database := newDMTestDB(t) + bc := &mockBroadcaster{} + router := buildDMRouter(database, bc) + tokens := []string{ + dmCreateToken(t, database, "alice", 4), + dmCreateToken(t, database, "bob", 4), + dmCreateToken(t, database, "carol", 4), + } + return database, router, bc, tokens +} + +// ─── creation ─────────────────────────────────────────────────────────────── + +func TestCreateGroupDM_CreatesChannelWithAllParticipants(t *testing.T) { + _, router, bc, tokens := groupFixture(t) + + rr := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + "name": "Lunch crew", + }) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + + info := decodeDMInfo(t, rr) + if !info.IsGroup { + t.Error("expected is_group=true") + } + if info.Name != "Lunch crew" { + t.Errorf("expected name %q, got %q", "Lunch crew", info.Name) + } + if len(info.Recipients) != 2 { + t.Fatalf("expected 2 recipients (creator excluded), got %d", len(info.Recipients)) + } + // Backward compat: a pre-group client reads `recipient` and must find one. + if info.Recipient.ID == 0 { + t.Error("expected a populated backward-compat recipient field") + } + for _, r := range info.Recipients { + if r.ID == 1 { + t.Error("creator must not appear in their own recipients list") + } + } + + // Every participant, creator included, is told about the new DM. + got := map[int64]bool{} + for _, m := range bc.sent { + got[m.UserID] = true + } + for _, want := range []int64{1, 2, 3} { + if !got[want] { + t.Errorf("expected dm_channel_open broadcast to user %d", want) + } + } +} + +func TestCreateGroupDM_RequiresTwoOtherUsers(t *testing.T) { + _, router, _, tokens := groupFixture(t) + + rr := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2}, + }) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for a one-recipient group, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestCreateGroupDM_DeduplicatesAndDropsSelf(t *testing.T) { + _, router, _, tokens := groupFixture(t) + + // Naming the creator and repeating bob leaves only bob + carol. + rr := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{1, 2, 2, 3}, + }) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + info := decodeDMInfo(t, rr) + if len(info.Recipients) != 2 { + t.Fatalf("expected 2 unique recipients, got %d", len(info.Recipients)) + } +} + +func TestCreateGroupDM_RejectsOverCap(t *testing.T) { + database, router, _, tokens := groupFixture(t) + ids := make([]int64, 0, 2+db.MaxGroupDMParticipants) + ids = append(ids, 2, 3) + for i := range db.MaxGroupDMParticipants { + name := fmt.Sprintf("extra%d", i) + if _, err := database.CreateUser(context.Background(), name, "$2a$12$fake", 4); err != nil { + t.Fatalf("CreateUser: %v", err) + } + ids = append(ids, int64(4+i)) + } + + rr := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{"recipient_ids": ids}) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 over the participant cap, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestCreateGroupDM_RejectsUnknownRecipient(t *testing.T) { + _, router, _, tokens := groupFixture(t) + + rr := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 9999}, + }) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404 for an unknown recipient, got %d: %s", rr.Code, rr.Body.String()) + } +} + +// ─── blocks ───────────────────────────────────────────────────────────────── + +func TestCreateGroupDM_BlockerCannotAddBlocked(t *testing.T) { + database, router, _, tokens := groupFixture(t) + if err := database.BlockUser(context.Background(), 1, 3); err != nil { + t.Fatalf("BlockUser: %v", err) + } + + rr := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + }) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403 when adding a user the creator blocked, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestCreateGroupDM_BlockedCannotAddBlocker(t *testing.T) { + database, router, _, tokens := groupFixture(t) + // carol blocks alice; alice must not be able to pull carol in either. + if err := database.BlockUser(context.Background(), 3, 1); err != nil { + t.Fatalf("BlockUser: %v", err) + } + + rr := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + }) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403 when the target blocked the creator, got %d: %s", rr.Code, rr.Body.String()) + } +} + +// ─── listing ──────────────────────────────────────────────────────────────── + +func TestListDMs_ReturnsGroupWithParticipants(t *testing.T) { + _, router, _, tokens := groupFixture(t) + + if rr := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + "name": "Trio", + }); rr.Code != http.StatusCreated { + t.Fatalf("create group: %d %s", rr.Code, rr.Body.String()) + } + + rr := dmGet(t, router, "/api/v1/dms", tokens[1]) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var resp struct { + DMChannels []db.DMChannelInfo `json:"dm_channels"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.DMChannels) != 1 { + t.Fatalf("expected 1 DM for bob, got %d", len(resp.DMChannels)) + } + dm := resp.DMChannels[0] + if !dm.IsGroup || dm.Name != "Trio" { + t.Errorf("expected group named Trio, got is_group=%v name=%q", dm.IsGroup, dm.Name) + } + if len(dm.Recipients) != 2 { + t.Fatalf("expected bob to see 2 others, got %d", len(dm.Recipients)) + } + for _, r := range dm.Recipients { + if r.ID == 2 { + t.Error("bob must not be in his own recipients list") + } + } +} + +// A 1:1 DM must keep listing exactly one recipient and is_group=false, so an +// older client's `recipient`-only rendering is unaffected by group support. +func TestListDMs_OneToOneStaysUngrouped(t *testing.T) { + _, router, _, tokens := groupFixture(t) + + if rr := dmPost(t, router, "/api/v1/dms", tokens[0], map[string]any{"recipient_id": 2}); rr.Code != http.StatusCreated { + t.Fatalf("create dm: %d %s", rr.Code, rr.Body.String()) + } + + rr := dmGet(t, router, "/api/v1/dms", tokens[0]) + var resp struct { + DMChannels []db.DMChannelInfo `json:"dm_channels"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.DMChannels) != 1 { + t.Fatalf("expected 1 DM, got %d", len(resp.DMChannels)) + } + dm := resp.DMChannels[0] + if dm.IsGroup { + t.Error("a two-person DM must not report is_group") + } + if len(dm.Recipients) != 1 || dm.Recipients[0].ID != 2 || dm.Recipient.ID != 2 { + t.Errorf("expected recipient=bob in both fields, got %+v / %+v", dm.Recipient, dm.Recipients) + } +} + +// A group DM containing both users must never be handed back as "the DM +// between alice and bob" — that would deliver a private message to the group. +func TestCreateDM_DoesNotReuseGroupChannel(t *testing.T) { + _, router, _, tokens := groupFixture(t) + + groupRR := dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + }) + group := decodeDMInfo(t, groupRR) + + rr := dmPost(t, router, "/api/v1/dms", tokens[0], map[string]any{"recipient_id": 2}) + if rr.Code != http.StatusCreated { + t.Fatalf("expected a NEW 1:1 DM (201), got %d: %s", rr.Code, rr.Body.String()) + } + var created struct { + ChannelID int64 `json:"channel_id"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("decode: %v", err) + } + if created.ChannelID == group.ChannelID { + t.Fatal("1:1 DM creation reused the group DM channel") + } +} + +// ─── rename ───────────────────────────────────────────────────────────────── + +func TestRenameGroupDM_ParticipantMayRename(t *testing.T) { + _, router, bc, tokens := groupFixture(t) + group := decodeDMInfo(t, dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + })) + bc.sent = nil + + rr := dmPatch(t, router, fmt.Sprintf("/api/v1/dms/%d", group.ChannelID), tokens[1], + map[string]any{"name": "Renamed by bob"}) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if got := decodeDMInfo(t, rr).Name; got != "Renamed by bob" { + t.Errorf("expected renamed group, got %q", got) + } + if len(bc.sent) != 3 { + t.Errorf("expected all 3 participants notified of the rename, got %d", len(bc.sent)) + } +} + +func TestRenameGroupDM_NonParticipantRefused(t *testing.T) { + database, router, _, tokens := groupFixture(t) + group := decodeDMInfo(t, dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + })) + outsider := dmCreateToken(t, database, "dave", 4) + + rr := dmPatch(t, router, fmt.Sprintf("/api/v1/dms/%d", group.ChannelID), outsider, + map[string]any{"name": "hijacked"}) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404 for a non-participant, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestRenameGroupDM_RefusesOneToOne(t *testing.T) { + _, router, _, tokens := groupFixture(t) + rr := dmPost(t, router, "/api/v1/dms", tokens[0], map[string]any{"recipient_id": 2}) + var created struct { + ChannelID int64 `json:"channel_id"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &created) + + renameRR := dmPatch(t, router, fmt.Sprintf("/api/v1/dms/%d", created.ChannelID), tokens[0], + map[string]any{"name": "not allowed"}) + if renameRR.Code != http.StatusBadRequest { + t.Fatalf("expected 400 renaming a 1:1 DM, got %d: %s", renameRR.Code, renameRR.Body.String()) + } +} + +func TestRenameGroupDM_RejectsOverlongName(t *testing.T) { + _, router, _, tokens := groupFixture(t) + group := decodeDMInfo(t, dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + })) + + long := make([]byte, 200) + for i := range long { + long[i] = 'x' + } + rr := dmPatch(t, router, fmt.Sprintf("/api/v1/dms/%d", group.ChannelID), tokens[0], + map[string]any{"name": string(long)}) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for an overlong name, got %d", rr.Code) + } +} + +// ─── leaving ──────────────────────────────────────────────────────────────── + +func TestLeaveGroupDM_RemovesParticipantAndNotifiesSurvivors(t *testing.T) { + database, router, bc, tokens := groupFixture(t) + group := decodeDMInfo(t, dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + })) + bc.sent = nil + + rr := dmDelete(t, router, fmt.Sprintf("/api/v1/dms/%d", group.ChannelID), tokens[1]) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String()) + } + + ok, err := database.IsDMParticipant(context.Background(), 2, group.ChannelID) + if err != nil { + t.Fatalf("IsDMParticipant: %v", err) + } + if ok { + t.Error("bob is still a participant after leaving") + } + + // The leaver gets a close; the survivors get a refreshed membership. + var closes, opens int + for _, m := range bc.sent { + var env struct { + Type string `json:"type"` + } + _ = json.Unmarshal(m.Msg, &env) + switch env.Type { + case "dm_channel_close": + closes++ + if m.UserID != 2 { + t.Errorf("close sent to user %d, expected the leaver (2)", m.UserID) + } + case "dm_channel_open": + opens++ + if m.UserID == 2 { + t.Error("the leaver must not receive a refreshed membership") + } + } + } + if closes != 1 { + t.Errorf("expected 1 dm_channel_close, got %d", closes) + } + if opens != 2 { + t.Errorf("expected 2 survivors notified, got %d", opens) + } +} + +func TestLeaveGroupDM_LastParticipantDeletesChannel(t *testing.T) { + database, router, _, tokens := groupFixture(t) + group := decodeDMInfo(t, dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + })) + + for _, tok := range tokens { + if rr := dmDelete(t, router, fmt.Sprintf("/api/v1/dms/%d", group.ChannelID), tok); rr.Code != http.StatusNoContent { + t.Fatalf("leave: %d %s", rr.Code, rr.Body.String()) + } + } + + ch, err := database.GetChannel(context.Background(), group.ChannelID) + if err != nil { + t.Fatalf("GetChannel: %v", err) + } + if ch != nil { + t.Error("the channel should be gone once the last participant leaves") + } +} + +// Closing a 1:1 DM must stay a hide, not a leave: the closer remains a +// participant so the next message from either side reopens the conversation. +func TestCloseDM_OneToOneKeepsParticipation(t *testing.T) { + database, router, _, tokens := groupFixture(t) + rr := dmPost(t, router, "/api/v1/dms", tokens[0], map[string]any{"recipient_id": 2}) + var created struct { + ChannelID int64 `json:"channel_id"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &created) + + if delRR := dmDelete(t, router, fmt.Sprintf("/api/v1/dms/%d", created.ChannelID), tokens[0]); delRR.Code != http.StatusNoContent { + t.Fatalf("close: %d", delRR.Code) + } + + ok, err := database.IsDMParticipant(context.Background(), 1, created.ChannelID) + if err != nil { + t.Fatalf("IsDMParticipant: %v", err) + } + if !ok { + t.Error("closing a 1:1 DM must not remove the participant row") + } +} + +func TestLeaveGroupDM_NonParticipantRefused(t *testing.T) { + database, router, _, tokens := groupFixture(t) + group := decodeDMInfo(t, dmPost(t, router, "/api/v1/dms/group", tokens[0], map[string]any{ + "recipient_ids": []int64{2, 3}, + })) + outsider := dmCreateToken(t, database, "erin", 4) + + rr := dmDelete(t, router, fmt.Sprintf("/api/v1/dms/%d", group.ChannelID), outsider) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go index c5925e16..b10643dc 100644 --- a/Server/api/dm_handler.go +++ b/Server/api/dm_handler.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "fmt" "log/slog" @@ -24,7 +25,9 @@ func MountDMRoutes(r chi.Router, database *db.DB, svc *service.Services, broadca r.Route("/api/v1/dms", func(r chi.Router) { r.Use(AuthMiddleware(database)) r.Post("/", handleCreateDM(svc)) + r.Post("/group", handleCreateGroupDM(svc, broadcaster)) r.Get("/", handleListDMs(svc)) + r.Patch("/{channelId}", handleRenameGroupDM(svc, broadcaster)) r.Delete("/{channelId}", handleCloseDM(svc, broadcaster)) }) @@ -49,6 +52,17 @@ type createDMResponse struct { Created bool `json:"created"` } +// createGroupDMRequest is the JSON body for POST /api/v1/dms/group. +type createGroupDMRequest struct { + RecipientIDs []int64 `json:"recipient_ids"` + Name string `json:"name"` +} + +// renameDMRequest is the JSON body for PATCH /api/v1/dms/{channelId}. +type renameDMRequest struct { + Name string `json:"name"` +} + // listDMsResponse is the JSON response for GET /api/v1/dms. type listDMsResponse struct { DMChannels []db.DMChannelInfo `json:"dm_channels"` @@ -83,11 +97,16 @@ func handleCreateDM(svc *service.Services) http.HandlerFunc { if result.Recipient.Avatar != nil { avatarStr = *result.Recipient.Avatar } + displayName := "" + if result.Recipient.DisplayName != nil { + displayName = *result.Recipient.DisplayName + } dmUser := db.DMUser{ - ID: result.Recipient.ID, - Username: result.Recipient.Username, - Avatar: avatarStr, - Status: result.Recipient.Status, + ID: result.Recipient.ID, + Username: result.Recipient.Username, + Avatar: avatarStr, + Status: db.StatusForViewer(result.Recipient.Status, result.Recipient.ID, user.ID), + DisplayName: displayName, } status := http.StatusOK @@ -138,7 +157,8 @@ func handleCloseDM(svc *service.Services, broadcaster DMBroadcaster) http.Handle return } - if err := svc.DMs.CloseDM(r.Context(), user.ID, channelID); err != nil { + result, err := svc.DMs.CloseDM(r.Context(), user.ID, channelID) + if err != nil { writeServiceError(r.Context(), w, err) return } @@ -149,12 +169,128 @@ func handleCloseDM(svc *service.Services, broadcaster DMBroadcaster) http.Handle if ok := broadcaster.SendToUser(user.ID, closeMsg); !ok { slog.Debug("handleCloseDM: user not connected", "user_id", user.ID, "channel_id", channelID) } + // A group leave changes the membership everyone else renders, so + // the survivors get a refreshed dm_channel_open rather than being + // left showing a member who has gone. + if result.Left && !result.ChannelDeleted { + broadcastDMOpen(r.Context(), svc, broadcaster, channelID, result.RemainingParticipantIDs) + } } w.WriteHeader(http.StatusNoContent) } } +// broadcastDMOpen sends a per-viewer dm_channel_open for channelID to each of +// targetIDs. The payload differs per addressee (`recipient`/`recipients` are +// relative to who is reading), so it is rebuilt inside the loop. +// +// Failures are logged and skipped, never surfaced: the mutation that prompted +// this has already committed, and a client that misses the event re-derives +// the same state from its next `ready`. +func broadcastDMOpen(ctx context.Context, svc *service.Services, broadcaster DMBroadcaster, channelID int64, targetIDs []int64) { + if broadcaster == nil || len(targetIDs) == 0 { + return + } + for _, pid := range targetIDs { + summary, pErr := svc.DMs.DMSummaryFor(ctx, pid, channelID) + if pErr != nil { + slog.Debug("broadcastDMOpen: summary unavailable", "user_id", pid, "channel_id", channelID, "err", pErr) + continue + } + msg, mErr := json.Marshal(map[string]any{ + "type": "dm_channel_open", + "payload": summary, + }) + if mErr != nil { + slog.Warn("broadcastDMOpen: marshal failed", "err", mErr, "channel_id", channelID) + continue + } + if ok := broadcaster.SendToUser(pid, msg); !ok { + slog.Debug("broadcastDMOpen: user not connected", "user_id", pid, "channel_id", channelID) + } + } +} + +// handleCreateGroupDM creates a group DM between the caller and 2..8 others. +func handleCreateGroupDM(svc *service.Services, broadcaster DMBroadcaster) 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 + } + + var req createGroupDMRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "invalid request body", + }) + return + } + + result, err := svc.DMs.CreateGroupDM(r.Context(), user.ID, req.RecipientIDs, req.Name) + if err != nil { + writeServiceError(r.Context(), w, err) + return + } + + // Everyone gets the DM in their sidebar immediately, the creator + // included — the REST response is only the creator's copy, and a + // second window of theirs needs the event just as much as the others. + broadcastDMOpen(r.Context(), svc, broadcaster, result.Channel.ID, result.ParticipantIDs) + + writeJSON(w, http.StatusCreated, + db.NewDMChannelInfo(result.Channel.ID, result.Channel.Name, true, result.Participants, user.ID)) + } +} + +// handleRenameGroupDM sets or clears a group DM's name. Participants only — +// there is no owner, so every member holds the same authority over it. +func handleRenameGroupDM(svc *service.Services, broadcaster DMBroadcaster) 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 + } + + channelID, ok := parseIDParam(w, r, "channelId") + if !ok { + return + } + + var req renameDMRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "invalid request body", + }) + return + } + + if _, err := svc.DMs.RenameGroupDM(r.Context(), user.ID, channelID, req.Name); err != nil { + writeServiceError(r.Context(), w, err) + return + } + + participantIDs, pErr := svc.Channels.GetDMParticipantIDs(r.Context(), channelID) + if pErr == nil { + broadcastDMOpen(r.Context(), svc, broadcaster, channelID, participantIDs) + } + + summary, sErr := svc.DMs.DMSummaryFor(r.Context(), user.ID, channelID) + if sErr != nil { + writeServiceError(r.Context(), w, sErr) + return + } + writeJSON(w, http.StatusOK, summary) + } +} + // handleBlockUser blocks a user. func handleBlockUser(svc *service.Services) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/Server/api/dm_handler_test.go b/Server/api/dm_handler_test.go index 88ab61d7..de42795f 100644 --- a/Server/api/dm_handler_test.go +++ b/Server/api/dm_handler_test.go @@ -49,7 +49,10 @@ CREATE TABLE IF NOT EXISTS users ( banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT, - identity_public_key TEXT + identity_public_key TEXT, + display_name TEXT, + about TEXT, + custom_status TEXT ); CREATE TABLE IF NOT EXISTS sessions ( @@ -77,7 +80,9 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, - voice_max_video INTEGER NOT NULL DEFAULT 0 + voice_max_video INTEGER NOT NULL DEFAULT 0, + nsfw INTEGER NOT NULL DEFAULT 0, + is_group INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( @@ -89,8 +94,15 @@ CREATE TABLE IF NOT EXISTS messages ( edited_at TEXT, deleted INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, - timestamp TEXT NOT NULL DEFAULT (datetime('now')) + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + mentions_everyone INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS message_mentions ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, mentioned_user_id) +); + CREATE TABLE IF NOT EXISTS dm_participants ( channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, diff --git a/Server/api/emoji_handler.go b/Server/api/emoji_handler.go new file mode 100644 index 00000000..c3e91592 --- /dev/null +++ b/Server/api/emoji_handler.go @@ -0,0 +1,407 @@ +package api + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "image" + _ "image/gif" // register the GIF decoder for image.DecodeConfig + _ "image/jpeg" // register the JPEG decoder for image.DecodeConfig + _ "image/png" // register the PNG decoder for image.DecodeConfig + "io" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/service" + "github.com/owncord/server/storage" +) + +// EmojiBroadcaster is the slice of the hub the emoji routes need: after every +// mutation the full set is pushed to every connected client so pickers, +// message rendering and reaction pills converge without a reconnect. +type EmojiBroadcaster interface { + BroadcastEmojiUpdate(list []*db.Emoji) +} + +// emojiResponse is the JSON shape of one emoji in GET/POST /api/v1/emoji. +// Deliberately not db.Emoji: the storage id and sniffed mime type are +// server-side details, and `url` is what a client actually needs. +type emojiResponse struct { + ID int64 `json:"id"` + Shortcode string `json:"shortcode"` + URL string `json:"url"` +} + +func toEmojiResponse(e *db.Emoji) emojiResponse { + return emojiResponse{ID: e.ID, Shortcode: e.Shortcode, URL: service.EmojiImageURL(e.ID)} +} + +func toEmojiResponses(list []*db.Emoji) []emojiResponse { + out := make([]emojiResponse, 0, len(list)) + for _, e := range list { + if e == nil { + continue + } + out = append(out, toEmojiResponse(e)) + } + return out +} + +// allowedEmojiMIME is the set of image types an emoji may be, matched against +// the type sniffed from the file's own bytes. SVG is absent on purpose: it is +// markup with script and external-fetch capability, which is exactly what +// isUnsafeInlineMIME forces to a download on the attachment route -- an emoji +// is by definition rendered inline, so the format simply cannot be allowed. +var allowedEmojiMIME = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, +} + +// MountEmojiRoutes registers the custom-emoji endpoints. +// +// Every route requires authentication: reading the set is ungated beyond that +// (emoji are server-wide and every member renders them), while POST and DELETE +// are gated on MANAGE_SERVER inside EmojiService. The image route is +// authenticated rather than public so an emoji cannot be used as an +// unauthenticated tracking pixel hosted on someone else's server. +func MountEmojiRoutes(r chi.Router, database *db.DB, svc *service.Services, store *storage.Storage, limiter *auth.RateLimiter, broadcaster EmojiBroadcaster) { + r.Route("/api/v1/emoji", func(r chi.Router) { + r.Use(AuthMiddleware(database)) + r.Get("/", handleListEmoji(svc)) + r.Get("/{id}/image", handleServeEmojiImage(svc, store)) + r.With(MaxBodySize(emojiMaxBodySize)). + Post("/", handleCreateEmoji(svc, store, limiter, broadcaster)) + r.Delete("/{id}", handleDeleteEmoji(svc, store, broadcaster)) + }) +} + +func handleListEmoji(svc *service.Services) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + list, err := svc.Emoji.List(r.Context()) + if err != nil { + writeServiceError(r.Context(), w, err) + return + } + writeJSON(w, http.StatusOK, toEmojiResponses(list)) + } +} + +// handleCreateEmoji processes POST /api/v1/emoji (multipart: `file` + `shortcode`). +// +// Order matters here. The permission gate runs BEFORE the multipart parse, so a +// member without MANAGE_SERVER cannot make the server spool a body to disk; the +// shortcode is validated next, so a malformed name costs nothing either; only +// then are the bytes read, sniffed, measured and stored. +func handleCreateEmoji(svc *service.Services, store *storage.Storage, limiter *auth.RateLimiter, broadcaster EmojiBroadcaster) 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 + } + + if err := svc.Emoji.RequireManage(r.Context(), user.ID); err != nil { + writeServiceError(r.Context(), w, err) + return + } + + if limiter != nil && !limiter.Allow(auth.Key("emoji_upload", user.ID), emojiUploadRateLimitPerMinute, time.Minute) { + writeJSON(w, http.StatusTooManyRequests, errorResponse{ + Error: "RATE_LIMITED", Message: "emoji upload rate limit exceeded, try again later", + }) + return + } + + // Bound the body before the multipart parser touches it. The route also + // carries MaxBodySize, but a handler that parses a form has to state + // its own limit — the parser is what turns an unbounded body into heap. + r.Body = http.MaxBytesReader(w, r.Body, emojiMaxBodySize) + + if err := r.ParseMultipartForm(emojiMultipartMemoryLimit); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "invalid multipart form", + }) + return + } + + shortcode, err := service.ValidateShortcode(r.FormValue("shortcode")) + if err != nil { + writeServiceError(r.Context(), w, err) + return + } + + file, _, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "missing file field", + }) + return + } + defer file.Close() //nolint:errcheck + + // Read at most one byte past the cap so "exactly at the limit" passes + // and "one byte over" is caught, without buffering an unbounded body. + raw, err := io.ReadAll(io.LimitReader(file, maxEmojiFileBytes+1)) + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "failed to read uploaded file", + }) + return + } + if int64(len(raw)) > maxEmojiFileBytes { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: fmt.Sprintf("emoji must be at most %d KB", maxEmojiFileBytes>>10), + }) + return + } + + mimeType := http.DetectContentType(raw) + if !allowedEmojiMIME[mimeType] { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: "emoji must be a PNG, JPEG, GIF or WebP image", + }) + return + } + + width, height, err := imageDimensions(raw, mimeType) + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "could not read image dimensions", + }) + return + } + // Re-check the sniffed dimensions rather than trusting anything the + // client said about the image: the cap is what keeps an "emoji" from + // being a full-size picture inlined into every message that names it. + if width <= 0 || height <= 0 || width > maxEmojiDimension || height > maxEmojiDimension { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: fmt.Sprintf("emoji must be at most %dx%d pixels (got %dx%d)", maxEmojiDimension, maxEmojiDimension, width, height), + }) + return + } + + storedAs := uuid.New().String() + if _, saveErr := store.Save(storedAs, bytes.NewReader(raw)); saveErr != nil { + slog.Warn("emoji upload rejected by storage", "error", saveErr) + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: fmt.Sprintf("upload rejected: %s", saveErr), + }) + return + } + + created, err := svc.Emoji.Create(r.Context(), user.ID, shortcode, storedAs, mimeType) + if err != nil { + // The row never landed, so the file is an orphan — unlink it. + if delErr := store.Delete(storedAs); delErr != nil { + slog.Error("failed to clean up orphaned emoji file", "stored_as", storedAs, "error", delErr) + } + writeServiceError(r.Context(), w, err) + return + } + + broadcastEmojiSet(r.Context(), svc, broadcaster) + writeJSON(w, http.StatusCreated, toEmojiResponse(created)) + } +} + +func handleDeleteEmoji(svc *service.Services, store *storage.Storage, broadcaster EmojiBroadcaster) 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 + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil || id <= 0 { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "invalid emoji id", + }) + return + } + + removed, err := svc.Emoji.Delete(r.Context(), user.ID, id) + if err != nil { + writeServiceError(r.Context(), w, err) + return + } + // The row is already gone, so a failed unlink leaves an orphaned blob, + // not a broken emoji — log it rather than failing a successful delete. + if delErr := store.Delete(removed.StoredAs); delErr != nil { + slog.Warn("failed to remove emoji file", "stored_as", removed.StoredAs, "error", delErr) + } + + broadcastEmojiSet(r.Context(), svc, broadcaster) + w.WriteHeader(http.StatusNoContent) + } +} + +// broadcastEmojiSet re-reads the set and pushes it to every client. A failure +// here is logged and swallowed: the mutation itself already succeeded, and the +// caller's own response carries the change. +func broadcastEmojiSet(ctx context.Context, svc *service.Services, broadcaster EmojiBroadcaster) { + if broadcaster == nil { + return + } + list, err := svc.Emoji.List(ctx) + if err != nil { + slog.Error("failed to load emoji for broadcast", "error", err) + return + } + broadcaster.BroadcastEmojiUpdate(list) +} + +// handleServeEmojiImage serves the stored bytes of one emoji. +// +// Unlike /api/v1/files/{id} there is no per-channel ACL to apply: an emoji is +// server-wide by construction, so authentication is the whole check. The +// response is immutable for the id's lifetime (an emoji's bytes never change +// — a replacement is a new row), which is what lets it be cached hard. +func handleServeEmojiImage(svc *service.Services, store *storage.Storage) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return + } + e, err := svc.Emoji.Get(r.Context(), id) + if err != nil { + if errors.Is(err, service.ErrNotFound) { + http.NotFound(w, r) + return + } + writeServiceError(r.Context(), w, err) + return + } + f, err := store.Open(e.StoredAs) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() //nolint:errcheck + + w.Header().Set("Content-Type", e.MimeType) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Disposition", "inline") + // Private (it needed a session to fetch) but immutable, so a client may + // keep it for a day rather than re-fetching it for every message. + w.Header().Set("Cache-Control", "private, max-age=86400, immutable") + + var modTime time.Time + if info, statErr := f.Stat(); statErr == nil { + modTime = info.ModTime() + } + http.ServeContent(w, r, e.Shortcode, modTime, f) + } +} + +// ─── Dimension extraction ──────────────────────────────────────────────────── + +// imageDimensions returns the pixel size of an image already known to be one of +// the allowed types. PNG/JPEG/GIF go through image.DecodeConfig; WebP has no +// decoder in the standard library and none is vendored, so its header is read +// directly — which is all that is wanted here anyway, since decoding a whole +// frame just to learn its size is work the cap exists to avoid. +// +// Shared by the emoji and avatar upload routes: both refuse an image too big +// for the surface it renders on, and both have to answer the same question +// about the same four formats. +func imageDimensions(raw []byte, mimeType string) (width, height int, err error) { + if mimeType == "image/webp" { + width, height, err = webpDimensions(raw) + } else { + var cfg image.Config + cfg, _, err = image.DecodeConfig(bytes.NewReader(raw)) + if err != nil { + return 0, 0, fmt.Errorf("decoding image config: %w", err) + } + width, height = cfg.Width, cfg.Height + } + if err != nil { + return 0, 0, err + } + // Both callers treat a non-error return as trustworthy enough to compare + // straight against their pixel cap. A width or height of zero is not a + // "small" image, it is a decoder -- Go's own GIF DecodeConfig happily + // reports height=0 for a malformed logical screen descriptor -- accepting + // a degenerate header as valid. Rejecting it here means the invariant + // holds even for a caller that forgets to re-check, instead of relying on + // every call site getting its own bounds check right. + if width <= 0 || height <= 0 { + return 0, 0, fmt.Errorf("decoding image config: non-positive dimensions %dx%d", width, height) + } + return width, height, nil +} + +// errBadWebP is returned for any WebP whose header does not parse; the caller +// turns it into the same 400 a corrupt PNG gets. +var errBadWebP = errors.New("malformed WebP header") + +// webpDimensions reads the canvas size out of a RIFF/WEBP container. All three +// chunk flavours are handled: VP8 (lossy), VP8L (lossless) and VP8X (extended, +// which is what an animated or alpha WebP uses). +func webpDimensions(raw []byte) (width, height int, err error) { + // 12-byte RIFF header + at least a 4-byte chunk fourcc. + if len(raw) < 16 || string(raw[0:4]) != "RIFF" || string(raw[8:12]) != "WEBP" { + return 0, 0, errBadWebP + } + switch string(raw[12:16]) { + case "VP8 ": + // Chunk payload starts at 20; the keyframe start code sits 3 bytes in, + // followed by two 14-bit dimensions (the top 2 bits are a scale field). + if len(raw) < 30 { + return 0, 0, errBadWebP + } + if raw[23] != 0x9d || raw[24] != 0x01 || raw[25] != 0x2a { + return 0, 0, errBadWebP + } + w := int(binary.LittleEndian.Uint16(raw[26:28]) & 0x3FFF) + h := int(binary.LittleEndian.Uint16(raw[28:30]) & 0x3FFF) + // Unlike VP8L/VP8X (which store size-1, so they can never encode + // zero), the VP8 keyframe stores the size directly: an all-zero + // dimension field is a validly-shaped but degenerate header, not a + // real 0x0 canvas. Reject it here rather than reporting "success" + // with a size no image actually has. + if w == 0 || h == 0 { + return 0, 0, errBadWebP + } + return w, h, nil + case "VP8L": + // Payload starts at 20 with a 0x2F signature byte, then a packed + // 14+14-bit (width-1, height-1) pair. + if len(raw) < 25 || raw[20] != 0x2F { + return 0, 0, errBadWebP + } + bits := binary.LittleEndian.Uint32(raw[21:25]) + w := int(bits&0x3FFF) + 1 + h := int((bits>>14)&0x3FFF) + 1 + return w, h, nil + case "VP8X": + // Payload starts at 20: flags byte, 3 reserved bytes, then canvas + // width-1 and height-1 as 24-bit little-endian values. + if len(raw) < 30 { + return 0, 0, errBadWebP + } + w := int(uint32(raw[24]) | uint32(raw[25])<<8 | uint32(raw[26])<<16) + h := int(uint32(raw[27]) | uint32(raw[28])<<8 | uint32(raw[29])<<16) + return w + 1, h + 1, nil + default: + return 0, 0, errBadWebP + } +} diff --git a/Server/api/emoji_handler_test.go b/Server/api/emoji_handler_test.go new file mode 100644 index 00000000..77dc3966 --- /dev/null +++ b/Server/api/emoji_handler_test.go @@ -0,0 +1,593 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/service" + "github.com/owncord/server/storage" +) + +// ─── harness ───────────────────────────────────────────────────────────────── + +// recordingEmojiBroadcaster captures every emoji_update fan-out so a test can +// assert the set was pushed (and what it contained) without a live hub. +type recordingEmojiBroadcaster struct { + calls [][]*db.Emoji +} + +func (b *recordingEmojiBroadcaster) BroadcastEmojiUpdate(list []*db.Emoji) { + b.calls = append(b.calls, list) +} + +type emojiHarness struct { + router http.Handler + database *db.DB + store *storage.Storage + broadcaster *recordingEmojiBroadcaster + // Tokens for the three principals the gate tests need. + ownerToken string // ADMINISTRATOR + adminToken string // MANAGE_SERVER, no ADMINISTRATOR + memberToken string // neither +} + +func newEmojiHarness(t *testing.T) *emojiHarness { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + + // Pin the exact permission masks the gate tests depend on rather than + // inheriting whatever the seeded defaults happen to carry. + exec := func(q string, args ...any) { + t.Helper() + if _, execErr := database.ExecContext(context.Background(), q, args...); execErr != nil { + t.Fatalf("exec %q: %v", q, execErr) + } + } + exec(`UPDATE roles SET permissions = ? WHERE id = ?`, permissions.Administrator, permissions.OwnerRoleID) + exec(`UPDATE roles SET permissions = ? WHERE id = ?`, + permissions.ManageServer|permissions.ReadMessages|permissions.SendMessages, permissions.AdminRoleID) + exec(`UPDATE roles SET permissions = ? WHERE id = ?`, + permissions.ReadMessages|permissions.SendMessages|permissions.AddReactions, permissions.MemberRoleID) + + store, err := storage.New(t.TempDir(), 10) + if err != nil { + t.Fatalf("storage.New: %v", err) + } + + h := &emojiHarness{ + database: database, + store: store, + broadcaster: &recordingEmojiBroadcaster{}, + } + svc := service.New(database, auth.NewRateLimiter()) + r := chi.NewRouter() + api.MountEmojiRoutes(r, database, svc, store, auth.NewRateLimiter(), h.broadcaster) + h.router = r + + h.ownerToken = emojiSeedUser(t, database, "owner", int(permissions.OwnerRoleID)) + h.adminToken = emojiSeedUser(t, database, "admin", int(permissions.AdminRoleID)) + h.memberToken = emojiSeedUser(t, database, "member", int(permissions.MemberRoleID)) + return h +} + +// emojiSeedUser creates a user with roleID and an unexpired session, returning +// the plaintext bearer token. +func emojiSeedUser(t *testing.T, database *db.DB, username string, roleID int) string { + t.Helper() + if _, err := database.CreateUser(context.Background(), username, "$2a$12$fake", roleID); err != nil { + t.Fatalf("CreateUser %q: %v", username, err) + } + token := "emoji-test-token-" + username + _, err := database.ExecContext(context.Background(), + `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 = ?`, + auth.HashToken(token), username) + if err != nil { + t.Fatalf("insert session for %q: %v", username, err) + } + return token +} + +func (h *emojiHarness) do(t *testing.T, method, path, token string, body *bytes.Buffer, contentType string) *httptest.ResponseRecorder { + t.Helper() + var req *http.Request + if body == nil { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, body) + req.Header.Set("Content-Type", contentType) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h.router.ServeHTTP(rec, req) + return rec +} + +// upload POSTs a multipart emoji and returns the recorder. +func (h *emojiHarness) upload(t *testing.T, token, shortcode string, content []byte) *httptest.ResponseRecorder { + t.Helper() + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + if shortcode != "" { + if err := w.WriteField("shortcode", shortcode); err != nil { + t.Fatalf("WriteField: %v", err) + } + } + if content != nil { + part, err := w.CreateFormFile("file", "emoji.bin") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("write part: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + return h.do(t, http.MethodPost, "/api/v1/emoji", token, body, w.FormDataContentType()) +} + +// ─── image fixtures ────────────────────────────────────────────────────────── + +func pngBytes(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + img.Set(0, 0, color.RGBA{R: 255, A: 255}) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("png.Encode: %v", err) + } + return buf.Bytes() +} + +func gifBytes(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewPaletted(image.Rect(0, 0, w, h), color.Palette{color.Black, color.White}) + var buf bytes.Buffer + if err := gif.Encode(&buf, img, nil); err != nil { + t.Fatalf("gif.Encode: %v", err) + } + return buf.Bytes() +} + +func jpegBytes(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatalf("jpeg.Encode: %v", err) + } + return buf.Bytes() +} + +// webpVP8LBytes builds the smallest byte sequence that both sniffs as +// image/webp and carries a readable lossless header of the given size. Go has +// no WebP encoder, so the container is written by hand — which is also what the +// dimension reader under test parses. +func webpVP8LBytes(w, h int) []byte { + buf := make([]byte, 30) + copy(buf[0:4], "RIFF") + binary.LittleEndian.PutUint32(buf[4:8], uint32(len(buf)-8)) + copy(buf[8:12], "WEBP") + copy(buf[12:16], "VP8L") + binary.LittleEndian.PutUint32(buf[16:20], uint32(len(buf)-20)) + buf[20] = 0x2F + bits := uint32(w-1) | uint32(h-1)<<14 + binary.LittleEndian.PutUint32(buf[21:25], bits) + return buf +} + +// ─── GET /api/v1/emoji ─────────────────────────────────────────────────────── + +func TestEmojiList_RequiresAuth(t *testing.T) { + h := newEmojiHarness(t) + rec := h.do(t, http.MethodGet, "/api/v1/emoji", "", nil, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestEmojiList_EmptyIsJSONArray(t *testing.T) { + h := newEmojiHarness(t) + rec := h.do(t, http.MethodGet, "/api/v1/emoji", h.memberToken, nil, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + if got := strings.TrimSpace(rec.Body.String()); got != "[]" { + t.Errorf("body = %q, want []", got) + } +} + +func TestEmojiList_AnyMemberMaySee(t *testing.T) { + h := newEmojiHarness(t) + if rec := h.upload(t, h.ownerToken, "wave", pngBytes(t, 64, 64)); rec.Code != http.StatusCreated { + t.Fatalf("upload status = %d (%s)", rec.Code, rec.Body.String()) + } + + rec := h.do(t, http.MethodGet, "/api/v1/emoji", h.memberToken, nil, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var list []struct { + ID int64 `json:"id"` + Shortcode string `json:"shortcode"` + URL string `json:"url"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(list) != 1 { + t.Fatalf("len = %d, want 1", len(list)) + } + if list[0].Shortcode != "wave" { + t.Errorf("shortcode = %q, want wave", list[0].Shortcode) + } + if want := fmt.Sprintf("/api/v1/emoji/%d/image", list[0].ID); list[0].URL != want { + t.Errorf("url = %q, want %q", list[0].URL, want) + } +} + +// ─── POST permission gate ──────────────────────────────────────────────────── + +func TestEmojiUpload_MemberIsForbidden(t *testing.T) { + h := newEmojiHarness(t) + rec := h.upload(t, h.memberToken, "wave", pngBytes(t, 64, 64)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (%s)", rec.Code, rec.Body.String()) + } + if len(h.broadcaster.calls) != 0 { + t.Errorf("broadcast fired on a refused upload") + } +} + +func TestEmojiUpload_ManageServerIsEnough(t *testing.T) { + h := newEmojiHarness(t) + rec := h.upload(t, h.adminToken, "wave", pngBytes(t, 64, 64)) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201 (%s)", rec.Code, rec.Body.String()) + } + if len(h.broadcaster.calls) != 1 { + t.Fatalf("broadcast calls = %d, want 1", len(h.broadcaster.calls)) + } + if got := h.broadcaster.calls[0]; len(got) != 1 || got[0].Shortcode != "wave" { + t.Errorf("broadcast payload = %+v, want one :wave:", got) + } +} + +func TestEmojiUpload_Unauthenticated(t *testing.T) { + h := newEmojiHarness(t) + rec := h.upload(t, "", "wave", pngBytes(t, 64, 64)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +// ─── POST validation ───────────────────────────────────────────────────────── + +func TestEmojiUpload_AcceptsEveryAllowedFormat(t *testing.T) { + h := newEmojiHarness(t) + cases := map[string][]byte{ + "apng": pngBytes(t, 128, 128), + "agif": gifBytes(t, 100, 100), + "ajpeg": jpegBytes(t, 64, 32), + "awebp": webpVP8LBytes(48, 48), + } + for shortcode, content := range cases { + rec := h.upload(t, h.ownerToken, shortcode, content) + if rec.Code != http.StatusCreated { + t.Errorf("%s: status = %d, want 201 (%s)", shortcode, rec.Code, rec.Body.String()) + } + } +} + +func TestEmojiUpload_RejectsNonImage(t *testing.T) { + h := newEmojiHarness(t) + // A plain-text body sniffs as text/plain, which is not in the allowlist. + rec := h.upload(t, h.ownerToken, "wave", []byte("this is definitely not an image at all")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (%s)", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "PNG") { + t.Errorf("message = %q, want the format list", rec.Body.String()) + } +} + +func TestEmojiUpload_RejectsSVG(t *testing.T) { + h := newEmojiHarness(t) + svg := []byte(`` + + ``) + rec := h.upload(t, h.ownerToken, "wave", svg) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (%s)", rec.Code, rec.Body.String()) + } +} + +func TestEmojiUpload_RejectsOversizeDimensions(t *testing.T) { + h := newEmojiHarness(t) + rec := h.upload(t, h.ownerToken, "toobig", pngBytes(t, 129, 64)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (%s)", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "128x128") { + t.Errorf("message = %q, want the pixel cap", rec.Body.String()) + } + // Same for height, and for a WebP whose header is what carries the size. + if rec := h.upload(t, h.ownerToken, "tootall", pngBytes(t, 64, 200)); rec.Code != http.StatusBadRequest { + t.Errorf("tall png status = %d, want 400", rec.Code) + } + if rec := h.upload(t, h.ownerToken, "widewebp", webpVP8LBytes(400, 40)); rec.Code != http.StatusBadRequest { + t.Errorf("wide webp status = %d, want 400", rec.Code) + } +} + +func TestEmojiUpload_AcceptsExactlyMaxDimension(t *testing.T) { + h := newEmojiHarness(t) + if rec := h.upload(t, h.ownerToken, "edge", pngBytes(t, 128, 128)); rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201 (%s)", rec.Code, rec.Body.String()) + } +} + +func TestEmojiUpload_RejectsOversizeFile(t *testing.T) { + h := newEmojiHarness(t) + // A 128x128 PNG of pure noise compresses badly enough to blow the 512 KB + // budget while staying inside the pixel cap, which is the case the byte + // limit exists for. + big := make([]byte, 600<<10) + copy(big, pngBytes(t, 128, 128)) + rec := h.upload(t, h.ownerToken, "huge", big) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (%s)", rec.Code, rec.Body.String()) + } +} + +func TestEmojiUpload_RejectsBadShortcode(t *testing.T) { + h := newEmojiHarness(t) + for _, sc := range []string{"", "a", "has space", "dash-es", strings.Repeat("x", 33)} { + rec := h.upload(t, h.ownerToken, sc, pngBytes(t, 32, 32)) + if rec.Code != http.StatusBadRequest { + t.Errorf("shortcode %q: status = %d, want 400 (%s)", sc, rec.Code, rec.Body.String()) + } + } +} + +func TestEmojiUpload_MissingFile(t *testing.T) { + h := newEmojiHarness(t) + rec := h.upload(t, h.ownerToken, "wave", nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (%s)", rec.Code, rec.Body.String()) + } +} + +func TestEmojiUpload_DuplicateShortcodeIsConflict(t *testing.T) { + h := newEmojiHarness(t) + if rec := h.upload(t, h.ownerToken, "wave", pngBytes(t, 32, 32)); rec.Code != http.StatusCreated { + t.Fatalf("first upload status = %d", rec.Code) + } + rec := h.upload(t, h.ownerToken, "WAVE", pngBytes(t, 32, 32)) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409 (%s)", rec.Code, rec.Body.String()) + } + // The losing upload must not leave its bytes behind. + list, err := h.database.ListEmoji(context.Background()) + if err != nil { + t.Fatalf("ListEmoji: %v", err) + } + if len(list) != 1 { + t.Fatalf("len(list) = %d, want 1", len(list)) + } +} + +// ─── GET image ─────────────────────────────────────────────────────────────── + +func TestEmojiImage_ServesStoredBytes(t *testing.T) { + h := newEmojiHarness(t) + content := gifBytes(t, 40, 40) + rec := h.upload(t, h.ownerToken, "wave", content) + if rec.Code != http.StatusCreated { + t.Fatalf("upload status = %d (%s)", rec.Code, rec.Body.String()) + } + var created struct { + ID int64 `json:"id"` + URL string `json:"url"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + got := h.do(t, http.MethodGet, created.URL, h.memberToken, nil, "") + if got.Code != http.StatusOK { + t.Fatalf("image status = %d, want 200", got.Code) + } + if ct := got.Header().Get("Content-Type"); ct != "image/gif" { + t.Errorf("Content-Type = %q, want image/gif", ct) + } + if got.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Errorf("missing nosniff header") + } + if !bytes.Equal(got.Body.Bytes(), content) { + t.Errorf("served %d bytes, want the %d uploaded", got.Body.Len(), len(content)) + } +} + +func TestEmojiImage_RequiresAuth(t *testing.T) { + h := newEmojiHarness(t) + rec := h.do(t, http.MethodGet, "/api/v1/emoji/1/image", "", nil, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestEmojiImage_UnknownIDIs404(t *testing.T) { + h := newEmojiHarness(t) + for _, path := range []string{"/api/v1/emoji/999/image", "/api/v1/emoji/abc/image", "/api/v1/emoji/0/image"} { + rec := h.do(t, http.MethodGet, path, h.memberToken, nil, "") + if rec.Code != http.StatusNotFound { + t.Errorf("%s: status = %d, want 404", path, rec.Code) + } + } +} + +// ─── DELETE ────────────────────────────────────────────────────────────────── + +func TestEmojiDelete_RemovesRowFileAndBroadcasts(t *testing.T) { + h := newEmojiHarness(t) + rec := h.upload(t, h.ownerToken, "wave", pngBytes(t, 32, 32)) + if rec.Code != http.StatusCreated { + t.Fatalf("upload status = %d", rec.Code) + } + var created struct { + ID int64 `json:"id"` + URL string `json:"url"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal: %v", err) + } + stored, err := h.database.GetEmoji(context.Background(), created.ID) + if err != nil || stored == nil { + t.Fatalf("GetEmoji = %v, %v", stored, err) + } + + del := h.do(t, http.MethodDelete, fmt.Sprintf("/api/v1/emoji/%d", created.ID), h.ownerToken, nil, "") + if del.Code != http.StatusNoContent { + t.Fatalf("delete status = %d, want 204 (%s)", del.Code, del.Body.String()) + } + + if row, gErr := h.database.GetEmoji(context.Background(), created.ID); gErr != nil || row != nil { + t.Errorf("row still present after delete: %v, %v", row, gErr) + } + if f, oErr := h.store.Open(stored.StoredAs); oErr == nil { + _ = f.Close() + t.Errorf("stored file %q survived the delete", stored.StoredAs) + } + if len(h.broadcaster.calls) != 2 { + t.Fatalf("broadcast calls = %d, want 2 (create + delete)", len(h.broadcaster.calls)) + } + if last := h.broadcaster.calls[1]; len(last) != 0 { + t.Errorf("post-delete broadcast = %+v, want empty", last) + } + // The image route follows the row. + if img := h.do(t, http.MethodGet, created.URL, h.memberToken, nil, ""); img.Code != http.StatusNotFound { + t.Errorf("image after delete = %d, want 404", img.Code) + } +} + +func TestEmojiDelete_MemberIsForbidden(t *testing.T) { + h := newEmojiHarness(t) + rec := h.upload(t, h.ownerToken, "wave", pngBytes(t, 32, 32)) + if rec.Code != http.StatusCreated { + t.Fatalf("upload status = %d", rec.Code) + } + var created struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + del := h.do(t, http.MethodDelete, fmt.Sprintf("/api/v1/emoji/%d", created.ID), h.memberToken, nil, "") + if del.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (%s)", del.Code, del.Body.String()) + } + if row, err := h.database.GetEmoji(context.Background(), created.ID); err != nil || row == nil { + t.Errorf("refused delete removed the row anyway") + } +} + +func TestEmojiDelete_UnknownIDIs404(t *testing.T) { + h := newEmojiHarness(t) + rec := h.do(t, http.MethodDelete, "/api/v1/emoji/999", h.ownerToken, nil, "") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (%s)", rec.Code, rec.Body.String()) + } +} + +func TestEmojiDelete_BadIDIs400(t *testing.T) { + h := newEmojiHarness(t) + rec := h.do(t, http.MethodDelete, "/api/v1/emoji/not-a-number", h.ownerToken, nil, "") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (%s)", rec.Code, rec.Body.String()) + } +} + +// ─── WebP header parsing ───────────────────────────────────────────────────── + +func TestWebPDimensions_AllChunkFlavours(t *testing.T) { + // VP8L is covered by webpVP8LBytes; build a VP8 (lossy) and a VP8X + // (extended) container too, since each encodes its size differently. + vp8 := make([]byte, 30) + copy(vp8[0:4], "RIFF") + copy(vp8[8:12], "WEBP") + copy(vp8[12:16], "VP8 ") + vp8[23], vp8[24], vp8[25] = 0x9d, 0x01, 0x2a + binary.LittleEndian.PutUint16(vp8[26:28], 100) + binary.LittleEndian.PutUint16(vp8[28:30], 80) + + vp8x := make([]byte, 30) + copy(vp8x[0:4], "RIFF") + copy(vp8x[8:12], "WEBP") + copy(vp8x[12:16], "VP8X") + vp8x[24], vp8x[25], vp8x[26] = 0x63, 0x00, 0x00 // width-1 = 99 + vp8x[27], vp8x[28], vp8x[29] = 0x4F, 0x00, 0x00 // height-1 = 79 + + cases := map[string]struct { + raw []byte + w, h int + }{ + "lossy (VP8)": {vp8, 100, 80}, + "extended (VP8X)": {vp8x, 100, 80}, + "lossless (VP8L)": {webpVP8LBytes(100, 80), 100, 80}, + } + for name, c := range cases { + w, h, err := api.WebPDimensionsForTest(c.raw) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + if w != c.w || h != c.h { + t.Errorf("%s: got %dx%d, want %dx%d", name, w, h, c.w, c.h) + } + } +} + +func TestWebPDimensions_RejectsMalformed(t *testing.T) { + bad := [][]byte{ + nil, + []byte("RIFF"), + append([]byte("RIFF0000NOTWVP8L"), make([]byte, 14)...), + append([]byte("RIFF0000WEBPXXXX"), make([]byte, 14)...), + append([]byte("RIFF0000WEBPVP8L"), make([]byte, 4)...), // truncated + } + for i, raw := range bad { + if _, _, err := api.WebPDimensionsForTest(raw); err == nil { + t.Errorf("case %d: want error", i) + } + } +} diff --git a/Server/api/export_test.go b/Server/api/export_test.go index ecd4bdbf..99008d0e 100644 --- a/Server/api/export_test.go +++ b/Server/api/export_test.go @@ -49,6 +49,11 @@ func HandleLiveKitHealthForTest(healthCheck func(context.Context) (bool, error)) // IsPrivateIPForTest exposes isPrivateIP for use in external tests. var IsPrivateIPForTest = isPrivateIP +// WebPDimensionsForTest exposes the hand-rolled WebP header reader. It has no +// standard-library counterpart to cross-check it against, so the chunk-flavour +// cases are tested directly rather than only through the upload handler. +var WebPDimensionsForTest = webpDimensions + // SetGIFUpstreamForTest points the GIF proxy at a stub upstream and returns a // restore func. The production transport uses the SSRF-guarded dialer, which // refuses loopback addresses, so tests must supply their own client too. diff --git a/Server/api/image_dimensions_fuzz_test.go b/Server/api/image_dimensions_fuzz_test.go new file mode 100644 index 00000000..914a3537 --- /dev/null +++ b/Server/api/image_dimensions_fuzz_test.go @@ -0,0 +1,149 @@ +package api + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "testing" + "time" +) + +// fuzzWebPVP8L builds a minimal RIFF/WEBP/VP8L container of the given size. +func fuzzWebPVP8L(w, h int) []byte { + buf := make([]byte, 30) + copy(buf[0:4], "RIFF") + binary.LittleEndian.PutUint32(buf[4:8], uint32(len(buf)-8)) + copy(buf[8:12], "WEBP") + copy(buf[12:16], "VP8L") + binary.LittleEndian.PutUint32(buf[16:20], uint32(len(buf)-20)) + buf[20] = 0x2F + bits := uint32(w-1) | uint32(h-1)<<14 + binary.LittleEndian.PutUint32(buf[21:25], bits) + return buf +} + +// fuzzWebPVP8 builds a minimal RIFF/WEBP/VP8 (lossy) container. +func fuzzWebPVP8(w, h int) []byte { + buf := make([]byte, 30) + copy(buf[0:4], "RIFF") + copy(buf[8:12], "WEBP") + copy(buf[12:16], "VP8 ") + buf[23], buf[24], buf[25] = 0x9d, 0x01, 0x2a + binary.LittleEndian.PutUint16(buf[26:28], uint16(w)&0x3FFF) + binary.LittleEndian.PutUint16(buf[28:30], uint16(h)&0x3FFF) + return buf +} + +// fuzzWebPVP8X builds a minimal RIFF/WEBP/VP8X (extended) container. +func fuzzWebPVP8X(w, h int) []byte { + buf := make([]byte, 30) + copy(buf[0:4], "RIFF") + copy(buf[8:12], "WEBP") + copy(buf[12:16], "VP8X") + w1, h1 := uint32(w-1), uint32(h-1) + buf[24], buf[25], buf[26] = byte(w1), byte(w1>>8), byte(w1>>16) + buf[27], buf[28], buf[29] = byte(h1), byte(h1>>8), byte(h1>>16) + return buf +} + +func fuzzPNGBytes(w, h int) []byte { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + img.Set(0, 0, color.RGBA{R: 255, A: 255}) + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes() +} + +func fuzzGIFBytes(w, h int) []byte { + img := image.NewPaletted(image.Rect(0, 0, w, h), color.Palette{color.Black, color.White}) + var buf bytes.Buffer + _ = gif.Encode(&buf, img, nil) + return buf.Bytes() +} + +func fuzzJPEGBytes(w, h int) []byte { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + var buf bytes.Buffer + _ = jpeg.Encode(&buf, img, nil) + return buf.Bytes() +} + +// FuzzImageDimensions is the prime crash target: hand-rolled and stdlib +// header parsing over completely untrusted bytes, exactly what a malicious +// emoji/avatar upload delivers. Every corpus entry is run through all four +// supported mime types (not just the one it happens to be valid for) so a +// truncated PNG is also thrown at the JPEG/GIF/WebP paths and vice versa. +// +// The only allowed outcomes are: an error, or a (width, height) that is +// strictly positive. Anything else -- a panic, a hang, or a non-positive +// dimension slipping past as "success" -- is a bug: the caller in +// emoji_handler.go trusts a non-error result enough to compare it against +// maxEmojiDimension without re-validating its sign. +func FuzzImageDimensions(f *testing.F) { + seeds := [][]byte{ + nil, + {}, + {0}, + {0, 0, 0, 0}, + []byte("RIFF"), + []byte("RIFFxxxxWEBP"), + []byte("RIFFxxxxWEBPVP8 "), + []byte("RIFFxxxxWEBPVP8L"), + []byte("RIFFxxxxWEBPVP8X"), + []byte("RIFFxxxxWEBPXXXX"), + fuzzPNGBytes(1, 1), + fuzzPNGBytes(128, 128), + fuzzGIFBytes(1, 1), + fuzzGIFBytes(128, 128), + fuzzJPEGBytes(1, 1), + fuzzJPEGBytes(128, 128), + fuzzWebPVP8(100, 80), + fuzzWebPVP8(16383, 16383), // max 14-bit dimension + fuzzWebPVP8L(100, 80), + fuzzWebPVP8L(16384, 16384), // max 14-bit+1 dimension + fuzzWebPVP8X(100, 80), + fuzzWebPVP8X(16777216, 16777216), // max 24-bit+1 dimension + } + // Truncate every seed at each prefix length up to 32 bytes (where every + // format's fixed header lives) and then more coarsely beyond that: the + // classic "header parser unchecked slice index" crasher lives in exactly + // these cuts, but walking every single offset of a 128x128 PNG bloats the + // corpus enough to stall the mutation phase for no extra coverage. + for _, s := range seeds { + f.Add(append([]byte(nil), s...)) + for cut := 0; cut < len(s) && cut <= 32; cut++ { + f.Add(append([]byte(nil), s[:cut]...)) + } + for cut := 40; cut < len(s); cut += 8 { + f.Add(append([]byte(nil), s[:cut]...)) + } + } + + mimeTypes := []string{"image/png", "image/jpeg", "image/gif", "image/webp"} + + f.Fuzz(func(t *testing.T, raw []byte) { + done := make(chan struct{}) + go func() { + defer close(done) + for _, mt := range mimeTypes { + w, h, err := imageDimensions(raw, mt) + if err == nil && (w <= 0 || h <= 0) { + t.Errorf("imageDimensions(%d bytes, %s) returned non-positive size %dx%d with no error", len(raw), mt, w, h) + } + } + w, h, err := webpDimensions(raw) + if err == nil && (w <= 0 || h <= 0) { + t.Errorf("webpDimensions(%d bytes) returned non-positive size %dx%d with no error", len(raw), w, h) + } + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("imageDimensions/webpDimensions hung on %d-byte input: %x", len(raw), raw) + } + }) +} diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index 9646fcb7..f74c9c89 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -1122,7 +1122,10 @@ CREATE TABLE IF NOT EXISTS users ( banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT, - identity_public_key TEXT + identity_public_key TEXT, + display_name TEXT, + about TEXT, + custom_status TEXT ); CREATE TABLE IF NOT EXISTS sessions ( @@ -1185,7 +1188,9 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, - voice_max_video INTEGER NOT NULL DEFAULT 0 + voice_max_video INTEGER NOT NULL DEFAULT 0, + nsfw INTEGER NOT NULL DEFAULT 0, + is_group INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channel_overrides ( @@ -1197,6 +1202,14 @@ CREATE TABLE IF NOT EXISTS channel_overrides ( UNIQUE(channel_id, role_id) ); +CREATE TABLE IF NOT EXISTS channel_user_overrides ( + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (channel_id, user_id) +); + CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, @@ -1206,8 +1219,15 @@ CREATE TABLE IF NOT EXISTS messages ( edited_at TEXT, deleted INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, - timestamp TEXT NOT NULL DEFAULT (datetime('now')) + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + mentions_everyone INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS message_mentions ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, mentioned_user_id) +); + CREATE TABLE IF NOT EXISTS dm_participants ( channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index 3106bd79..74f0749b 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -1,18 +1,25 @@ package api import ( + "bytes" "encoding/base64" "encoding/json" "fmt" + "io" + "log/slog" "net/http" "net/url" "strings" "time" + "unicode" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/service" + "github.com/owncord/server/storage" + "github.com/owncord/server/ws" ) // ─── Request / Response types ──────────────────────────────────────────────── @@ -24,6 +31,11 @@ type updateProfileRequest struct { Username string `json:"username"` Avatar *string `json:"avatar"` IdentityPublicKey *string `json:"identity_public_key"` + // DisplayName and About are omitted = unchanged, "" = cleared. Both are + // sanitized and length-checked in UserService, which is also the path a + // non-REST caller would take. + DisplayName *string `json:"display_name"` + About *string `json:"about"` } // changePasswordRequest is the JSON body for PUT /api/v1/users/me/password. @@ -52,12 +64,16 @@ type sessionsListResponse struct { // ProfileBroadcaster is the interface the profile handler uses to notify // connected WebSocket clients about profile changes. type ProfileBroadcaster interface { - BroadcastUserUpdate(userID int64, username string, avatar *string, identityPublicKey *string) + BroadcastUserUpdate(u ws.UserUpdate) } // MountProfileRoutes registers user profile management endpoints. // All routes require authentication. trustedProxies is used for rate limiting. -func MountProfileRoutes(r chi.Router, database *db.DB, svc *service.Services, limiter *auth.RateLimiter, trustedProxies []string, broadcaster ProfileBroadcaster) { +// +// store may be nil, in which case the avatar-upload route is not registered — +// a server with no storage backend has nowhere to put the bytes, and a route +// that 500s on every call is worse than one that 404s. +func MountProfileRoutes(r chi.Router, database *db.DB, svc *service.Services, store *storage.Storage, limiter *auth.RateLimiter, trustedProxies []string, broadcaster ProfileBroadcaster) { r.Route("/api/v1/users/me", func(r chi.Router) { r.Use(AuthMiddleware(database)) @@ -67,6 +83,11 @@ func MountProfileRoutes(r chi.Router, database *db.DB, svc *service.Services, li r.With(RateLimitMiddleware(limiter, profilePasswordRateLimitPerMinute, time.Minute, trustedProxies)). Put("/password", handleChangePassword(svc, limiter)) + if store != nil { + r.With(MaxBodySize(avatarMaxBodySize)). + Post("/avatar", handleUploadAvatar(database, svc, store, limiter, broadcaster)) + } + r.Get("/sessions", handleListSessions(svc)) r.Delete("/sessions/{id}", handleRevokeSession(svc)) }) @@ -109,6 +130,32 @@ func validateAvatarURL(avatar string) error { return nil } +// validateDisplayName rejects a nickname that would render as something other +// than what it says. Length and emptiness are the service's job (empty clears +// the field); this is the character-class check auth.ValidateUsername applies +// for the same reason — a display name stands in for a username on every +// message row, so a bidi override or a control character in one is a spoof. +func validateDisplayName(name string) error { + for _, r := range name { + if unicode.IsControl(r) || unicode.In(r, unicode.Cf) { + return fmt.Errorf("display_name must not contain control or invisible characters") + } + } + return nil +} + +// allowedAvatarMIME is the set of image types an avatar may be, matched against +// the type sniffed from the file's own bytes. GIF is absent (an animated +// avatar in every message row is a distraction the renderer cannot opt out of) +// and so is SVG, for the same reason emoji refuse it: it is markup with script +// and external-fetch capability, and an avatar is rendered inline by +// definition. +var allowedAvatarMIME = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/webp": true, +} + // ─── Handlers ──────────────────────────────────────────────────────────────── // handleUpdateProfile processes PATCH /api/v1/users/me. @@ -156,6 +203,19 @@ func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster) req.Avatar = &trimmed } + // display_name gets the same username-shaped scrutiny beyond length: + // it is rendered wherever a username is, so control characters and + // bidi overrides are exactly as unwelcome here. Length, sanitization + // and the empty-clears-it rule live in UserService. + if req.DisplayName != nil { + if err := validateDisplayName(*req.DisplayName); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", Message: err.Error(), + }) + return + } + } + // Validate the identity key before any write so the request is // all-or-nothing. if req.IdentityPublicKey != nil { @@ -169,7 +229,12 @@ func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster) req.IdentityPublicKey = &trimmed } - updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, req.Username, req.Avatar) + updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{ + Username: req.Username, + Avatar: req.Avatar, + DisplayName: req.DisplayName, + About: req.About, + }) if err != nil { writeServiceError(r.Context(), w, err) return @@ -183,15 +248,29 @@ func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster) } } - // Broadcast profile change to all connected WebSocket clients. - if broadcaster != nil { - broadcaster.BroadcastUserUpdate(updated.ID, updated.Username, updated.Avatar, updated.IdentityPublicKey) - } + broadcastUserUpdate(broadcaster, updated) writeJSON(w, http.StatusOK, toUserResponse(updated)) } } +// broadcastUserUpdate pushes a profile snapshot to every connected client. +// Every profile mutation goes through it so a new one cannot ship half the +// fields — user_update replaces the client's copy wholesale. +func broadcastUserUpdate(broadcaster ProfileBroadcaster, u *db.User) { + if broadcaster == nil || u == nil { + return + } + broadcaster.BroadcastUserUpdate(ws.UserUpdate{ + UserID: u.ID, + Username: u.Username, + Avatar: u.Avatar, + DisplayName: u.DisplayName, + About: u.About, + IdentityPublicKey: u.IdentityPublicKey, + }) +} + // handleChangePassword processes PUT /api/v1/users/me/password. func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -360,3 +439,158 @@ func handleRevokeSession(svc *service.Services) http.HandlerFunc { w.WriteHeader(http.StatusNoContent) } } + +// handleUploadAvatar processes POST /api/v1/users/me/avatar (multipart: `file`). +// +// The bytes land in the ordinary attachments table with no channel, and the +// user's avatar column is pointed at /api/v1/files/{id}. That is what makes +// the picture readable: an unlinked attachment is private to its uploader, and +// handleServeFile additionally admits one that some user's avatar currently +// points at — so an avatar is public exactly while it is in use and stops +// being readable the moment it is replaced. +// +// PATCH /users/me still takes an https:// URL; this route is the other way to +// set the same field, and both end at the same column. +func handleUploadAvatar( + database *db.DB, + svc *service.Services, + store *storage.Storage, + limiter *auth.RateLimiter, + broadcaster ProfileBroadcaster, +) 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 + } + + if limiter != nil && !limiter.Allow(auth.Key("avatar_upload", user.ID), avatarUploadRateLimitPerMinute, time.Minute) { + writeJSON(w, http.StatusTooManyRequests, errorResponse{ + Error: "RATE_LIMITED", Message: "avatar upload rate limit exceeded, try again later", + }) + return + } + + // Bound the body before the multipart parser touches it: the route + // carries MaxBodySize too, but the parser is what turns an unbounded + // body into heap, so the handler states its own limit. + r.Body = http.MaxBytesReader(w, r.Body, avatarMaxBodySize) + if err := r.ParseMultipartForm(avatarMultipartMemoryLimit); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "invalid multipart form", + }) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "missing file field", + }) + return + } + defer file.Close() //nolint:errcheck + + // Read one byte past the cap so "exactly at the limit" passes and "one + // byte over" is caught, without buffering an unbounded body. + raw, err := io.ReadAll(io.LimitReader(file, maxAvatarFileBytes+1)) + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "failed to read uploaded file", + }) + return + } + if int64(len(raw)) > maxAvatarFileBytes { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: fmt.Sprintf("avatar must be at most %d KB", maxAvatarFileBytes>>10), + }) + return + } + + // Never trust the client's Content-Type — sniff the bytes. + mimeType := http.DetectContentType(raw) + if !allowedAvatarMIME[mimeType] { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "avatar must be a PNG, JPEG or WebP image", + }) + return + } + + width, height, err := imageDimensions(raw, mimeType) + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: "could not read image dimensions", + }) + return + } + // Measured from the sniffed image, not from anything the client said. + // The client crops to a square before uploading; the server does not + // re-encode (that would mean decoding and re-compressing every upload + // to change nothing a CSS circle mask does not already do), it just + // refuses a picture too big to be an avatar. + if width <= 0 || height <= 0 || width > maxAvatarDimension || height > maxAvatarDimension { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", + Message: fmt.Sprintf("avatar must be at most %dx%d pixels (got %dx%d)", maxAvatarDimension, maxAvatarDimension, width, height), + }) + return + } + + fileID := uuid.New().String() + written, saveErr := store.Save(fileID, bytes.NewReader(raw)) + if saveErr != nil { + slog.Warn("avatar upload rejected by storage", "error", saveErr) + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "BAD_REQUEST", Message: fmt.Sprintf("upload rejected: %s", saveErr), + }) + return + } + + filename := sanitizeUploadFilename(header.Filename) + if err := database.CreateAttachment(r.Context(), fileID, user.ID, filename, fileID, mimeType, written, &width, &height); err != nil { + if delErr := store.Delete(fileID); delErr != nil { + slog.Error("failed to clean up orphaned avatar file", "stored_as", fileID, "error", delErr) + } + slog.Error("failed to create avatar attachment record", "error", err) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", Message: "failed to save avatar", + }) + return + } + + avatarURL := service.AvatarFileURL(fileID) + updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{ + Username: user.Username, + Avatar: &avatarURL, + }) + if err != nil { + // The column never moved, so the file and its row are orphans. + if delErr := store.Delete(fileID); delErr != nil { + slog.Error("failed to clean up orphaned avatar file", "stored_as", fileID, "error", delErr) + } + writeServiceError(r.Context(), w, err) + return + } + + // The previous avatar's bytes are deliberately left on disk: a message + // that was rendered with it may still be cached client-side, and a + // blind delete here would race any request already in flight for it. + // Reclaiming them is an operator-side sweep, not a request-path action. + broadcastUserUpdate(broadcaster, updated) + + slog.Info("avatar uploaded", "user_id", user.ID, "id", fileID, "size", written, "mime", mimeType) + writeJSON(w, http.StatusCreated, uploadResponse{ + ID: fileID, + Filename: filename, + Size: written, + Mime: mimeType, + URL: avatarURL, + Width: &width, + Height: &height, + }) + } +} diff --git a/Server/api/profile_handler_fuzz_test.go b/Server/api/profile_handler_fuzz_test.go new file mode 100644 index 00000000..6a896284 --- /dev/null +++ b/Server/api/profile_handler_fuzz_test.go @@ -0,0 +1,109 @@ +package api + +import ( + "net/url" + "strings" + "testing" + "unicode" +) + +// FuzzValidateAvatarURL checks validateAvatarURL against the rule its doc +// comment states: avatar must be either empty, or a URL no longer than +// maxAvatarURLLen characters that parses with scheme "https" and a non-empty +// host. This is the prime target for an active-content escape — an accepted +// "avatar" URL that is actually javascript:, data:, or otherwise not a real +// https:// origin would let a client render/execute it wherever avatars are +// displayed. +func FuzzValidateAvatarURL(f *testing.F) { + seeds := []string{ + "", + "https://example.com/avatar.png", + "https://example.com", + "http://example.com/avatar.png", + "javascript:alert(1)", + "JavaScript:alert(1)", + "data:text/html,", + "data:image/png;base64,iVBORw0KGgo=", + "vbscript:msgbox(1)", + "https://", + "https:///avatar.png", + "https:example.com", + " https://example.com/avatar.png", + "https://example.com/avatar.png ", + "//example.com/avatar.png", + "file:///etc/passwd", + "https://user:pass@example.com/avatar.png", + "https://例え.com/avatar.png", + strings.Repeat("a", 600), + "https://" + strings.Repeat("a", 600) + ".com/x.png", + "https:// evil.com", + "ht!tp://bad url", + "https:\t//example.com", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, avatar string) { + err := validateAvatarURL(avatar) + if err != nil { + return + } + if avatar == "" { + return + } + if len(avatar) > maxAvatarURLLen { + t.Fatalf("validateAvatarURL(%q) = nil, but length %d exceeds maxAvatarURLLen %d", avatar, len(avatar), maxAvatarURLLen) + } + parsed, perr := url.Parse(avatar) + if perr != nil { + t.Fatalf("validateAvatarURL(%q) = nil, but url.Parse fails: %v", avatar, perr) + } + if parsed.Scheme != "https" { + t.Fatalf("validateAvatarURL(%q) = nil, but parsed scheme is %q, not https (active-content/off-scheme escape)", avatar, parsed.Scheme) + } + if parsed.Host == "" { + t.Fatalf("validateAvatarURL(%q) = nil, but parsed host is empty", avatar) + } + }) +} + +// FuzzValidateDisplayName checks validateDisplayName against the rule its +// doc comment states: no control characters and no invisible (Cf) formatting +// characters, since a display name renders wherever a username does and a +// bidi override or control character there is a spoofing vector. +func FuzzValidateDisplayName(f *testing.F) { + seeds := []string{ + "", + "Normal Name", + "emoji😀name", + "日本語", + "name\x00null", + "name\ttab", + "name\nnewline", + "zero\u200bwidth", // ZERO WIDTH SPACE + "bidi\u202eoverride", + "\u202ereversed\u202c", + strings.Repeat("a", 1000), + "\u200bname\u200b", + "\u202eevil\u202c", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, name string) { + err := validateDisplayName(name) + if err != nil { + return + } + for _, r := range name { + if unicode.IsControl(r) { + t.Fatalf("validateDisplayName(%q) = nil, but contains control rune %q", name, r) + } + if unicode.In(r, unicode.Cf) { + t.Fatalf("validateDisplayName(%q) = nil, but contains invisible (Cf) rune %q", name, r) + } + } + }) +} diff --git a/Server/api/profile_handler_test.go b/Server/api/profile_handler_test.go index af6ec30f..76bda2bc 100644 --- a/Server/api/profile_handler_test.go +++ b/Server/api/profile_handler_test.go @@ -23,7 +23,7 @@ func buildProfileRouter(database *db.DB) http.Handler { r := chi.NewRouter() limiter := auth.NewRateLimiter() svc := service.New(database, limiter) - api.MountProfileRoutes(r, database, svc, limiter, nil, nil) + api.MountProfileRoutes(r, database, svc, nil, limiter, nil, nil) return r } diff --git a/Server/api/router.go b/Server/api/router.go index 4222d2ec..567f50fe 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -109,8 +109,8 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Invite management routes (require MANAGE_INVITES permission). MountInviteRoutes(r, database, svc) - // Channel and message REST routes. - MountChannelRoutes(r, database, svc, limiter, cfg.Server.TrustedProxies) + // Channel and message REST routes are mounted after hub creation (below) + // so the hub can fan a bulk delete out as one chat_bulk_deleted event. // GIF proxy — keeps the Klipy API key server-side. Mounted unconditionally; // with no key configured the endpoints answer 503 GIF_DISABLED so the @@ -208,12 +208,30 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Profile routes: update profile, change password, session management. // Mounted after hub creation so the hub can broadcast user_update events. - MountProfileRoutes(r, database, svc, limiter, cfg.Server.TrustedProxies, hub) + // A storage failure leaves store unusable, so the avatar-upload route is + // simply not registered; the rest of the profile surface is unaffected. + profileStore := store + if storeErr != nil { + profileStore = nil + } + MountProfileRoutes(r, database, svc, profileStore, limiter, cfg.Server.TrustedProxies, hub) // DM (direct message) REST routes — mounted after hub creation so the // hub can send real-time dm_channel_close events to WebSocket clients. MountDMRoutes(r, database, svc, hub) + // Channel and message REST routes — mounted after hub creation so a + // message purge can broadcast chat_bulk_deleted to the channel. + MountChannelRoutes(r, database, svc, limiter, cfg.Server.TrustedProxies, hub) + + // Custom emoji REST routes — mounted after hub creation so an upload or a + // delete can fan the new set out as an emoji_update. Requires the same file + // storage the attachment routes use; without it the emoji endpoints are not + // mounted at all (a 404 the client reads as "this server has no emoji"). + if storeErr == nil { + MountEmojiRoutes(r, database, svc, store, limiter, hub) + } + // H-8: Connectivity diagnostics restricted to admin users only. // Exposes Go runtime version and LiveKit node IP which aid targeted attacks. r.With(AuthMiddleware(database), @@ -246,7 +264,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Admin panel: static files + REST API (Phase 6). // Restrict /admin to configured CIDRs (default: private networks only). u := updater.NewUpdater(ver, cfg.GitHub.Token, cfg.GitHub.Owner, cfg.GitHub.Repo) - adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation, + adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation, svc.Roles, admin.SetupOptions{ConfigPath: config.DefaultPath, RunningCfg: cfg}) r.Group(func(r chi.Router) { r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies)) diff --git a/Server/api/testdata/fuzz/FuzzImageDimensions/08ce89b31b17a283 b/Server/api/testdata/fuzz/FuzzImageDimensions/08ce89b31b17a283 new file mode 100644 index 00000000..6dcf0b42 --- /dev/null +++ b/Server/api/testdata/fuzz/FuzzImageDimensions/08ce89b31b17a283 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("GIF87a00\x00\x00000") diff --git a/Server/api/testdata/fuzz/FuzzImageDimensions/90947704e4047f07 b/Server/api/testdata/fuzz/FuzzImageDimensions/90947704e4047f07 new file mode 100644 index 00000000..dd6b3545 --- /dev/null +++ b/Server/api/testdata/fuzz/FuzzImageDimensions/90947704e4047f07 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("RIFF0000WEBPVP8 0000000\x9d\x01*00\x00\x00") diff --git a/Server/api/testdata/fuzz/FuzzSanitizeUploadFilename/slash-survives-as-path-separator b/Server/api/testdata/fuzz/FuzzSanitizeUploadFilename/slash-survives-as-path-separator new file mode 100644 index 00000000..e4946606 --- /dev/null +++ b/Server/api/testdata/fuzz/FuzzSanitizeUploadFilename/slash-survives-as-path-separator @@ -0,0 +1,2 @@ +go test fuzz v1 +string("/") diff --git a/Server/api/testdata/fuzz/FuzzSanitizeUploadFilename/truncation-splits-multibyte-rune b/Server/api/testdata/fuzz/FuzzSanitizeUploadFilename/truncation-splits-multibyte-rune new file mode 100644 index 00000000..8a4b2d0c --- /dev/null +++ b/Server/api/testdata/fuzz/FuzzSanitizeUploadFilename/truncation-splits-multibyte-rune @@ -0,0 +1,2 @@ +go test fuzz v1 +string("éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé") diff --git a/Server/api/upload_filename_fuzz_test.go b/Server/api/upload_filename_fuzz_test.go new file mode 100644 index 00000000..76306db0 --- /dev/null +++ b/Server/api/upload_filename_fuzz_test.go @@ -0,0 +1,79 @@ +package api + +import ( + "strings" + "testing" + "unicode" + "unicode/utf8" +) + +// FuzzSanitizeUploadFilename hunts for inputs where sanitizeUploadFilename's +// output violates the safety contract its callers rely on: the returned name +// is later used both as a display string and pre-filled into a native save +// dialog on the downloading client, so it must never smuggle a path +// separator, a control/bidi-override character, or invalid UTF-8 (the byte +// truncation to maxUploadFilenameLength is the prime suspect for splitting a +// multibyte rune in half). +func FuzzSanitizeUploadFilename(f *testing.F) { + seeds := []string{ + "", + ".", + "..", + "...", + "/", + "\\", + "a/b/c", + "a\\b\\c", + "/etc/passwd", + "..\\..\\windows\\system32", + "C:\\Windows\\System32\\evil.exe", + "normal.txt", + " leading-space.txt", + "trailing-space.txt ", + "\t\n\r", + "\x00\x01\x02", + "file\x00name.txt", + "\u202Eexe.txt\u202Cgnp.jpg", // RTL override trick + "\u2066\u2069", + strings.Repeat("a", 300), + strings.Repeat("é", 200), // 2-byte UTF-8 rune repeated, truncation-prone + strings.Repeat("😀", 100), // 4-byte rune repeated + strings.Repeat("a", 254) + "é", // boundary: truncation splits the multibyte rune + strings.Repeat("a", 253) + "😀", + strings.Repeat("a", 255) + "x", + "file/../../etc/passwd", + "a" + string(rune(0x202E)) + "b", + "\ufeff.txt", // BOM + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, name string) { + out := sanitizeUploadFilename(name) + + if strings.ContainsAny(out, "/\\") { + t.Fatalf("sanitizeUploadFilename(%q) = %q contains a path separator", name, out) + } + if out == "" { + t.Fatalf("sanitizeUploadFilename(%q) = %q is empty", name, out) + } + if out == "." || out == ".." { + t.Fatalf("sanitizeUploadFilename(%q) = %q is a reserved dot-name", name, out) + } + if !utf8.ValidString(out) { + t.Fatalf("sanitizeUploadFilename(%q) = %q is not valid UTF-8 (bytes: %x)", name, out, out) + } + if len(out) > maxUploadFilenameLength { + t.Fatalf("sanitizeUploadFilename(%q) = %q has length %d > max %d", name, out, len(out), maxUploadFilenameLength) + } + for _, r := range out { + if unicode.IsControl(r) { + t.Fatalf("sanitizeUploadFilename(%q) = %q contains control char %U", name, out, r) + } + if unicode.In(r, unicode.Cf) { + t.Fatalf("sanitizeUploadFilename(%q) = %q contains bidi/format char %U", name, out, r) + } + } + }) +} diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index 41145c3c..066896da 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -15,6 +15,7 @@ import ( "strings" "time" "unicode" + "unicode/utf8" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -48,7 +49,8 @@ func sanitizeUploadFilename(name string) string { if i := strings.LastIndexByte(name, '\\'); i >= 0 { name = name[i+1:] } - // Remove control characters and invisible formatting characters. + // Remove control characters, invisible formatting characters, and any + // residual forward slash. var sb strings.Builder for _, r := range name { // unicode.Cf covers the bidi overrides (U+202A–U+202E, U+2066–U+2069): @@ -57,15 +59,26 @@ func sanitizeUploadFilename(name string) string { // member of the channel while really being an executable script — and // the same string is what the native save dialog pre-fills. This is the // rule auth.ValidateUsername already applies to usernames. - if unicode.IsControl(r) || unicode.In(r, unicode.Cf) { + // + // A forward slash is dropped too: filepath.Base("/") returns "/" (root + // is its own basename), so an upload literally named "/" would otherwise + // slip through the reserved-name check below with a path separator + // intact. Any residual '/' is unsafe as a basename, so strip it here. + if unicode.IsControl(r) || unicode.In(r, unicode.Cf) || r == '/' { continue } sb.WriteRune(r) } name = strings.TrimSpace(sb.String()) - // Truncate to 255 characters (filesystem limit). + // Truncate to the filesystem limit. Slicing by byte offset can land in the + // middle of a multibyte rune, so trim back to the last full rune to keep the + // result valid UTF-8 (an invalid name misbehaves in JSON encoding, on disk, + // and in the client's download-name handling). if len(name) > maxUploadFilenameLength { name = name[:maxUploadFilenameLength] + for len(name) > 0 && !utf8.ValidString(name) { + name = name[:len(name)-1] + } } if name == "" || name == "." || name == ".." { name = "unnamed" @@ -257,17 +270,29 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s if !isAdmin { if aa.ChannelID == nil { + // An unlinked attachment that some user's avatar points at is + // readable by every authenticated user: an avatar has to be + // visible to the people who see the messages it sits next to. + // The check is by the exact URL the column stores, so the file + // stops being public the instant the avatar is replaced. + isAvatar, avatarErr := database.IsAvatarFileURL(r.Context(), service.AvatarFileURL(fileID)) + if avatarErr != nil { + slog.Error("failed to check avatar file", "id", fileID, "error", avatarErr) + } + switch { + case isAvatar: + // Public while in use — fall through to serving. // Unlinked attachment — only the uploader may access. // M-2: Legacy rows (NULL uploader_id) are now denied rather than // served to any authenticated user. - if aa.UploaderID == nil { + case aa.UploaderID == nil: slog.Warn("legacy attachment access denied (NULL uploader_id)", "id", fileID) writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "you do not have access to this file", }) return - } else if user == nil || *aa.UploaderID != user.ID { + case user == nil || *aa.UploaderID != user.ID: writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "you do not have access to this file", diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go index e3623d50..200a37c5 100644 --- a/Server/api/upload_handler_test.go +++ b/Server/api/upload_handler_test.go @@ -64,7 +64,10 @@ CREATE TABLE IF NOT EXISTS users ( banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT, - identity_public_key TEXT + identity_public_key TEXT, + display_name TEXT, + about TEXT, + custom_status TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -88,7 +91,8 @@ CREATE TABLE IF NOT EXISTS channels ( 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_max_users INTEGER NOT NULL DEFAULT 0, + is_group INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -97,8 +101,15 @@ CREATE TABLE IF NOT EXISTS messages ( content TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), edited_at TEXT, - deleted INTEGER NOT NULL DEFAULT 0 + deleted INTEGER NOT NULL DEFAULT 0, + mentions_everyone INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS message_mentions ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, mentioned_user_id) +); + CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, message_id INTEGER, @@ -125,6 +136,14 @@ CREATE TABLE IF NOT EXISTS channel_overrides ( PRIMARY KEY (channel_id, role_id) ); +CREATE TABLE IF NOT EXISTS channel_user_overrides ( + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (channel_id, user_id) +); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL diff --git a/Server/auth/helpers_fuzz_test.go b/Server/auth/helpers_fuzz_test.go new file mode 100644 index 00000000..572a364c --- /dev/null +++ b/Server/auth/helpers_fuzz_test.go @@ -0,0 +1,68 @@ +package auth + +import ( + "strings" + "testing" + "unicode" +) + +// FuzzValidateUsername checks ValidateUsername against the rules its own doc +// comment states: length 2-32 runes after trim, no control characters, no +// zero-width/invisible (Cf) characters, and never inside the reserved +// "[deleted-…]" namespace (case-insensitively). On success (err == nil) every +// one of those must hold for the *original* input (ValidateUsername trims +// internally but the caller passes the raw string). +func FuzzValidateUsername(f *testing.F) { + seeds := []string{ + "", + "a", + "ab", + strings.Repeat("a", 32), + strings.Repeat("a", 33), + strings.Repeat("a", 2), + " ab ", + "[deleted-123]", + "[DELETED-123]", + "[deleted-]", + "[deleted-abc]", + " [deleted-1] ", + "normal_user", + "user\x00name", + "user\tname", + "user\nname", + "zero\u200bwidth", // ZERO WIDTH SPACE (Cf) + "bidi\u202eoverride", // RIGHT-TO-LEFT OVERRIDE (Cf) + "emoji😀name", + "日本語ユーザー", + " nbsp ", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, username string) { + err := ValidateUsername(username) + if err != nil { + return + } + + trimmed := strings.TrimSpace(username) + n := len([]rune(trimmed)) + if n < minUsernameLength || n > maxUsernameLength { + t.Fatalf("ValidateUsername(%q) = nil, but trimmed length %d outside [%d,%d]", username, n, minUsernameLength, maxUsernameLength) + } + + if lower := strings.ToLower(trimmed); strings.HasPrefix(lower, "[deleted-") && strings.HasSuffix(lower, "]") { + t.Fatalf("ValidateUsername(%q) = nil, but trimmed form %q is in the reserved [deleted-…] namespace", username, trimmed) + } + + for _, r := range trimmed { + if unicode.IsControl(r) { + t.Fatalf("ValidateUsername(%q) = nil, but trimmed form contains control rune %q", username, r) + } + if unicode.In(r, unicode.Cf) { + t.Fatalf("ValidateUsername(%q) = nil, but trimmed form contains invisible (Cf) rune %q", username, r) + } + } + }) +} diff --git a/Server/auth/password_fuzz_test.go b/Server/auth/password_fuzz_test.go new file mode 100644 index 00000000..8e93d195 --- /dev/null +++ b/Server/auth/password_fuzz_test.go @@ -0,0 +1,40 @@ +package auth + +import ( + "strings" + "testing" +) + +// FuzzValidatePasswordStrength checks ValidatePasswordStrength against the +// rule its doc comment states: length (in bytes, since len() on a Go string +// is a byte count) between minPassLen and maxPassLen inclusive. On success +// (err == nil) that bound must hold for the exact input passed in. +func FuzzValidatePasswordStrength(f *testing.F) { + seeds := []string{ + "", + "a", + strings.Repeat("a", minPassLen-1), + strings.Repeat("a", minPassLen), + strings.Repeat("a", maxPassLen), + strings.Repeat("a", maxPassLen+1), + strings.Repeat("a", maxPassLen*4), + strings.Repeat("é", minPassLen), // multi-byte runes + strings.Repeat("🔥", minPassLen), // 4-byte runes + "password", + " ", // spaces only, exactly minPassLen + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, password string) { + err := ValidatePasswordStrength(password) + if err != nil { + return + } + n := len(password) + if n < minPassLen || n > maxPassLen { + t.Fatalf("ValidatePasswordStrength(%q) = nil, but byte length %d outside [%d,%d]", password, n, minPassLen, maxPassLen) + } + }) +} diff --git a/Server/db/admin_queries.go b/Server/db/admin_queries.go index 35b2cb24..c6e4a901 100644 --- a/Server/db/admin_queries.go +++ b/Server/db/admin_queries.go @@ -141,15 +141,48 @@ func (d *DB) AdminCreateChannel(ctx context.Context, name, chanType, category, t return res.LastInsertId() } -// AdminUpdateChannel updates all mutable channel fields. -func (d *DB) AdminUpdateChannel(ctx context.Context, id int64, name, topic string, slowMode, position int, archived bool) error { +// ChannelUpdate is the full set of mutable channel fields an admin edit writes. +// +// A struct rather than a positional argument list: the update covers nine +// fields now, four of them ints, and `AdminUpdateChannel(ctx, id, name, topic, +// category, slowMode, position, archived, nsfw, maxUsers, maxVideo)` invites +// exactly the silent transposition (slow mode into user limit) that no test +// would catch. Every field is written unconditionally, so callers must start +// from the channel's current values — the handler does, which is what makes a +// partial PATCH body safe. +type ChannelUpdate struct { + Name string + Topic string + Category string + SlowMode int + Position int + Archived bool + // NSFW is stored and broadcast only; it drives no server-side content + // behaviour (see migration 025). + NSFW bool + // VoiceMaxUsers / VoiceMaxVideo are the voice capacity limits the ws + // voice-join path already enforces (0 = unlimited). They are meaningless + // on a text channel but are still written there, because refusing them + // would make the value depend on a type that can never change anyway. + VoiceMaxUsers int + VoiceMaxVideo int +} + +// AdminUpdateChannel updates all mutable channel fields, category included — +// moving a channel between categories is a rename of free text, not a +// structural change, so it rides on the ordinary update. +func (d *DB) AdminUpdateChannel(ctx context.Context, id int64, u ChannelUpdate) error { if err := d.q.AdminUpdateChannel(ctx, dbgen.AdminUpdateChannelParams{ - Name: name, - Topic: strToNullPtr(topic), - SlowMode: int64(slowMode), - Position: int64(position), - Archived: b2i64(archived), - ID: id, + Name: u.Name, + Topic: strToNullPtr(u.Topic), + Category: strToNullPtr(u.Category), + SlowMode: int64(u.SlowMode), + Position: int64(u.Position), + Archived: b2i64(u.Archived), + Nsfw: b2i64(u.NSFW), + VoiceMaxUsers: int64(u.VoiceMaxUsers), + VoiceMaxVideo: int64(u.VoiceMaxVideo), + ID: id, }); err != nil { return fmt.Errorf("AdminUpdateChannel: %w", err) } diff --git a/Server/db/admin_queries_test.go b/Server/db/admin_queries_test.go index 57920bee..3b4f2aa3 100644 --- a/Server/db/admin_queries_test.go +++ b/Server/db/admin_queries_test.go @@ -27,7 +27,9 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, - voice_max_video INTEGER NOT NULL DEFAULT 0 + voice_max_video INTEGER NOT NULL DEFAULT 0, + nsfw INTEGER NOT NULL DEFAULT 0, + is_group INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( @@ -39,8 +41,15 @@ CREATE TABLE IF NOT EXISTS messages ( edited_at TEXT, deleted INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, - timestamp TEXT NOT NULL DEFAULT (datetime('now')) + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + mentions_everyone INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS message_mentions ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, mentioned_user_id) +); + CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -410,7 +419,17 @@ func TestAdminUpdateChannel(t *testing.T) { t.Fatalf("AdminCreateChannel() error: %v", err) } - if err := database.AdminUpdateChannel(context.Background(), id, "new-name", "new topic", 5, 2, true); err != nil { + if err := database.AdminUpdateChannel(context.Background(), id, db.ChannelUpdate{ + Name: "new-name", + Topic: "new topic", + Category: "Moved", + SlowMode: 5, + Position: 2, + Archived: true, + NSFW: true, + VoiceMaxUsers: 7, + VoiceMaxVideo: 3, + }); err != nil { t.Fatalf("AdminUpdateChannel() error: %v", err) } @@ -433,13 +452,65 @@ func TestAdminUpdateChannel(t *testing.T) { if !ch.Archived { t.Error("Archived = false, want true") } + if !ch.NSFW { + t.Error("NSFW = false, want true") + } + if ch.VoiceMaxUsers != 7 { + t.Errorf("VoiceMaxUsers = %d, want 7", ch.VoiceMaxUsers) + } + if ch.VoiceMaxVideo != 3 { + t.Errorf("VoiceMaxVideo = %d, want 3", ch.VoiceMaxVideo) + } +} + +// TestAdminUpdateChannel_ClearsNSFW proves the flag is a real round-trip in +// both directions: an update writes every field unconditionally, so a caller +// that starts from the channel's current values and flips one is the only +// thing standing between a partial PATCH and a wiped row. +func TestAdminUpdateChannel_ClearsNSFW(t *testing.T) { + database := newAdminTestDB(t) + + id, _ := database.AdminCreateChannel(context.Background(), "nsfw-ch", "text", "", "", 0) + if err := database.AdminUpdateChannel(context.Background(), id, db.ChannelUpdate{Name: "nsfw-ch", NSFW: true}); err != nil { + t.Fatalf("AdminUpdateChannel() error: %v", err) + } + ch, _ := database.GetChannel(context.Background(), id) + if !ch.NSFW { + t.Fatal("NSFW = false after marking, want true") + } + + if err := database.AdminUpdateChannel(context.Background(), id, db.ChannelUpdate{Name: "nsfw-ch", NSFW: false}); err != nil { + t.Fatalf("AdminUpdateChannel() error: %v", err) + } + ch, _ = database.GetChannel(context.Background(), id) + if ch.NSFW { + t.Error("NSFW = true after unmarking, want false") + } +} + +// A freshly created channel is not NSFW and carries no voice limits — the +// migration's defaults, which every client relies on for an unflagged channel. +func TestAdminCreateChannel_DefaultsNotNSFW(t *testing.T) { + database := newAdminTestDB(t) + + id, _ := database.AdminCreateChannel(context.Background(), "plain", "text", "", "", 0) + ch, err := database.GetChannel(context.Background(), id) + if err != nil { + t.Fatalf("GetChannel() error: %v", err) + } + if ch.NSFW { + t.Error("NSFW = true on a new channel, want false") + } + if ch.VoiceMaxUsers != 0 { + t.Errorf("VoiceMaxUsers = %d on a new channel, want 0", ch.VoiceMaxUsers) + } } func TestAdminUpdateChannel_Unarchive(t *testing.T) { database := newAdminTestDB(t) id, _ := database.AdminCreateChannel(context.Background(), "arch-ch", "text", "", "", 0) - _ = database.AdminUpdateChannel(context.Background(), id, "arch-ch", "", 0, 0, true) + _ = database.AdminUpdateChannel(context.Background(), id, db.ChannelUpdate{Name: "arch-ch", Archived: true}) ch, _ := database.GetChannel(context.Background(), id) if !ch.Archived { @@ -447,7 +518,7 @@ func TestAdminUpdateChannel_Unarchive(t *testing.T) { } // Unarchive - _ = database.AdminUpdateChannel(context.Background(), id, "arch-ch", "", 0, 0, false) + _ = database.AdminUpdateChannel(context.Background(), id, db.ChannelUpdate{Name: "arch-ch", Archived: false}) ch, _ = database.GetChannel(context.Background(), id) if ch.Archived { t.Error("Archived = true after unarchiving, want false") diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index 6363df3f..e510336f 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -180,8 +180,10 @@ func (d *DB) UpdateUserIdentityKey(ctx context.Context, id int64, key *string) e return nil } -// ResetAllUserStatuses sets all users to "offline". Called on server startup -// to clear stale statuses from a previous run or crash. +// ResetAllUserStatuses clears the "online" status left behind by a previous +// run or crash. Called on server startup. Chosen statuses (idle/dnd/invisible) +// are left standing — they are what the user picked, not evidence of a session, +// and the read path already renders a user with no live connection as offline. func (d *DB) ResetAllUserStatuses(ctx context.Context) error { if err := d.q.ResetAllUserStatuses(ctx); err != nil { return fmt.Errorf("ResetAllUserStatuses: %w", err) @@ -189,6 +191,16 @@ func (d *DB) ResetAllUserStatuses(ctx context.Context) error { return nil } +// MarkUserDisconnected records that a user's last session went away: last_seen +// is refreshed and "online" falls back to "offline", while a chosen +// idle/dnd/invisible is preserved for the next connect to honour. +func (d *DB) MarkUserDisconnected(ctx context.Context, userID int64) error { + if err := d.q.MarkUserDisconnected(ctx, userID); err != nil { + return fmt.Errorf("MarkUserDisconnected: %w", err) + } + return nil +} + // BanUser marks a user as banned with an optional expiry. Pass nil for a // permanent ban. func (d *DB) BanUser(ctx context.Context, id int64, reason string, expires *time.Time) error { @@ -502,6 +514,20 @@ type MemberSummary struct { // (base64), pinned by peers on first sight (F3 TOFU). Omitted when the // user has not published one. IdentityPublicKey *string `json:"identity_public_key,omitempty"` + // DisplayName is the nickname to render instead of Username. Null when + // unset; clients fall back to Username. + DisplayName *string `json:"display_name"` + // CustomStatus is the free-text status line shown under the name. Null + // when unset. + CustomStatus *string `json:"custom_status"` +} + +// ForViewer returns a copy of the summary as viewerID may see it: an invisible +// member is offline to everyone but themselves. Ready payloads go through this +// so "who is invisible" is decided in exactly one place. +func (m MemberSummary) ForViewer(viewerID int64) MemberSummary { + m.Status = StatusForViewer(m.Status, m.ID, viewerID) + return m } // ListMembers returns non-banned users as lightweight summaries. @@ -520,6 +546,8 @@ func (d *DB) ListMembers(ctx context.Context) ([]MemberSummary, error) { Status: r.Status, Role: r.Lower, IdentityPublicKey: r.IdentityPublicKey, + DisplayName: r.DisplayName, + CustomStatus: r.CustomStatus, }) } return members, nil diff --git a/Server/db/auth_queries_test.go b/Server/db/auth_queries_test.go index 577f37a8..c04e4b91 100644 --- a/Server/db/auth_queries_test.go +++ b/Server/db/auth_queries_test.go @@ -60,7 +60,10 @@ CREATE TABLE IF NOT EXISTS users ( banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT, - identity_public_key TEXT + identity_public_key TEXT, + display_name TEXT, + about TEXT, + custom_status TEXT ); CREATE TABLE IF NOT EXISTS sessions ( @@ -716,8 +719,12 @@ func TestResetAllUserStatuses(t *testing.T) { 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) + // A chosen status survives the startup reset: nothing is connected yet, so + // "online" is the only value that can be a leftover session. dnd is a + // preference, and the read path renders a user with no live connection as + // offline regardless of what the column holds. + if u2.Status != "dnd" { + t.Errorf("user2 status = %q, want 'dnd' (chosen statuses survive the reset)", u2.Status) } } diff --git a/Server/db/channel_queries.go b/Server/db/channel_queries.go index efa22af6..4b9d692c 100644 --- a/Server/db/channel_queries.go +++ b/Server/db/channel_queries.go @@ -10,7 +10,7 @@ import ( "github.com/owncord/server/db/dbgen" ) -// channelFields carries the 13 columns shared by GetChannelRow and +// channelFields carries the 14 columns shared by GetChannelRow and // ListChannelsRow; both generated row types are structurally identical, so a // single mapper narrows either to the domain Channel model. type channelFields struct { @@ -27,6 +27,10 @@ type channelFields struct { VoiceQuality *string MixingThreshold *int64 VoiceMaxVideo int64 + // Nsfw keeps sqlc's spelling, not the domain model's NSFW: the two + // generated row types are narrowed by a direct struct conversion, which + // requires identical field names. + Nsfw int64 } func channelFromFields(f channelFields) Channel { @@ -44,6 +48,7 @@ func channelFromFields(f channelFields) Channel { VoiceQuality: f.VoiceQuality, MixingThreshold: ptrI64toI(f.MixingThreshold), VoiceMaxVideo: int(f.VoiceMaxVideo), + NSFW: f.Nsfw != 0, } } @@ -147,10 +152,15 @@ func (d *DB) GetChannelPermissions(ctx context.Context, channelID, roleID int64) return r.Allow, r.Deny, nil } -// ChannelOverride holds the allow/deny permission bits for a single channel. +// ChannelOverride holds the resolved override layers for a single channel. +// Allow/Deny are the ROLE layer (channel_overrides); UserAllow/UserDeny are the +// per-member layer (channel_user_overrides) applied on top of it. See +// permissions.EffectiveChannelPerms for the resolution order. type ChannelOverride struct { - Allow int64 - Deny int64 + Allow int64 + Deny int64 + UserAllow int64 + UserDeny int64 } // GetAllChannelPermissionsForRole returns all channel permission overrides for @@ -194,6 +204,143 @@ func (d *DB) DeleteChannelOverride(ctx context.Context, channelID, roleID int64) return nil } +// GetUserChannelPermissions returns the per-user allow/deny override bits for a +// user on a channel. Returns (0, 0, nil) when no override exists. +func (d *DB) GetUserChannelPermissions(ctx context.Context, channelID, userID int64) (allow, deny int64, err error) { + r, scanErr := d.q.GetChannelUserPermission(ctx, dbgen.GetChannelUserPermissionParams{ + ChannelID: channelID, + UserID: userID, + }) + if errors.Is(scanErr, sql.ErrNoRows) { + return 0, 0, nil + } + if scanErr != nil { + return 0, 0, fmt.Errorf("GetUserChannelPermissions: %w", scanErr) + } + return r.Allow, r.Deny, nil +} + +// GetAllChannelPermissionsForUser returns every per-user channel override the +// user carries, keyed by channel ID, in one query. The per-user layer is fetched +// exactly like the per-role one (GetAllChannelPermissionsForRole) so no call +// site pays an N+1 for the second layer. +func (d *DB) GetAllChannelPermissionsForUser(ctx context.Context, userID int64) (map[int64]ChannelOverride, error) { + rows, err := d.q.GetUserChannelPermissions(ctx, userID) + if err != nil { + return nil, fmt.Errorf("GetAllChannelPermissionsForUser: %w", err) + } + result := make(map[int64]ChannelOverride, len(rows)) + for _, r := range rows { + result[r.ChannelID] = ChannelOverride{UserAllow: r.Allow, UserDeny: r.Deny} + } + return result, nil +} + +// GetChannelOverridesFor returns the merged role + user override layers for one +// member, keyed by channel ID: two batch queries, never per channel. It is the +// single fetch every "what can this member do here" site uses, so the role and +// user layers can never be loaded by one site and forgotten by another. +func (d *DB) GetChannelOverridesFor(ctx context.Context, roleID, userID int64) (map[int64]ChannelOverride, error) { + merged, err := d.GetAllChannelPermissionsForRole(ctx, roleID) + if err != nil { + return nil, err + } + userOv, err := d.GetAllChannelPermissionsForUser(ctx, userID) + if err != nil { + return nil, err + } + for chID, o := range userOv { + existing := merged[chID] + existing.UserAllow = o.UserAllow + existing.UserDeny = o.UserDeny + merged[chID] = existing + } + return merged, nil +} + +// UpsertChannelUserOverride inserts or updates the allow/deny permission +// override for a single user on a channel. +func (d *DB) UpsertChannelUserOverride(ctx context.Context, channelID, userID, allow, deny int64) error { + if err := d.q.UpsertChannelUserPermission(ctx, dbgen.UpsertChannelUserPermissionParams{ + ChannelID: channelID, + UserID: userID, + Allow: allow, + Deny: deny, + }); err != nil { + return fmt.Errorf("UpsertChannelUserOverride: %w", err) + } + return nil +} + +// DeleteChannelUserOverride removes a user's permission override on a channel. +// Deleting a non-existent override is a no-op. +func (d *DB) DeleteChannelUserOverride(ctx context.Context, channelID, userID int64) error { + if err := d.q.DeleteChannelUserPermission(ctx, dbgen.DeleteChannelUserPermissionParams{ + ChannelID: channelID, + UserID: userID, + }); err != nil { + return fmt.Errorf("DeleteChannelUserOverride: %w", err) + } + return nil +} + +// GetChannelUserOverrides returns every per-user override on a channel, keyed +// by user id. The per-user reverse (GetAllChannelPermissionsForUser) backs the +// permission cache; this direction backs the @everyone fan-out and the admin +// panel's override matrix, which need every member's verdict on one channel. +func (d *DB) GetChannelUserOverrides(ctx context.Context, channelID int64) (map[int64]ChannelOverride, error) { + rows, err := d.q.GetChannelUserOverrides(ctx, channelID) + if err != nil { + return nil, fmt.Errorf("GetChannelUserOverrides: %w", err) + } + result := make(map[int64]ChannelOverride, len(rows)) + for _, r := range rows { + result[r.UserID] = ChannelOverride{UserAllow: r.Allow, UserDeny: r.Deny} + } + return result, nil +} + +// ChannelUserOverride pairs a user with their allow/deny override on a specific +// channel. Unlike ChannelRoleOverride it lists only users who actually HAVE an +// override row — every member of a server is not a sensible list to ship. +type ChannelUserOverride struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + RoleID int64 `json:"role_id"` + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + +// ListChannelUserOverrides returns the per-user overrides on a channel joined +// with the users' names, ordered by username. +func (d *DB) ListChannelUserOverrides(ctx context.Context, channelID int64) ([]ChannelUserOverride, error) { + rows, err := d.reader.QueryContext(ctx, + `SELECT u.id, u.username, u.role_id, o.allow, o.deny + FROM channel_user_overrides o + JOIN users u ON u.id = o.user_id + WHERE o.channel_id = ? + ORDER BY u.username COLLATE NOCASE ASC`, + channelID, + ) + if err != nil { + return nil, fmt.Errorf("ListChannelUserOverrides: %w", err) + } + defer rows.Close() //nolint:errcheck + + result := []ChannelUserOverride{} + for rows.Next() { + var o ChannelUserOverride + if scanErr := rows.Scan(&o.UserID, &o.Username, &o.RoleID, &o.Allow, &o.Deny); scanErr != nil { + return nil, fmt.Errorf("ListChannelUserOverrides scan: %w", scanErr) + } + result = append(result, o) + } + if rows.Err() != nil { + return nil, fmt.Errorf("ListChannelUserOverrides rows: %w", rows.Err()) + } + return result, nil +} + // ChannelRoleOverride pairs a role with its (possibly zero) permission // override on a specific channel. Permissions carries the role's base bits so // callers can tell which roles bypass overrides via Administrator. diff --git a/Server/db/channel_user_override_queries_test.go b/Server/db/channel_user_override_queries_test.go new file mode 100644 index 00000000..eca89e5b --- /dev/null +++ b/Server/db/channel_user_override_queries_test.go @@ -0,0 +1,211 @@ +package db_test + +import ( + "context" + "testing" + + "github.com/owncord/server/permissions" +) + +// ─── channel_user_overrides ────────────────────────────────────────────────── + +func TestChannelUserOverride_UpsertGetDelete(t *testing.T) { + database := openMigratedMemory(t) + ctx := context.Background() + + chID, err := database.CreateChannel(ctx, "secret", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + uid, err := database.CreateUser(ctx, "alice", "hash", int(permissions.MemberRoleID)) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + // No row yet — a missing override is (0, 0), not an error. + allow, deny, err := database.GetUserChannelPermissions(ctx, chID, uid) + if err != nil { + t.Fatalf("GetUserChannelPermissions (absent): %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("absent override = (%#x, %#x), want (0, 0)", allow, deny) + } + + if err := database.UpsertChannelUserOverride(ctx, chID, uid, permissions.ReadMessages, permissions.SendMessages); err != nil { + t.Fatalf("UpsertChannelUserOverride: %v", err) + } + allow, deny, err = database.GetUserChannelPermissions(ctx, chID, uid) + if err != nil { + t.Fatalf("GetUserChannelPermissions: %v", err) + } + if allow != permissions.ReadMessages || deny != permissions.SendMessages { + t.Errorf("override = (%#x, %#x)", allow, deny) + } + + // Upsert replaces rather than duplicating. + if err := database.UpsertChannelUserOverride(ctx, chID, uid, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelUserOverride (update): %v", err) + } + rows, err := database.ListChannelUserOverrides(ctx, chID) + if err != nil { + t.Fatalf("ListChannelUserOverrides: %v", err) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].Username != "alice" || rows[0].Allow != 0 || rows[0].Deny != permissions.ReadMessages { + t.Errorf("listed row = %+v", rows[0]) + } + + if err := database.DeleteChannelUserOverride(ctx, chID, uid); err != nil { + t.Fatalf("DeleteChannelUserOverride: %v", err) + } + rows, err = database.ListChannelUserOverrides(ctx, chID) + if err != nil { + t.Fatalf("ListChannelUserOverrides after delete: %v", err) + } + if len(rows) != 0 { + t.Errorf("rows after delete = %d, want 0", len(rows)) + } + // Deleting again is a no-op. + if err := database.DeleteChannelUserOverride(ctx, chID, uid); err != nil { + t.Fatalf("second DeleteChannelUserOverride: %v", err) + } +} + +// GetChannelOverridesFor is the single merged fetch every visibility site uses. +// It must carry BOTH layers, and a channel with only one layer set must keep +// the other at zero. +func TestGetChannelOverridesFor_MergesBothLayers(t *testing.T) { + database := openMigratedMemory(t) + ctx := context.Background() + + roleOnly, err := database.CreateChannel(ctx, "role-only", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + userOnly, err := database.CreateChannel(ctx, "user-only", "text", "", "", 1) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + both, err := database.CreateChannel(ctx, "both", "text", "", "", 2) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + uid, err := database.CreateUser(ctx, "bob", "hash", int(permissions.MemberRoleID)) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + if err := database.UpsertChannelOverride(ctx, roleOnly, permissions.MemberRoleID, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + if err := database.UpsertChannelUserOverride(ctx, userOnly, uid, permissions.SendMessages, 0); err != nil { + t.Fatalf("UpsertChannelUserOverride: %v", err) + } + if err := database.UpsertChannelOverride(ctx, both, permissions.MemberRoleID, permissions.AttachFiles, 0); err != nil { + t.Fatalf("UpsertChannelOverride both: %v", err) + } + if err := database.UpsertChannelUserOverride(ctx, both, uid, 0, permissions.AttachFiles); err != nil { + t.Fatalf("UpsertChannelUserOverride both: %v", err) + } + + merged, err := database.GetChannelOverridesFor(ctx, permissions.MemberRoleID, uid) + if err != nil { + t.Fatalf("GetChannelOverridesFor: %v", err) + } + if got := merged[roleOnly]; got.Deny != permissions.ReadMessages || got.UserDeny != 0 { + t.Errorf("role-only channel = %+v", got) + } + if got := merged[userOnly]; got.Allow != 0 || got.UserAllow != permissions.SendMessages { + t.Errorf("user-only channel = %+v", got) + } + if got := merged[both]; got.Allow != permissions.AttachFiles || got.UserDeny != permissions.AttachFiles { + t.Errorf("both-layers channel = %+v", got) + } + + // A different member of the same role sees the role layer only. + other, err := database.CreateUser(ctx, "carol", "hash", int(permissions.MemberRoleID)) + if err != nil { + t.Fatalf("CreateUser carol: %v", err) + } + otherMerged, err := database.GetChannelOverridesFor(ctx, permissions.MemberRoleID, other) + if err != nil { + t.Fatalf("GetChannelOverridesFor carol: %v", err) + } + if got := otherMerged[userOnly]; got.UserAllow != 0 { + t.Errorf("carol picked up bob's user override: %+v", got) + } + if got := otherMerged[both]; got.UserDeny != 0 { + t.Errorf("carol picked up bob's user deny: %+v", got) + } +} + +// Deleting a channel or a user must take its override rows with it — the FKs +// carry ON DELETE CASCADE precisely so a stale row cannot grant access to a +// channel id that has been reused. +func TestChannelUserOverride_CascadesOnChannelDelete(t *testing.T) { + database := openMigratedMemory(t) + ctx := context.Background() + + chID, err := database.CreateChannel(ctx, "doomed", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + uid, err := database.CreateUser(ctx, "dave", "hash", int(permissions.MemberRoleID)) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := database.UpsertChannelUserOverride(ctx, chID, uid, permissions.ReadMessages, 0); err != nil { + t.Fatalf("UpsertChannelUserOverride: %v", err) + } + if err := database.DeleteChannel(ctx, chID); err != nil { + t.Fatalf("DeleteChannel: %v", err) + } + + byUser, err := database.GetAllChannelPermissionsForUser(ctx, uid) + if err != nil { + t.Fatalf("GetAllChannelPermissionsForUser: %v", err) + } + if len(byUser) != 0 { + t.Errorf("override survived channel delete: %+v", byUser) + } +} + +func TestGetChannelUserOverrides_ByChannel(t *testing.T) { + database := openMigratedMemory(t) + ctx := context.Background() + + chID, err := database.CreateChannel(ctx, "listing", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + a, err := database.CreateUser(ctx, "aaa", "hash", int(permissions.MemberRoleID)) + if err != nil { + t.Fatalf("CreateUser aaa: %v", err) + } + b, err := database.CreateUser(ctx, "bbb", "hash", int(permissions.MemberRoleID)) + if err != nil { + t.Fatalf("CreateUser bbb: %v", err) + } + if err := database.UpsertChannelUserOverride(ctx, chID, a, permissions.ReadMessages, 0); err != nil { + t.Fatalf("UpsertChannelUserOverride a: %v", err) + } + if err := database.UpsertChannelUserOverride(ctx, chID, b, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelUserOverride b: %v", err) + } + + byUser, err := database.GetChannelUserOverrides(ctx, chID) + if err != nil { + t.Fatalf("GetChannelUserOverrides: %v", err) + } + if len(byUser) != 2 { + t.Fatalf("entries = %d, want 2", len(byUser)) + } + if byUser[a].UserAllow != permissions.ReadMessages { + t.Errorf("user a = %+v", byUser[a]) + } + if byUser[b].UserDeny != permissions.ReadMessages { + t.Errorf("user b = %+v", byUser[b]) + } +} diff --git a/Server/db/coverage_boost_test.go b/Server/db/coverage_boost_test.go index c4cea5ab..4e0367e3 100644 --- a/Server/db/coverage_boost_test.go +++ b/Server/db/coverage_boost_test.go @@ -412,9 +412,14 @@ func TestCreateAttachment_Success(t *testing.T) { func TestCreateAttachment_WithDimensions(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "att-dim-uploader") + chID := seedChannel(t, database, "att-dim-chan") + msgID, err := database.CreateMessage(context.Background(), chID, userID, "with dims", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } w, h := 1920, 1080 - err := database.CreateAttachment(context.Background(), "att-dim", userID, "photo.jpg", "stored-dim.jpg", "image/jpeg", 54321, &w, &h) + err = database.CreateAttachment(context.Background(), "att-dim", userID, "photo.jpg", "stored-dim.jpg", "image/jpeg", 54321, &w, &h) if err != nil { t.Fatalf("CreateAttachment with dims: %v", err) } @@ -423,6 +428,25 @@ func TestCreateAttachment_WithDimensions(t *testing.T) { if att == nil { t.Fatal("expected attachment") } + + if n, linkErr := database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"att-dim"}); linkErr != nil || n != 1 { + t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, linkErr) + } + + byMsg, err := database.GetAttachmentsByMessageIDs(context.Background(), []int64{msgID}) + if err != nil { + t.Fatalf("GetAttachmentsByMessageIDs: %v", err) + } + infos := byMsg[msgID] + if len(infos) != 1 { + t.Fatalf("expected 1 attachment for message, got %d", len(infos)) + } + if infos[0].Width == nil || *infos[0].Width != w { + t.Errorf("Width = %v, want %d", infos[0].Width, w) + } + if infos[0].Height == nil || *infos[0].Height != h { + t.Errorf("Height = %v, want %d", infos[0].Height, h) + } } // ─── DeleteOrphanedAttachments ────────────────────────────────────────────── diff --git a/Server/db/dbgen/apitokens.sql.go b/Server/db/dbgen/apitokens.sql.go index a74ebb5d..10ce9d44 100644 --- a/Server/db/dbgen/apitokens.sql.go +++ b/Server/db/dbgen/apitokens.sql.go @@ -60,7 +60,8 @@ func (q *Queries) GetActiveAPIToken(ctx context.Context, tokenHash string) (ApiT const getOwnerUser = `-- name: GetOwnerUser :one SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key, + display_name, about, custom_status FROM users ORDER BY (SELECT r.position FROM roles r WHERE r.id = users.role_id) DESC, id ASC ` @@ -89,6 +90,9 @@ func (q *Queries) GetOwnerUser(ctx context.Context) (User, error) { &i.BanReason, &i.BanExpires, &i.IdentityPublicKey, + &i.DisplayName, + &i.About, + &i.CustomStatus, ) return i, err } diff --git a/Server/db/dbgen/blocks.sql.go b/Server/db/dbgen/blocks.sql.go index 58e41f7e..6eac4f8d 100644 --- a/Server/db/dbgen/blocks.sql.go +++ b/Server/db/dbgen/blocks.sql.go @@ -92,6 +92,33 @@ func (q *Queries) ListBlockedUsers(ctx context.Context, blockerID int64) ([]int6 return items, nil } +const listBlockersOfUser = `-- name: ListBlockersOfUser :many +SELECT blocker_id FROM user_blocks WHERE blocked_id = ? +` + +func (q *Queries) ListBlockersOfUser(ctx context.Context, blockedID int64) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, listBlockersOfUser, blockedID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []int64{} + for rows.Next() { + var blocker_id int64 + if err := rows.Scan(&blocker_id); err != nil { + return nil, err + } + items = append(items, blocker_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const unblockUser = `-- name: UnblockUser :exec DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ? ` diff --git a/Server/db/dbgen/channels.sql.go b/Server/db/dbgen/channels.sql.go index 7b1e83d1..07a0a43d 100644 --- a/Server/db/dbgen/channels.sql.go +++ b/Server/db/dbgen/channels.sql.go @@ -12,26 +12,35 @@ import ( const adminUpdateChannel = `-- name: AdminUpdateChannel :exec UPDATE channels -SET name = ?, topic = ?, slow_mode = ?, position = ?, archived = ? +SET name = ?, topic = ?, category = ?, slow_mode = ?, position = ?, archived = ?, + nsfw = ?, voice_max_users = ?, voice_max_video = ? WHERE id = ? ` type AdminUpdateChannelParams struct { - Name string `json:"name"` - Topic *string `json:"topic"` - SlowMode int64 `json:"slowMode"` - Position int64 `json:"position"` - Archived int64 `json:"archived"` - ID int64 `json:"id"` + Name string `json:"name"` + Topic *string `json:"topic"` + Category *string `json:"category"` + SlowMode int64 `json:"slowMode"` + Position int64 `json:"position"` + Archived int64 `json:"archived"` + Nsfw int64 `json:"nsfw"` + VoiceMaxUsers int64 `json:"voiceMaxUsers"` + VoiceMaxVideo int64 `json:"voiceMaxVideo"` + ID int64 `json:"id"` } func (q *Queries) AdminUpdateChannel(ctx context.Context, arg AdminUpdateChannelParams) error { _, err := q.db.ExecContext(ctx, adminUpdateChannel, arg.Name, arg.Topic, + arg.Category, arg.SlowMode, arg.Position, arg.Archived, + arg.Nsfw, + arg.VoiceMaxUsers, + arg.VoiceMaxVideo, arg.ID, ) return err @@ -82,13 +91,28 @@ func (q *Queries) DeleteChannelPermission(ctx context.Context, arg DeleteChannel return err } +const deleteChannelUserPermission = `-- name: DeleteChannelUserPermission :exec +DELETE FROM channel_user_overrides WHERE channel_id = ? AND user_id = ? +` + +type DeleteChannelUserPermissionParams struct { + ChannelID int64 `json:"channelId"` + UserID int64 `json:"userId"` +} + +func (q *Queries) DeleteChannelUserPermission(ctx context.Context, arg DeleteChannelUserPermissionParams) error { + _, err := q.db.ExecContext(ctx, deleteChannelUserPermission, arg.ChannelID, arg.UserID) + return err +} + const getChannel = `-- name: GetChannel :one SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, position, slow_mode, archived, created_at, COALESCE(voice_max_users, 0) AS voice_max_users, voice_quality, mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video + COALESCE(voice_max_video, 0) AS voice_max_video, + nsfw FROM channels WHERE id = ? ` @@ -106,6 +130,7 @@ type GetChannelRow struct { VoiceQuality *string `json:"voiceQuality"` MixingThreshold *int64 `json:"mixingThreshold"` VoiceMaxVideo int64 `json:"voiceMaxVideo"` + Nsfw int64 `json:"nsfw"` } func (q *Queries) GetChannel(ctx context.Context, id int64) (GetChannelRow, error) { @@ -125,10 +150,44 @@ func (q *Queries) GetChannel(ctx context.Context, id int64) (GetChannelRow, erro &i.VoiceQuality, &i.MixingThreshold, &i.VoiceMaxVideo, + &i.Nsfw, ) return i, err } +const getChannelOverrides = `-- name: GetChannelOverrides :many +SELECT role_id, allow, deny FROM channel_overrides WHERE channel_id = ? +` + +type GetChannelOverridesRow struct { + RoleID int64 `json:"roleId"` + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + +func (q *Queries) GetChannelOverrides(ctx context.Context, channelID int64) ([]GetChannelOverridesRow, error) { + rows, err := q.db.QueryContext(ctx, getChannelOverrides, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetChannelOverridesRow{} + for rows.Next() { + var i GetChannelOverridesRow + if err := rows.Scan(&i.RoleID, &i.Allow, &i.Deny); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getChannelPermission = `-- name: GetChannelPermission :one SELECT allow, deny FROM channel_overrides WHERE channel_id = ? AND role_id = ? ` @@ -150,6 +209,60 @@ func (q *Queries) GetChannelPermission(ctx context.Context, arg GetChannelPermis return i, err } +const getChannelUserOverrides = `-- name: GetChannelUserOverrides :many +SELECT user_id, allow, deny FROM channel_user_overrides WHERE channel_id = ? +` + +type GetChannelUserOverridesRow struct { + UserID int64 `json:"userId"` + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + +func (q *Queries) GetChannelUserOverrides(ctx context.Context, channelID int64) ([]GetChannelUserOverridesRow, error) { + rows, err := q.db.QueryContext(ctx, getChannelUserOverrides, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetChannelUserOverridesRow{} + for rows.Next() { + var i GetChannelUserOverridesRow + if err := rows.Scan(&i.UserID, &i.Allow, &i.Deny); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChannelUserPermission = `-- name: GetChannelUserPermission :one +SELECT allow, deny FROM channel_user_overrides WHERE channel_id = ? AND user_id = ? +` + +type GetChannelUserPermissionParams struct { + ChannelID int64 `json:"channelId"` + UserID int64 `json:"userId"` +} + +type GetChannelUserPermissionRow struct { + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + +func (q *Queries) GetChannelUserPermission(ctx context.Context, arg GetChannelUserPermissionParams) (GetChannelUserPermissionRow, error) { + row := q.db.QueryRowContext(ctx, getChannelUserPermission, arg.ChannelID, arg.UserID) + var i GetChannelUserPermissionRow + err := row.Scan(&i.Allow, &i.Deny) + return i, err +} + const getRoleChannelPermissions = `-- name: GetRoleChannelPermissions :many SELECT channel_id, allow, deny FROM channel_overrides WHERE role_id = ? ` @@ -183,13 +296,47 @@ func (q *Queries) GetRoleChannelPermissions(ctx context.Context, roleID int64) ( return items, nil } +const getUserChannelPermissions = `-- name: GetUserChannelPermissions :many +SELECT channel_id, allow, deny FROM channel_user_overrides WHERE user_id = ? +` + +type GetUserChannelPermissionsRow struct { + ChannelID int64 `json:"channelId"` + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + +func (q *Queries) GetUserChannelPermissions(ctx context.Context, userID int64) ([]GetUserChannelPermissionsRow, error) { + rows, err := q.db.QueryContext(ctx, getUserChannelPermissions, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUserChannelPermissionsRow{} + for rows.Next() { + var i GetUserChannelPermissionsRow + if err := rows.Scan(&i.ChannelID, &i.Allow, &i.Deny); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listChannels = `-- name: ListChannels :many SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, position, slow_mode, archived, created_at, COALESCE(voice_max_users, 0) AS voice_max_users, voice_quality, mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video + COALESCE(voice_max_video, 0) AS voice_max_video, + nsfw FROM channels ORDER BY position ASC, id ASC ` @@ -207,6 +354,7 @@ type ListChannelsRow struct { VoiceQuality *string `json:"voiceQuality"` MixingThreshold *int64 `json:"mixingThreshold"` VoiceMaxVideo int64 `json:"voiceMaxVideo"` + Nsfw int64 `json:"nsfw"` } func (q *Queries) ListChannels(ctx context.Context) ([]ListChannelsRow, error) { @@ -232,6 +380,7 @@ func (q *Queries) ListChannels(ctx context.Context) ([]ListChannelsRow, error) { &i.VoiceQuality, &i.MixingThreshold, &i.VoiceMaxVideo, + &i.Nsfw, ); err != nil { return nil, err } @@ -319,3 +468,28 @@ func (q *Queries) UpsertChannelPermission(ctx context.Context, arg UpsertChannel ) return err } + +const upsertChannelUserPermission = `-- name: UpsertChannelUserPermission :exec +INSERT INTO channel_user_overrides (channel_id, user_id, allow, deny) +VALUES (?, ?, ?, ?) +ON CONFLICT(channel_id, user_id) DO UPDATE SET + allow = excluded.allow, + deny = excluded.deny +` + +type UpsertChannelUserPermissionParams struct { + ChannelID int64 `json:"channelId"` + UserID int64 `json:"userId"` + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + +func (q *Queries) UpsertChannelUserPermission(ctx context.Context, arg UpsertChannelUserPermissionParams) error { + _, err := q.db.ExecContext(ctx, upsertChannelUserPermission, + arg.ChannelID, + arg.UserID, + arg.Allow, + arg.Deny, + ) + return err +} diff --git a/Server/db/dbgen/dm.sql.go b/Server/db/dbgen/dm.sql.go index 3394d246..d8063da4 100644 --- a/Server/db/dbgen/dm.sql.go +++ b/Server/db/dbgen/dm.sql.go @@ -23,6 +23,17 @@ func (q *Queries) CloseDM(ctx context.Context, arg CloseDMParams) error { return err } +const countDMParticipants = `-- name: CountDMParticipants :one +SELECT COUNT(*) FROM dm_participants WHERE channel_id = ? +` + +func (q *Queries) CountDMParticipants(ctx context.Context, channelID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countDMParticipants, channelID) + var count int64 + err := row.Scan(&count) + return count, err +} + const getDMParticipantIDs = `-- name: GetDMParticipantIDs :many SELECT user_id FROM dm_participants WHERE channel_id = ? ` @@ -50,6 +61,113 @@ func (q *Queries) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]i return items, nil } +const getDMParticipants = `-- name: GetDMParticipants :many +SELECT + u.id AS id, + u.username AS username, + COALESCE(u.display_name, '') AS display_name, + COALESCE(u.avatar, '') AS avatar, + u.status AS status +FROM dm_participants dp +JOIN users u ON u.id = dp.user_id +WHERE dp.channel_id = ? +ORDER BY u.id ASC +` + +type GetDMParticipantsRow struct { + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + Status string `json:"status"` +} + +func (q *Queries) GetDMParticipants(ctx context.Context, channelID int64) ([]GetDMParticipantsRow, error) { + rows, err := q.db.QueryContext(ctx, getDMParticipants, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetDMParticipantsRow{} + for rows.Next() { + var i GetDMParticipantsRow + if err := rows.Scan( + &i.ID, + &i.Username, + &i.DisplayName, + &i.Avatar, + &i.Status, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getDMParticipantsForUser = `-- name: GetDMParticipantsForUser :many +SELECT + dp.channel_id AS channel_id, + u.id AS id, + u.username AS username, + COALESCE(u.display_name, '') AS display_name, + COALESCE(u.avatar, '') AS avatar, + u.status AS status +FROM dm_open_state dos +JOIN dm_participants dp ON dp.channel_id = dos.channel_id +JOIN users u ON u.id = dp.user_id +WHERE dos.user_id = ? +ORDER BY dp.channel_id ASC, u.id ASC +` + +type GetDMParticipantsForUserRow struct { + ChannelID int64 `json:"channelId"` + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + Status string `json:"status"` +} + +// Every participant of every DM the user has open, in one pass. Includes the +// user themselves so a caller can tell "group of three" from "group of three +// others"; the Go layer filters when it needs the others. +func (q *Queries) GetDMParticipantsForUser(ctx context.Context, userID int64) ([]GetDMParticipantsForUserRow, error) { + rows, err := q.db.QueryContext(ctx, getDMParticipantsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetDMParticipantsForUserRow{} + for rows.Next() { + var i GetDMParticipantsForUserRow + if err := rows.Scan( + &i.ChannelID, + &i.ID, + &i.Username, + &i.DisplayName, + &i.Avatar, + &i.Status, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getUserDMChannelIDs = `-- name: GetUserDMChannelIDs :many SELECT channel_id FROM dm_open_state WHERE user_id = ? ` @@ -80,10 +198,8 @@ func (q *Queries) GetUserDMChannelIDs(ctx context.Context, userID int64) ([]int6 const getUserDMChannels = `-- name: GetUserDMChannels :many SELECT c.id AS channel_id, - u.id AS recipient_id, - u.username AS recipient_username, - COALESCE(u.avatar, '') AS recipient_avatar, - u.status AS recipient_status, + c.name AS name, + c.is_group AS is_group, lm.id AS last_message_id, COALESCE(lm.content, '') AS last_message, COALESCE(lm.timestamp, '') AS last_message_at, @@ -94,8 +210,6 @@ SELECT ) AS unread_count FROM dm_open_state dos JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' -JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != ? -JOIN users u ON u.id = dp.user_id LEFT JOIN messages lm ON lm.id = ( SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = 0 ) @@ -103,25 +217,22 @@ WHERE dos.user_id = ? ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC ` -type GetUserDMChannelsParams struct { - UserID int64 `json:"userId"` - UserID_2 int64 `json:"userId2"` -} - type GetUserDMChannelsRow struct { - ChannelID int64 `json:"channelId"` - RecipientID int64 `json:"recipientId"` - RecipientUsername string `json:"recipientUsername"` - RecipientAvatar string `json:"recipientAvatar"` - RecipientStatus string `json:"recipientStatus"` - LastMessageID *int64 `json:"lastMessageId"` - LastMessage string `json:"lastMessage"` - LastMessageAt string `json:"lastMessageAt"` - UnreadCount int64 `json:"unreadCount"` + ChannelID int64 `json:"channelId"` + Name string `json:"name"` + IsGroup int64 `json:"isGroup"` + LastMessageID *int64 `json:"lastMessageId"` + LastMessage string `json:"lastMessage"` + LastMessageAt string `json:"lastMessageAt"` + UnreadCount int64 `json:"unreadCount"` } -func (q *Queries) GetUserDMChannels(ctx context.Context, arg GetUserDMChannelsParams) ([]GetUserDMChannelsRow, error) { - rows, err := q.db.QueryContext(ctx, getUserDMChannels, arg.UserID, arg.UserID_2) +// A DM row carries no recipient any more: dm_participants holds N users, so +// "the other one" is only well defined for a two-person DM. The participant +// set comes from GetDMParticipantsForUser below, one extra query for the whole +// list rather than one per channel, and the Go layer stitches them together. +func (q *Queries) GetUserDMChannels(ctx context.Context, userID int64) ([]GetUserDMChannelsRow, error) { + rows, err := q.db.QueryContext(ctx, getUserDMChannels, userID) if err != nil { return nil, err } @@ -131,10 +242,8 @@ func (q *Queries) GetUserDMChannels(ctx context.Context, arg GetUserDMChannelsPa var i GetUserDMChannelsRow if err := rows.Scan( &i.ChannelID, - &i.RecipientID, - &i.RecipientUsername, - &i.RecipientAvatar, - &i.RecipientStatus, + &i.Name, + &i.IsGroup, &i.LastMessageID, &i.LastMessage, &i.LastMessageAt, @@ -169,6 +278,17 @@ func (q *Queries) IsDMParticipant(ctx context.Context, arg IsDMParticipantParams return user_id, err } +const isGroupDM = `-- name: IsGroupDM :one +SELECT is_group FROM channels WHERE id = ? AND type = 'dm' +` + +func (q *Queries) IsGroupDM(ctx context.Context, id int64) (int64, error) { + row := q.db.QueryRowContext(ctx, isGroupDM, id) + var is_group int64 + err := row.Scan(&is_group) + return is_group, err +} + const openDM = `-- name: OpenDM :exec INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?) ` @@ -182,3 +302,31 @@ func (q *Queries) OpenDM(ctx context.Context, arg OpenDMParams) error { _, err := q.db.ExecContext(ctx, openDM, arg.UserID, arg.ChannelID) return err } + +const removeDMParticipant = `-- name: RemoveDMParticipant :exec +DELETE FROM dm_participants WHERE channel_id = ? AND user_id = ? +` + +type RemoveDMParticipantParams struct { + ChannelID int64 `json:"channelId"` + UserID int64 `json:"userId"` +} + +func (q *Queries) RemoveDMParticipant(ctx context.Context, arg RemoveDMParticipantParams) error { + _, err := q.db.ExecContext(ctx, removeDMParticipant, arg.ChannelID, arg.UserID) + return err +} + +const setDMChannelName = `-- name: SetDMChannelName :exec +UPDATE channels SET name = ? WHERE id = ? AND type = 'dm' +` + +type SetDMChannelNameParams struct { + Name string `json:"name"` + ID int64 `json:"id"` +} + +func (q *Queries) SetDMChannelName(ctx context.Context, arg SetDMChannelNameParams) error { + _, err := q.db.ExecContext(ctx, setDMChannelName, arg.Name, arg.ID) + return err +} diff --git a/Server/db/dbgen/emoji.sql.go b/Server/db/dbgen/emoji.sql.go new file mode 100644 index 00000000..375ab3c2 --- /dev/null +++ b/Server/db/dbgen/emoji.sql.go @@ -0,0 +1,160 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: emoji.sql + +package dbgen + +import ( + "context" + "database/sql" +) + +const createEmoji = `-- name: CreateEmoji :one +INSERT INTO emoji (shortcode, filename, mime_type, uploaded_by) +VALUES (?, ?, ?, ?) +RETURNING id, shortcode, filename, mime_type, uploaded_by, created_at +` + +type CreateEmojiParams struct { + Shortcode string `json:"shortcode"` + Filename string `json:"filename"` + MimeType string `json:"mimeType"` + UploadedBy int64 `json:"uploadedBy"` +} + +type CreateEmojiRow struct { + ID int64 `json:"id"` + Shortcode string `json:"shortcode"` + Filename string `json:"filename"` + MimeType string `json:"mimeType"` + UploadedBy int64 `json:"uploadedBy"` + CreatedAt string `json:"createdAt"` +} + +func (q *Queries) CreateEmoji(ctx context.Context, arg CreateEmojiParams) (CreateEmojiRow, error) { + row := q.db.QueryRowContext(ctx, createEmoji, + arg.Shortcode, + arg.Filename, + arg.MimeType, + arg.UploadedBy, + ) + var i CreateEmojiRow + err := row.Scan( + &i.ID, + &i.Shortcode, + &i.Filename, + &i.MimeType, + &i.UploadedBy, + &i.CreatedAt, + ) + return i, err +} + +const deleteEmoji = `-- name: DeleteEmoji :execresult +DELETE FROM emoji WHERE id = ? +` + +func (q *Queries) DeleteEmoji(ctx context.Context, id int64) (sql.Result, error) { + return q.db.ExecContext(ctx, deleteEmoji, id) +} + +const getEmojiByID = `-- name: GetEmojiByID :one +SELECT id, shortcode, filename, mime_type, uploaded_by, created_at +FROM emoji WHERE id = ? +` + +type GetEmojiByIDRow struct { + ID int64 `json:"id"` + Shortcode string `json:"shortcode"` + Filename string `json:"filename"` + MimeType string `json:"mimeType"` + UploadedBy int64 `json:"uploadedBy"` + CreatedAt string `json:"createdAt"` +} + +func (q *Queries) GetEmojiByID(ctx context.Context, id int64) (GetEmojiByIDRow, error) { + row := q.db.QueryRowContext(ctx, getEmojiByID, id) + var i GetEmojiByIDRow + err := row.Scan( + &i.ID, + &i.Shortcode, + &i.Filename, + &i.MimeType, + &i.UploadedBy, + &i.CreatedAt, + ) + return i, err +} + +const getEmojiByShortcode = `-- name: GetEmojiByShortcode :one +SELECT id, shortcode, filename, mime_type, uploaded_by, created_at +FROM emoji WHERE shortcode = ? +` + +type GetEmojiByShortcodeRow struct { + ID int64 `json:"id"` + Shortcode string `json:"shortcode"` + Filename string `json:"filename"` + MimeType string `json:"mimeType"` + UploadedBy int64 `json:"uploadedBy"` + CreatedAt string `json:"createdAt"` +} + +func (q *Queries) GetEmojiByShortcode(ctx context.Context, shortcode string) (GetEmojiByShortcodeRow, error) { + row := q.db.QueryRowContext(ctx, getEmojiByShortcode, shortcode) + var i GetEmojiByShortcodeRow + err := row.Scan( + &i.ID, + &i.Shortcode, + &i.Filename, + &i.MimeType, + &i.UploadedBy, + &i.CreatedAt, + ) + return i, err +} + +const listEmoji = `-- name: ListEmoji :many +SELECT id, shortcode, filename, mime_type, uploaded_by, created_at +FROM emoji ORDER BY shortcode ASC +` + +type ListEmojiRow struct { + ID int64 `json:"id"` + Shortcode string `json:"shortcode"` + Filename string `json:"filename"` + MimeType string `json:"mimeType"` + UploadedBy int64 `json:"uploadedBy"` + CreatedAt string `json:"createdAt"` +} + +func (q *Queries) ListEmoji(ctx context.Context) ([]ListEmojiRow, error) { + rows, err := q.db.QueryContext(ctx, listEmoji) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListEmojiRow{} + for rows.Next() { + var i ListEmojiRow + if err := rows.Scan( + &i.ID, + &i.Shortcode, + &i.Filename, + &i.MimeType, + &i.UploadedBy, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/Server/db/dbgen/messages.sql.go b/Server/db/dbgen/messages.sql.go index 9ad36dd1..9495fb1b 100644 --- a/Server/db/dbgen/messages.sql.go +++ b/Server/db/dbgen/messages.sql.go @@ -12,7 +12,8 @@ import ( const createMessage = `-- name: CreateMessage :one INSERT INTO messages (channel_id, user_id, content, reply_to) VALUES (?, ?, ?, ?) -RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp +RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp, + mentions_everyone ` type CreateMessageParams struct { @@ -40,13 +41,15 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M &i.Deleted, &i.Pinned, &i.Timestamp, + &i.MentionsEveryone, ) return i, err } const editMessageContent = `-- name: EditMessageContent :one UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ? -RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp +RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp, + mentions_everyone ` type EditMessageContentParams struct { @@ -67,6 +70,7 @@ func (q *Queries) EditMessageContent(ctx context.Context, arg EditMessageContent &i.Deleted, &i.Pinned, &i.Timestamp, + &i.MentionsEveryone, ) return i, err } @@ -78,19 +82,30 @@ SELECT c.id, (SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id AND m.deleted = 0 AND m.id > COALESCE((SELECT rs.last_message_id FROM read_states rs - WHERE rs.channel_id = c.id AND rs.user_id = ?), 0)) AS unread + WHERE rs.channel_id = c.id AND rs.user_id = ?), 0)) AS unread, + COALESCE((SELECT rs.mention_count FROM read_states rs + WHERE rs.channel_id = c.id AND rs.user_id = ?), 0) AS mentions FROM channels c WHERE c.type IN ('text', 'announcement') + OR (c.type = 'dm' AND EXISTS (SELECT 1 FROM dm_participants dp + WHERE dp.channel_id = c.id AND dp.user_id = ?)) ` +type GetChannelUnreadCountsParams struct { + UserID int64 `json:"userId"` + UserID_2 int64 `json:"userId2"` + UserID_3 int64 `json:"userId3"` +} + type GetChannelUnreadCountsRow struct { ID int64 `json:"id"` LastMsgID interface{} `json:"lastMsgId"` Unread int64 `json:"unread"` + Mentions interface{} `json:"mentions"` } -func (q *Queries) GetChannelUnreadCounts(ctx context.Context, userID int64) ([]GetChannelUnreadCountsRow, error) { - rows, err := q.db.QueryContext(ctx, getChannelUnreadCounts, userID) +func (q *Queries) GetChannelUnreadCounts(ctx context.Context, arg GetChannelUnreadCountsParams) ([]GetChannelUnreadCountsRow, error) { + rows, err := q.db.QueryContext(ctx, getChannelUnreadCounts, arg.UserID, arg.UserID_2, arg.UserID_3) if err != nil { return nil, err } @@ -98,7 +113,12 @@ func (q *Queries) GetChannelUnreadCounts(ctx context.Context, userID int64) ([]G items := []GetChannelUnreadCountsRow{} for rows.Next() { var i GetChannelUnreadCountsRow - if err := rows.Scan(&i.ID, &i.LastMsgID, &i.Unread); err != nil { + if err := rows.Scan( + &i.ID, + &i.LastMsgID, + &i.Unread, + &i.Mentions, + ); err != nil { return nil, err } items = append(items, i) @@ -124,7 +144,8 @@ func (q *Queries) GetLatestMessageID(ctx context.Context, channelID int64) (inte } const getMessage = `-- name: GetMessage :one -SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp +SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp, + mentions_everyone FROM messages WHERE id = ? ` @@ -141,6 +162,7 @@ func (q *Queries) GetMessage(ctx context.Context, id int64) (Message, error) { &i.Deleted, &i.Pinned, &i.Timestamp, + &i.MentionsEveryone, ) return i, err } @@ -230,9 +252,11 @@ func (q *Queries) SoftDeleteMessage(ctx context.Context, id int64) error { } const updateReadState = `-- name: UpdateReadState :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 +INSERT INTO read_states (user_id, channel_id, last_message_id, mention_count) +VALUES (?, ?, ?, 0) +ON CONFLICT(user_id, channel_id) DO UPDATE SET + last_message_id = excluded.last_message_id, + mention_count = 0 ` type UpdateReadStateParams struct { @@ -241,6 +265,8 @@ type UpdateReadStateParams struct { LastMessageID int64 `json:"lastMessageId"` } +// Marking a channel read also clears its mention badge: channel_focus is the +// only caller, and a focused channel has no outstanding mentions by definition. func (q *Queries) UpdateReadState(ctx context.Context, arg UpdateReadStateParams) error { _, err := q.db.ExecContext(ctx, updateReadState, arg.UserID, arg.ChannelID, arg.LastMessageID) return err diff --git a/Server/db/dbgen/models.go b/Server/db/dbgen/models.go index 88799c5d..608c8535 100644 --- a/Server/db/dbgen/models.go +++ b/Server/db/dbgen/models.go @@ -56,6 +56,8 @@ type Channel struct { VoiceQuality *string `json:"voiceQuality"` MixingThreshold *int64 `json:"mixingThreshold"` VoiceMaxVideo int64 `json:"voiceMaxVideo"` + Nsfw int64 `json:"nsfw"` + IsGroup int64 `json:"isGroup"` } type ChannelOverride struct { @@ -66,6 +68,13 @@ type ChannelOverride struct { Deny int64 `json:"deny"` } +type ChannelUserOverride struct { + ChannelID int64 `json:"channelId"` + UserID int64 `json:"userId"` + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + type DmOpenState struct { UserID int64 `json:"userId"` ChannelID int64 `json:"channelId"` @@ -83,6 +92,7 @@ type Emoji struct { Filename string `json:"filename"` UploadedBy int64 `json:"uploadedBy"` CreatedAt string `json:"createdAt"` + MimeType string `json:"mimeType"` } type Event struct { @@ -114,15 +124,21 @@ type LoginAttempt struct { } type Message struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt *string `json:"editedAt"` - Deleted int64 `json:"deleted"` - Pinned int64 `json:"pinned"` - Timestamp string `json:"timestamp"` + ID int64 `json:"id"` + ChannelID int64 `json:"channelId"` + UserID int64 `json:"userId"` + Content string `json:"content"` + ReplyTo *int64 `json:"replyTo"` + EditedAt *string `json:"editedAt"` + Deleted int64 `json:"deleted"` + Pinned int64 `json:"pinned"` + Timestamp string `json:"timestamp"` + MentionsEveryone int64 `json:"mentionsEveryone"` +} + +type MessageMention struct { + MessageID int64 `json:"messageId"` + MentionedUserID int64 `json:"mentionedUserId"` } type MessagesFt struct { @@ -211,6 +227,9 @@ type User struct { BanReason *string `json:"banReason"` BanExpires *string `json:"banExpires"` IdentityPublicKey *string `json:"identityPublicKey"` + DisplayName *string `json:"displayName"` + About *string `json:"about"` + CustomStatus *string `json:"customStatus"` } type UserBlock struct { @@ -220,12 +239,14 @@ type UserBlock struct { } type VoiceState struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Muted int64 `json:"muted"` - Deafened int64 `json:"deafened"` - Speaking int64 `json:"speaking"` - JoinedAt string `json:"joinedAt"` - Camera int64 `json:"camera"` - Screenshare int64 `json:"screenshare"` + UserID int64 `json:"userId"` + ChannelID int64 `json:"channelId"` + Muted int64 `json:"muted"` + Deafened int64 `json:"deafened"` + Speaking int64 `json:"speaking"` + JoinedAt string `json:"joinedAt"` + Camera int64 `json:"camera"` + Screenshare int64 `json:"screenshare"` + ServerMuted int64 `json:"serverMuted"` + ServerDeafened int64 `json:"serverDeafened"` } diff --git a/Server/db/dbgen/profile.sql.go b/Server/db/dbgen/profile.sql.go index a993396c..4770c378 100644 --- a/Server/db/dbgen/profile.sql.go +++ b/Server/db/dbgen/profile.sql.go @@ -10,6 +10,37 @@ import ( "database/sql" ) +const countUsersWithAvatar = `-- name: CountUsersWithAvatar :one +SELECT COUNT(*) FROM users WHERE avatar = ? +` + +// Authorization probe for the file route: an unlinked attachment is readable by +// everyone exactly while some user's avatar points at it. Covered by the +// partial index on users(avatar) added in migration 027. +func (q *Queries) CountUsersWithAvatar(ctx context.Context, avatar *string) (int64, error) { + row := q.db.QueryRowContext(ctx, countUsersWithAvatar, avatar) + var count int64 + err := row.Scan(&count) + return count, err +} + +const updateUserCustomStatus = `-- name: UpdateUserCustomStatus :exec +UPDATE users SET custom_status = ? WHERE id = ? +` + +type UpdateUserCustomStatusParams struct { + CustomStatus *string `json:"customStatus"` + ID int64 `json:"id"` +} + +// Separate from UpdateUserProfile because a custom status arrives over the +// WebSocket presence path, not the REST profile PATCH, and must not be able to +// clobber the username/avatar of a profile edit racing it. +func (q *Queries) UpdateUserCustomStatus(ctx context.Context, arg UpdateUserCustomStatusParams) error { + _, err := q.db.ExecContext(ctx, updateUserCustomStatus, arg.CustomStatus, arg.ID) + return err +} + const updateUserPassword = `-- name: UpdateUserPassword :exec UPDATE users SET password = ? WHERE id = ? ` @@ -25,15 +56,25 @@ func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPassword } const updateUserProfile = `-- name: UpdateUserProfile :execresult -UPDATE users SET username = ?, avatar = ? WHERE id = ? +UPDATE users +SET username = ?, avatar = ?, display_name = ?, about = ? +WHERE id = ? ` type UpdateUserProfileParams struct { - Username string `json:"username"` - Avatar *string `json:"avatar"` - ID int64 `json:"id"` + Username string `json:"username"` + Avatar *string `json:"avatar"` + DisplayName *string `json:"displayName"` + About *string `json:"about"` + ID int64 `json:"id"` } func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (sql.Result, error) { - return q.db.ExecContext(ctx, updateUserProfile, arg.Username, arg.Avatar, arg.ID) + return q.db.ExecContext(ctx, updateUserProfile, + arg.Username, + arg.Avatar, + arg.DisplayName, + arg.About, + arg.ID, + ) } diff --git a/Server/db/dbgen/querier.go b/Server/db/dbgen/querier.go index 00e25015..de3f3b26 100644 --- a/Server/db/dbgen/querier.go +++ b/Server/db/dbgen/querier.go @@ -13,30 +13,45 @@ import ( type Querier interface { AddReaction(ctx context.Context, arg AddReactionParams) error AdminUpdateChannel(ctx context.Context, arg AdminUpdateChannelParams) error + ApplyVoiceServerDeafen(ctx context.Context, userID int64) error + ApplyVoiceServerMute(ctx context.Context, userID int64) error BanUser(ctx context.Context, arg BanUserParams) error BlockUser(ctx context.Context, arg BlockUserParams) error CleanupExpiredLockouts(ctx context.Context, expiresAt string) error ClearAllVoiceStates(ctx context.Context) error + ClearVoiceServerDeafen(ctx context.Context, userID int64) error + ClearVoiceServerMute(ctx context.Context, userID int64) error ClearVoiceState(ctx context.Context, userID int64) error CloseDM(ctx context.Context, arg CloseDMParams) error CountActiveCameras(ctx context.Context, channelID int64) (int64, error) CountActiveInvites(ctx context.Context) (int64, error) CountActiveMessages(ctx context.Context) (int64, error) CountChannels(ctx context.Context) (int64, error) + CountDMParticipants(ctx context.Context, channelID int64) (int64, error) + CountRoleMembers(ctx context.Context) ([]CountRoleMembersRow, error) CountUsers(ctx context.Context) (int64, error) + // Authorization probe for the file route: an unlinked attachment is readable by + // everyone exactly while some user's avatar points at it. Covered by the + // partial index on users(avatar) added in migration 027. + CountUsersWithAvatar(ctx context.Context, avatar *string) (int64, error) CountUsersWithoutTOTP(ctx context.Context) (int64, error) CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (sql.Result, error) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) error CreateChannel(ctx context.Context, arg CreateChannelParams) (sql.Result, error) + CreateEmoji(ctx context.Context, arg CreateEmojiParams) (CreateEmojiRow, error) CreateInvite(ctx context.Context, arg CreateInviteParams) error CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) + CreateRole(ctx context.Context, arg CreateRoleParams) (Role, error) CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error) DeleteChannel(ctx context.Context, id int64) error DeleteChannelPermission(ctx context.Context, arg DeleteChannelPermissionParams) error + DeleteChannelUserPermission(ctx context.Context, arg DeleteChannelUserPermissionParams) error + DeleteEmoji(ctx context.Context, id int64) (sql.Result, error) DeleteExpiredSessions(ctx context.Context) error DeleteLockout(ctx context.Context, key string) error DeleteOrphanedAttachments(ctx context.Context, uploadedAt string) ([]string, error) DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) (sql.Result, error) + DeleteRole(ctx context.Context, id int64) error DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) (sql.Result, error) DeleteSessionByToken(ctx context.Context, token string) error DisablePlugin(ctx context.Context, id int64) error @@ -55,10 +70,23 @@ type Querier interface { GetAttachmentWithChannel(ctx context.Context, id string) (GetAttachmentWithChannelRow, error) GetAuditLog(ctx context.Context, arg GetAuditLogParams) ([]GetAuditLogRow, error) GetChannel(ctx context.Context, id int64) (GetChannelRow, error) + GetChannelOverrides(ctx context.Context, channelID int64) ([]GetChannelOverridesRow, error) GetChannelPermission(ctx context.Context, arg GetChannelPermissionParams) (GetChannelPermissionRow, error) - GetChannelUnreadCounts(ctx context.Context, userID int64) ([]GetChannelUnreadCountsRow, error) + GetChannelUnreadCounts(ctx context.Context, arg GetChannelUnreadCountsParams) ([]GetChannelUnreadCountsRow, error) + GetChannelUserOverrides(ctx context.Context, channelID int64) ([]GetChannelUserOverridesRow, error) + GetChannelUserPermission(ctx context.Context, arg GetChannelUserPermissionParams) (GetChannelUserPermissionRow, error) GetChannelVoiceStates(ctx context.Context, channelID int64) ([]GetChannelVoiceStatesRow, error) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) + GetDMParticipants(ctx context.Context, channelID int64) ([]GetDMParticipantsRow, error) + // Every participant of every DM the user has open, in one pass. Includes the + // user themselves so a caller can tell "group of three" from "group of three + // others"; the Go layer filters when it needs the others. + GetDMParticipantsForUser(ctx context.Context, userID int64) ([]GetDMParticipantsForUserRow, error) + // The fallback role every member lands on when their role is deleted. Highest + // position wins if a database somehow carries more than one default. + GetDefaultRole(ctx context.Context) (Role, error) + GetEmojiByID(ctx context.Context, id int64) (GetEmojiByIDRow, error) + GetEmojiByShortcode(ctx context.Context, shortcode string) (GetEmojiByShortcodeRow, error) GetEventsSince(ctx context.Context, arg GetEventsSinceParams) ([]GetEventsSinceRow, error) GetInvite(ctx context.Context, code string) (GetInviteRow, error) GetLatestMessageID(ctx context.Context, channelID int64) (interface{}, error) @@ -74,7 +102,13 @@ type Querier interface { // highest-position role first, so that first row is the owner. GetOwnerUser(ctx context.Context) (User, error) GetReactionCounts(ctx context.Context, messageID int64) ([]GetReactionCountsRow, error) + // Reactors for one (message, emoji) pair, oldest reaction first. The reactions + // table has no timestamp column, so the autoincrement id carries the order. + GetReactionUsers(ctx context.Context, arg GetReactionUsersParams) ([]GetReactionUsersRow, error) GetRoleByID(ctx context.Context, id int64) (Role, error) + // Case-insensitive by design: migration 023 enforces uniqueness under the same + // collation, so this is the lookup that agrees with the constraint. + GetRoleByName(ctx context.Context, name string) (Role, error) GetRoleChannelPermissions(ctx context.Context, roleID int64) ([]GetRoleChannelPermissionsRow, error) GetRoleForUser(ctx context.Context, id int64) (Role, error) GetSessionByTokenHash(ctx context.Context, token string) (Session, error) @@ -82,8 +116,13 @@ type Querier interface { GetSetting(ctx context.Context, key string) (string, error) GetUserByID(ctx context.Context, id int64) (User, error) GetUserByUsername(ctx context.Context, username string) (User, error) + GetUserChannelPermissions(ctx context.Context, userID int64) ([]GetUserChannelPermissionsRow, error) GetUserDMChannelIDs(ctx context.Context, userID int64) ([]int64, error) - GetUserDMChannels(ctx context.Context, arg GetUserDMChannelsParams) ([]GetUserDMChannelsRow, error) + // A DM row carries no recipient any more: dm_participants holds N users, so + // "the other one" is only well defined for a two-person DM. The participant + // set comes from GetDMParticipantsForUser below, one extra query for the whole + // list rather than one per channel, and the Go layer stitches them together. + GetUserDMChannels(ctx context.Context, userID int64) ([]GetUserDMChannelsRow, error) GetUserSessions(ctx context.Context, userID int64) ([]Session, error) GetUserVoiceState(ctx context.Context, userID int64) (GetUserVoiceStateRow, error) GetUserWithRole(ctx context.Context, id int64) (GetUserWithRoleRow, error) @@ -92,6 +131,11 @@ type Querier interface { IsBlocked(ctx context.Context, arg IsBlockedParams) (int64, error) IsDMParticipant(ctx context.Context, arg IsDMParticipantParams) (int64, error) IsEitherBlocked(ctx context.Context, arg IsEitherBlockedParams) (int64, error) + IsGroupDM(ctx context.Context, id int64) (int64, error) + // server_muted / server_deafened are deliberately absent from both upserts' + // reset lists: a moderator-imposed mute must survive a channel switch, which + // reaches the ON CONFLICT branch. It is scoped to the voice session: + // leaving voice deletes the row, so a rejoin starts clean. JoinVoiceChannel(ctx context.Context, arg JoinVoiceChannelParams) error JoinVoiceChannelIfCapacity(ctx context.Context, arg JoinVoiceChannelIfCapacityParams) (sql.Result, error) LeaveVoiceChannel(ctx context.Context, userID int64) error @@ -101,14 +145,31 @@ type Querier interface { ListAPITokens(ctx context.Context) ([]ListAPITokensRow, error) ListAllUsers(ctx context.Context, arg ListAllUsersParams) ([]ListAllUsersRow, error) ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error) + ListBlockersOfUser(ctx context.Context, blockedID int64) ([]int64, error) ListChannels(ctx context.Context) ([]ListChannelsRow, error) + ListEmoji(ctx context.Context) ([]ListEmojiRow, error) ListInvites(ctx context.Context) ([]ListInvitesRow, error) ListMembers(ctx context.Context) ([]ListMembersRow, error) ListPlugins(ctx context.Context) ([]Plugin, error) + // Highest rank first. Positions are only "unique enough": reorder normalizes + // them, but creating a role inserts just below the actor and may tie with an + // existing role, so id is a tiebreaker. Without it SQLite may return tied rows + // in any order, and the admin panel derives its reorder payload from this + // order, so a single move-up would silently shuffle the tied roles. + // NOTE: keep comments in this file ASCII-only. sqlc mixes byte and rune + // offsets when stripping them, so a non-ASCII character here truncates the + // generated SQL of THIS and every following query by the byte/rune delta. ListRoles(ctx context.Context) ([]Role, error) + ListUserIDsByRole(ctx context.Context, roleID int64) ([]int64, error) ListUserSessions(ctx context.Context, userID int64) ([]Session, error) LoadActiveLockouts(ctx context.Context, expiresAt string) ([]RateLockout, error) LogAudit(ctx context.Context, arg LogAuditParams) error + // Disconnect bookkeeping. It clears only 'online', which is the one status + // that means "has a live session"; idle, dnd and invisible are choices the + // user made and are what the next connect reads instead of stamping online + // (db.ConnectStatus). A stale choice never renders as "present" because the + // read path treats a member with no live connection as offline regardless. + MarkUserDisconnected(ctx context.Context, id int64) error OpenDM(ctx context.Context, arg OpenDMParams) error // seq is supplied by the hub so the row seq matches the wrapped-payload seq. PersistEvent(ctx context.Context, arg PersistEventParams) error @@ -116,14 +177,20 @@ type Querier interface { PluginKVGet(ctx context.Context, arg PluginKVGetParams) ([]byte, error) PluginKVSet(ctx context.Context, arg PluginKVSetParams) error PruneEventsOlderThan(ctx context.Context, createdAt time.Time) (int64, error) + RemoveDMParticipant(ctx context.Context, arg RemoveDMParticipantParams) error RemoveReaction(ctx context.Context, arg RemoveReactionParams) (sql.Result, error) + // Startup reset: nothing is connected yet, so every 'online' is a leftover + // from the previous process. Chosen statuses survive for the same reason they + // survive a disconnect. ResetAllUserStatuses(ctx context.Context) error RevokeAPIToken(ctx context.Context, id int64) (sql.Result, error) RevokeAPITokenByLabel(ctx context.Context, label string) (sql.Result, error) RevokeInvite(ctx context.Context, code string) error SetChannelSlowMode(ctx context.Context, arg SetChannelSlowModeParams) error SetChannelVoiceMaxUsers(ctx context.Context, arg SetChannelVoiceMaxUsersParams) error + SetDMChannelName(ctx context.Context, arg SetDMChannelNameParams) error SetMessagePinned(ctx context.Context, arg SetMessagePinnedParams) (sql.Result, error) + SetRolePosition(ctx context.Context, arg SetRolePositionParams) error SetSetting(ctx context.Context, arg SetSettingParams) error SoftDeleteMessage(ctx context.Context, id int64) error TouchAPIToken(ctx context.Context, tokenHash string) error @@ -132,7 +199,14 @@ type Querier interface { UnblockUser(ctx context.Context, arg UnblockUserParams) error UninstallPlugin(ctx context.Context, id int64) error UpdateChannel(ctx context.Context, arg UpdateChannelParams) error + // Marking a channel read also clears its mention badge: channel_focus is the + // only caller, and a focused channel has no outstanding mentions by definition. UpdateReadState(ctx context.Context, arg UpdateReadStateParams) error + UpdateRole(ctx context.Context, arg UpdateRoleParams) error + // Separate from UpdateUserProfile because a custom status arrives over the + // WebSocket presence path, not the REST profile PATCH, and must not be able to + // clobber the username/avatar of a profile edit racing it. + UpdateUserCustomStatus(ctx context.Context, arg UpdateUserCustomStatusParams) error UpdateUserIdentityKey(ctx context.Context, arg UpdateUserIdentityKeyParams) error UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (sql.Result, error) @@ -144,6 +218,7 @@ type Querier interface { UpdateVoiceMute(ctx context.Context, arg UpdateVoiceMuteParams) error UpdateVoiceScreenshare(ctx context.Context, arg UpdateVoiceScreenshareParams) error UpsertChannelPermission(ctx context.Context, arg UpsertChannelPermissionParams) error + UpsertChannelUserPermission(ctx context.Context, arg UpsertChannelUserPermissionParams) error UpsertLockout(ctx context.Context, arg UpsertLockoutParams) error UseInviteAtomic(ctx context.Context, code string) (sql.Result, error) UserCount(ctx context.Context) (int64, error) diff --git a/Server/db/dbgen/reactions.sql.go b/Server/db/dbgen/reactions.sql.go index 92daf3b8..ee01840e 100644 --- a/Server/db/dbgen/reactions.sql.go +++ b/Server/db/dbgen/reactions.sql.go @@ -59,6 +59,52 @@ func (q *Queries) GetReactionCounts(ctx context.Context, messageID int64) ([]Get return items, nil } +const getReactionUsers = `-- name: GetReactionUsers :many +SELECT u.id, u.username, COALESCE(u.avatar, '') AS avatar +FROM reactions r +JOIN users u ON u.id = r.user_id +WHERE r.message_id = ? AND r.emoji = ? +ORDER BY r.id +LIMIT ? +` + +type GetReactionUsersParams struct { + MessageID int64 `json:"messageId"` + Emoji string `json:"emoji"` + Limit int64 `json:"limit"` +} + +type GetReactionUsersRow struct { + ID int64 `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar"` +} + +// Reactors for one (message, emoji) pair, oldest reaction first. The reactions +// table has no timestamp column, so the autoincrement id carries the order. +func (q *Queries) GetReactionUsers(ctx context.Context, arg GetReactionUsersParams) ([]GetReactionUsersRow, error) { + rows, err := q.db.QueryContext(ctx, getReactionUsers, arg.MessageID, arg.Emoji, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetReactionUsersRow{} + for rows.Next() { + var i GetReactionUsersRow + if err := rows.Scan(&i.ID, &i.Username, &i.Avatar); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const removeReaction = `-- name: RemoveReaction :execresult DELETE FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ? ` diff --git a/Server/db/dbgen/roles.sql.go b/Server/db/dbgen/roles.sql.go index f615c782..47dcce7d 100644 --- a/Server/db/dbgen/roles.sql.go +++ b/Server/db/dbgen/roles.sql.go @@ -9,6 +9,100 @@ import ( "context" ) +const countRoleMembers = `-- name: CountRoleMembers :many +SELECT role_id, COUNT(*) AS member_count FROM users GROUP BY role_id +` + +type CountRoleMembersRow struct { + RoleID int64 `json:"roleId"` + MemberCount int64 `json:"memberCount"` +} + +func (q *Queries) CountRoleMembers(ctx context.Context) ([]CountRoleMembersRow, error) { + rows, err := q.db.QueryContext(ctx, countRoleMembers) + if err != nil { + return nil, err + } + defer rows.Close() + items := []CountRoleMembersRow{} + for rows.Next() { + var i CountRoleMembersRow + if err := rows.Scan(&i.RoleID, &i.MemberCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const createRole = `-- name: CreateRole :one +INSERT INTO roles (name, color, permissions, position, is_default) +VALUES (?, ?, ?, ?, 0) +RETURNING id, name, color, permissions, position, is_default +` + +type CreateRoleParams struct { + Name string `json:"name"` + Color *string `json:"color"` + Permissions int64 `json:"permissions"` + Position int64 `json:"position"` +} + +func (q *Queries) CreateRole(ctx context.Context, arg CreateRoleParams) (Role, error) { + row := q.db.QueryRowContext(ctx, createRole, + arg.Name, + arg.Color, + arg.Permissions, + arg.Position, + ) + var i Role + err := row.Scan( + &i.ID, + &i.Name, + &i.Color, + &i.Permissions, + &i.Position, + &i.IsDefault, + ) + return i, err +} + +const deleteRole = `-- name: DeleteRole :exec +DELETE FROM roles WHERE id = ? +` + +func (q *Queries) DeleteRole(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteRole, id) + return err +} + +const getDefaultRole = `-- name: GetDefaultRole :one +SELECT id, name, color, permissions, position, is_default +FROM roles WHERE is_default = 1 ORDER BY position DESC, id ASC LIMIT 1 +` + +// The fallback role every member lands on when their role is deleted. Highest +// position wins if a database somehow carries more than one default. +func (q *Queries) GetDefaultRole(ctx context.Context) (Role, error) { + row := q.db.QueryRowContext(ctx, getDefaultRole) + var i Role + err := row.Scan( + &i.ID, + &i.Name, + &i.Color, + &i.Permissions, + &i.Position, + &i.IsDefault, + ) + return i, err +} + const getRoleByID = `-- name: GetRoleByID :one SELECT id, name, color, permissions, position, is_default FROM roles WHERE id = ? @@ -28,6 +122,27 @@ func (q *Queries) GetRoleByID(ctx context.Context, id int64) (Role, error) { return i, err } +const getRoleByName = `-- name: GetRoleByName :one +SELECT id, name, color, permissions, position, is_default +FROM roles WHERE name = ? COLLATE NOCASE +` + +// Case-insensitive by design: migration 023 enforces uniqueness under the same +// collation, so this is the lookup that agrees with the constraint. +func (q *Queries) GetRoleByName(ctx context.Context, name string) (Role, error) { + row := q.db.QueryRowContext(ctx, getRoleByName, name) + var i Role + err := row.Scan( + &i.ID, + &i.Name, + &i.Color, + &i.Permissions, + &i.Position, + &i.IsDefault, + ) + return i, err +} + const getRoleForUser = `-- name: GetRoleForUser :one SELECT r.id, r.name, r.color, r.permissions, r.position, r.is_default FROM users u @@ -108,9 +223,17 @@ func (q *Queries) GetUserWithRole(ctx context.Context, id int64) (GetUserWithRol const listRoles = `-- name: ListRoles :many SELECT id, name, color, permissions, position, is_default -FROM roles ORDER BY position DESC +FROM roles ORDER BY position DESC, id ASC ` +// Highest rank first. Positions are only "unique enough": reorder normalizes +// them, but creating a role inserts just below the actor and may tie with an +// existing role, so id is a tiebreaker. Without it SQLite may return tied rows +// in any order, and the admin panel derives its reorder payload from this +// order, so a single move-up would silently shuffle the tied roles. +// NOTE: keep comments in this file ASCII-only. sqlc mixes byte and rune +// offsets when stripping them, so a non-ASCII character here truncates the +// generated SQL of THIS and every following query by the byte/rune delta. func (q *Queries) ListRoles(ctx context.Context) ([]Role, error) { rows, err := q.db.QueryContext(ctx, listRoles) if err != nil { @@ -140,3 +263,67 @@ func (q *Queries) ListRoles(ctx context.Context) ([]Role, error) { } return items, nil } + +const listUserIDsByRole = `-- name: ListUserIDsByRole :many +SELECT id FROM users WHERE role_id = ? +` + +func (q *Queries) ListUserIDsByRole(ctx context.Context, roleID int64) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, listUserIDsByRole, roleID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []int64{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setRolePosition = `-- name: SetRolePosition :exec +UPDATE roles SET position = ? WHERE id = ? +` + +type SetRolePositionParams struct { + Position int64 `json:"position"` + ID int64 `json:"id"` +} + +func (q *Queries) SetRolePosition(ctx context.Context, arg SetRolePositionParams) error { + _, err := q.db.ExecContext(ctx, setRolePosition, arg.Position, arg.ID) + return err +} + +const updateRole = `-- name: UpdateRole :exec +UPDATE roles SET name = ?, color = ?, permissions = ?, position = ? WHERE id = ? +` + +type UpdateRoleParams struct { + Name string `json:"name"` + Color *string `json:"color"` + Permissions int64 `json:"permissions"` + Position int64 `json:"position"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateRole(ctx context.Context, arg UpdateRoleParams) error { + _, err := q.db.ExecContext(ctx, updateRole, + arg.Name, + arg.Color, + arg.Permissions, + arg.Position, + arg.ID, + ) + return err +} diff --git a/Server/db/dbgen/users.sql.go b/Server/db/dbgen/users.sql.go index a54c95ce..d351bdd0 100644 --- a/Server/db/dbgen/users.sql.go +++ b/Server/db/dbgen/users.sql.go @@ -63,7 +63,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (sql.Res const getUserByID = `-- name: GetUserByID :one SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key, + display_name, about, custom_status FROM users WHERE id = ? ` @@ -84,13 +85,17 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.BanReason, &i.BanExpires, &i.IdentityPublicKey, + &i.DisplayName, + &i.About, + &i.CustomStatus, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key, + display_name, about, custom_status FROM users WHERE username = ? COLLATE NOCASE ` @@ -111,12 +116,16 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.BanReason, &i.BanExpires, &i.IdentityPublicKey, + &i.DisplayName, + &i.About, + &i.CustomStatus, ) return i, err } const listMembers = `-- name: ListMembers :many -SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name), u.identity_public_key +SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name), u.identity_public_key, + u.display_name, u.custom_status FROM users u JOIN roles r ON u.role_id = r.id WHERE u.banned = 0 @@ -131,6 +140,8 @@ type ListMembersRow struct { Status string `json:"status"` Lower string `json:"lower"` IdentityPublicKey *string `json:"identityPublicKey"` + DisplayName *string `json:"displayName"` + CustomStatus *string `json:"customStatus"` } func (q *Queries) ListMembers(ctx context.Context) ([]ListMembersRow, error) { @@ -149,6 +160,8 @@ func (q *Queries) ListMembers(ctx context.Context) ([]ListMembersRow, error) { &i.Status, &i.Lower, &i.IdentityPublicKey, + &i.DisplayName, + &i.CustomStatus, ); err != nil { return nil, err } @@ -163,10 +176,30 @@ func (q *Queries) ListMembers(ctx context.Context) ([]ListMembersRow, error) { return items, nil } -const resetAllUserStatuses = `-- name: ResetAllUserStatuses :exec -UPDATE users SET status = 'offline' WHERE status != 'offline' +const markUserDisconnected = `-- name: MarkUserDisconnected :exec +UPDATE users +SET status = CASE WHEN status = 'online' THEN 'offline' ELSE status END, + last_seen = datetime('now') +WHERE id = ? ` +// Disconnect bookkeeping. It clears only 'online', which is the one status +// that means "has a live session"; idle, dnd and invisible are choices the +// user made and are what the next connect reads instead of stamping online +// (db.ConnectStatus). A stale choice never renders as "present" because the +// read path treats a member with no live connection as offline regardless. +func (q *Queries) MarkUserDisconnected(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, markUserDisconnected, id) + return err +} + +const resetAllUserStatuses = `-- name: ResetAllUserStatuses :exec +UPDATE users SET status = 'offline' WHERE status = 'online' +` + +// Startup reset: nothing is connected yet, so every 'online' is a leftover +// from the previous process. Chosen statuses survive for the same reason they +// survive a disconnect. func (q *Queries) ResetAllUserStatuses(ctx context.Context) error { _, err := q.db.ExecContext(ctx, resetAllUserStatuses) return err diff --git a/Server/db/dbgen/voice.sql.go b/Server/db/dbgen/voice.sql.go index 4db305ab..bd334081 100644 --- a/Server/db/dbgen/voice.sql.go +++ b/Server/db/dbgen/voice.sql.go @@ -10,6 +10,24 @@ import ( "database/sql" ) +const applyVoiceServerDeafen = `-- name: ApplyVoiceServerDeafen :exec +UPDATE voice_states SET server_deafened = 1, deafened = 1 WHERE user_id = ? +` + +func (q *Queries) ApplyVoiceServerDeafen(ctx context.Context, userID int64) error { + _, err := q.db.ExecContext(ctx, applyVoiceServerDeafen, userID) + return err +} + +const applyVoiceServerMute = `-- name: ApplyVoiceServerMute :exec +UPDATE voice_states SET server_muted = 1, muted = 1 WHERE user_id = ? +` + +func (q *Queries) ApplyVoiceServerMute(ctx context.Context, userID int64) error { + _, err := q.db.ExecContext(ctx, applyVoiceServerMute, userID) + return err +} + const clearAllVoiceStates = `-- name: ClearAllVoiceStates :exec DELETE FROM voice_states ` @@ -19,6 +37,24 @@ func (q *Queries) ClearAllVoiceStates(ctx context.Context) error { return err } +const clearVoiceServerDeafen = `-- name: ClearVoiceServerDeafen :exec +UPDATE voice_states SET server_deafened = 0 WHERE user_id = ? +` + +func (q *Queries) ClearVoiceServerDeafen(ctx context.Context, userID int64) error { + _, err := q.db.ExecContext(ctx, clearVoiceServerDeafen, userID) + return err +} + +const clearVoiceServerMute = `-- name: ClearVoiceServerMute :exec +UPDATE voice_states SET server_muted = 0 WHERE user_id = ? +` + +func (q *Queries) ClearVoiceServerMute(ctx context.Context, userID int64) error { + _, err := q.db.ExecContext(ctx, clearVoiceServerMute, userID) + return err +} + const clearVoiceState = `-- name: ClearVoiceState :exec DELETE FROM voice_states WHERE user_id = ? ` @@ -64,22 +100,25 @@ func (q *Queries) EnableCameraIfUnderLimit(ctx context.Context, arg EnableCamera const getAllVoiceStates = `-- name: GetAllVoiceStates :many SELECT vs.user_id, vs.channel_id, u.username, vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at + vs.camera, vs.screenshare, + vs.server_muted, vs.server_deafened, vs.joined_at FROM voice_states vs JOIN users u ON u.id = vs.user_id ORDER BY vs.channel_id, vs.joined_at ASC ` type GetAllVoiceStatesRow struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Username string `json:"username"` - Muted int64 `json:"muted"` - Deafened int64 `json:"deafened"` - Speaking int64 `json:"speaking"` - Camera int64 `json:"camera"` - Screenshare int64 `json:"screenshare"` - JoinedAt string `json:"joinedAt"` + UserID int64 `json:"userId"` + ChannelID int64 `json:"channelId"` + Username string `json:"username"` + Muted int64 `json:"muted"` + Deafened int64 `json:"deafened"` + Speaking int64 `json:"speaking"` + Camera int64 `json:"camera"` + Screenshare int64 `json:"screenshare"` + ServerMuted int64 `json:"serverMuted"` + ServerDeafened int64 `json:"serverDeafened"` + JoinedAt string `json:"joinedAt"` } func (q *Queries) GetAllVoiceStates(ctx context.Context) ([]GetAllVoiceStatesRow, error) { @@ -100,6 +139,8 @@ func (q *Queries) GetAllVoiceStates(ctx context.Context) ([]GetAllVoiceStatesRow &i.Speaking, &i.Camera, &i.Screenshare, + &i.ServerMuted, + &i.ServerDeafened, &i.JoinedAt, ); err != nil { return nil, err @@ -118,7 +159,8 @@ func (q *Queries) GetAllVoiceStates(ctx context.Context) ([]GetAllVoiceStatesRow const getChannelVoiceStates = `-- name: GetChannelVoiceStates :many SELECT vs.user_id, vs.channel_id, u.username, vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at + vs.camera, vs.screenshare, + vs.server_muted, vs.server_deafened, vs.joined_at FROM voice_states vs JOIN users u ON u.id = vs.user_id WHERE vs.channel_id = ? @@ -126,15 +168,17 @@ ORDER BY vs.joined_at ASC ` type GetChannelVoiceStatesRow struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Username string `json:"username"` - Muted int64 `json:"muted"` - Deafened int64 `json:"deafened"` - Speaking int64 `json:"speaking"` - Camera int64 `json:"camera"` - Screenshare int64 `json:"screenshare"` - JoinedAt string `json:"joinedAt"` + UserID int64 `json:"userId"` + ChannelID int64 `json:"channelId"` + Username string `json:"username"` + Muted int64 `json:"muted"` + Deafened int64 `json:"deafened"` + Speaking int64 `json:"speaking"` + Camera int64 `json:"camera"` + Screenshare int64 `json:"screenshare"` + ServerMuted int64 `json:"serverMuted"` + ServerDeafened int64 `json:"serverDeafened"` + JoinedAt string `json:"joinedAt"` } func (q *Queries) GetChannelVoiceStates(ctx context.Context, channelID int64) ([]GetChannelVoiceStatesRow, error) { @@ -155,6 +199,8 @@ func (q *Queries) GetChannelVoiceStates(ctx context.Context, channelID int64) ([ &i.Speaking, &i.Camera, &i.Screenshare, + &i.ServerMuted, + &i.ServerDeafened, &i.JoinedAt, ); err != nil { return nil, err @@ -173,22 +219,25 @@ func (q *Queries) GetChannelVoiceStates(ctx context.Context, channelID int64) ([ const getUserVoiceState = `-- name: GetUserVoiceState :one SELECT vs.user_id, vs.channel_id, u.username, vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at + vs.camera, vs.screenshare, + vs.server_muted, vs.server_deafened, vs.joined_at FROM voice_states vs JOIN users u ON u.id = vs.user_id WHERE vs.user_id = ? ` type GetUserVoiceStateRow struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Username string `json:"username"` - Muted int64 `json:"muted"` - Deafened int64 `json:"deafened"` - Speaking int64 `json:"speaking"` - Camera int64 `json:"camera"` - Screenshare int64 `json:"screenshare"` - JoinedAt string `json:"joinedAt"` + UserID int64 `json:"userId"` + ChannelID int64 `json:"channelId"` + Username string `json:"username"` + Muted int64 `json:"muted"` + Deafened int64 `json:"deafened"` + Speaking int64 `json:"speaking"` + Camera int64 `json:"camera"` + Screenshare int64 `json:"screenshare"` + ServerMuted int64 `json:"serverMuted"` + ServerDeafened int64 `json:"serverDeafened"` + JoinedAt string `json:"joinedAt"` } func (q *Queries) GetUserVoiceState(ctx context.Context, userID int64) (GetUserVoiceStateRow, error) { @@ -203,12 +252,15 @@ func (q *Queries) GetUserVoiceState(ctx context.Context, userID int64) (GetUserV &i.Speaking, &i.Camera, &i.Screenshare, + &i.ServerMuted, + &i.ServerDeafened, &i.JoinedAt, ) return i, err } const joinVoiceChannel = `-- name: JoinVoiceChannel :exec + INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) VALUES (?, ?, 0, 0, 0, 0, 0, ?) ON CONFLICT(user_id) DO UPDATE SET @@ -227,6 +279,10 @@ type JoinVoiceChannelParams struct { JoinedAt string `json:"joinedAt"` } +// server_muted / server_deafened are deliberately absent from both upserts' +// reset lists: a moderator-imposed mute must survive a channel switch, which +// reaches the ON CONFLICT branch. It is scoped to the voice session: +// leaving voice deletes the row, so a rejoin starts clean. func (q *Queries) JoinVoiceChannel(ctx context.Context, arg JoinVoiceChannelParams) error { _, err := q.db.ExecContext(ctx, joinVoiceChannel, arg.UserID, arg.ChannelID, arg.JoinedAt) return err diff --git a/Server/db/dm_group_queries_test.go b/Server/db/dm_group_queries_test.go new file mode 100644 index 00000000..0fa656c4 --- /dev/null +++ b/Server/db/dm_group_queries_test.go @@ -0,0 +1,214 @@ +package db_test + +import ( + "context" + "testing" + + "github.com/owncord/server/db" +) + +// groupDMFixture returns a migrated database with three users (ids 1..3). +func groupDMFixture(t *testing.T) *db.DB { + t.Helper() + database := openMigratedMemory(t) + for i, name := range []string{"ga", "gb", "gc"} { + if got := seedUser(t, database, name); got != int64(i+1) { + t.Fatalf("seedUser %s: expected id %d, got %d", name, i+1, got) + } + } + return database +} + +func TestCreateGroupDMChannel_OpensForEveryone(t *testing.T) { + database := groupDMFixture(t) + ctx := context.Background() + + ch, err := database.CreateGroupDMChannel(ctx, "Crew", []int64{1, 2, 3}) + if err != nil { + t.Fatalf("CreateGroupDMChannel: %v", err) + } + if ch.Name != "Crew" { + t.Errorf("expected name Crew, got %q", ch.Name) + } + + isGroup, err := database.IsGroupDM(ctx, ch.ID) + if err != nil || !isGroup { + t.Errorf("expected is_group=true, got %v (err=%v)", isGroup, err) + } + + for _, uid := range []int64{1, 2, 3} { + dms, listErr := database.GetUserDMChannels(ctx, uid) + if listErr != nil { + t.Fatalf("GetUserDMChannels(%d): %v", uid, listErr) + } + if len(dms) != 1 { + t.Fatalf("user %d: expected 1 open DM, got %d", uid, len(dms)) + } + if !dms[0].IsGroup || dms[0].Name != "Crew" { + t.Errorf("user %d: expected the group, got %+v", uid, dms[0]) + } + if len(dms[0].Recipients) != 2 { + t.Errorf("user %d: expected 2 others, got %d", uid, len(dms[0].Recipients)) + } + for _, r := range dms[0].Recipients { + if r.ID == uid { + t.Errorf("user %d appears in their own recipients list", uid) + } + } + } +} + +func TestCreateGroupDMChannel_RefusesUnderThree(t *testing.T) { + database := groupDMFixture(t) + if _, err := database.CreateGroupDMChannel(context.Background(), "", []int64{1, 2}); err == nil { + t.Fatal("expected an error creating a 2-person group DM") + } +} + +// The 1:1 lookup must never return a group DM, even one whose membership has +// shrunk to exactly the two users being looked up. +func TestGetOrCreateDMChannel_IgnoresShrunkGroup(t *testing.T) { + database := groupDMFixture(t) + ctx := context.Background() + + group, err := database.CreateGroupDMChannel(ctx, "Shrinking", []int64{1, 2, 3}) + if err != nil { + t.Fatalf("CreateGroupDMChannel: %v", err) + } + if _, err = database.LeaveGroupDM(ctx, 3, group.ID); err != nil { + t.Fatalf("LeaveGroupDM: %v", err) + } + + ch, created, err := database.GetOrCreateDMChannel(ctx, 1, 2) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + if !created { + t.Error("expected a brand-new 1:1 DM, not a reused channel") + } + if ch.ID == group.ID { + t.Fatal("the 1:1 lookup returned the shrunk group DM") + } +} + +func TestLeaveGroupDM_DeletesChannelOnLastLeave(t *testing.T) { + database := groupDMFixture(t) + ctx := context.Background() + + group, err := database.CreateGroupDMChannel(ctx, "Ephemeral", []int64{1, 2, 3}) + if err != nil { + t.Fatalf("CreateGroupDMChannel: %v", err) + } + + for i, uid := range []int64{1, 2, 3} { + deleted, leaveErr := database.LeaveGroupDM(ctx, uid, group.ID) + if leaveErr != nil { + t.Fatalf("LeaveGroupDM(%d): %v", uid, leaveErr) + } + wantDeleted := i == 2 + if deleted != wantDeleted { + t.Errorf("leave %d: deleted=%v, want %v", uid, deleted, wantDeleted) + } + } + + ch, err := database.GetChannel(ctx, group.ID) + if err != nil { + t.Fatalf("GetChannel: %v", err) + } + if ch != nil { + t.Error("channel survived the last participant leaving") + } +} + +func TestSetDMChannelName_RefusesNonDM(t *testing.T) { + database := groupDMFixture(t) + ctx := context.Background() + + textID, err := database.CreateChannel(ctx, "general", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.SetDMChannelName(ctx, textID, "hijacked"); err != nil { + t.Fatalf("SetDMChannelName: %v", err) + } + + ch, err := database.GetChannel(ctx, textID) + if err != nil { + t.Fatalf("GetChannel: %v", err) + } + if ch.Name != "general" { + t.Errorf("a text channel was renamed through the DM route: %q", ch.Name) + } +} + +// An invisible participant must read as offline to everyone but themselves, +// on the DM list exactly as everywhere else. +func TestGetDMParticipants_CollapsesInvisible(t *testing.T) { + database := groupDMFixture(t) + ctx := context.Background() + + group, err := database.CreateGroupDMChannel(ctx, "Ghosts", []int64{1, 2, 3}) + if err != nil { + t.Fatalf("CreateGroupDMChannel: %v", err) + } + if err := database.UpdateUserStatus(ctx, 2, db.StatusInvisible); err != nil { + t.Fatalf("UpdateUserStatus: %v", err) + } + + others, err := database.GetDMParticipants(ctx, group.ID, 1) + if err != nil { + t.Fatalf("GetDMParticipants: %v", err) + } + for _, p := range others { + if p.ID == 2 && p.Status != db.StatusOffline { + t.Errorf("invisible user leaked as %q to another participant", p.Status) + } + } + + self, err := database.GetDMParticipants(ctx, group.ID, 2) + if err != nil { + t.Fatalf("GetDMParticipants: %v", err) + } + for _, p := range self { + if p.ID == 2 && p.Status != db.StatusInvisible { + t.Errorf("the owner of an invisible status sees %q, want invisible", p.Status) + } + } +} + +func TestNewDMChannelInfo_ExcludesViewerAndPicksRecipient(t *testing.T) { + participants := []db.DMUser{ + {ID: 1, Username: "a"}, + {ID: 2, Username: "b"}, + {ID: 3, Username: "c"}, + } + info := db.NewDMChannelInfo(7, "Trio", true, participants, 2) + + if info.ChannelID != 7 || info.Name != "Trio" || !info.IsGroup { + t.Errorf("unexpected header fields: %+v", info) + } + if len(info.Recipients) != 2 { + t.Fatalf("expected 2 recipients, got %d", len(info.Recipients)) + } + for _, r := range info.Recipients { + if r.ID == 2 { + t.Error("the viewer is in their own recipients list") + } + } + // Backward compat: a pre-group client reads `recipient` alone. + if info.Recipient.ID != 1 { + t.Errorf("expected the first other participant as the compat recipient, got %d", info.Recipient.ID) + } +} + +// A DM with no other participants (the moment before the row is cleaned up) +// must produce an empty list, not a nil one — clients iterate it directly. +func TestNewDMChannelInfo_EmptyRecipientsIsNotNil(t *testing.T) { + info := db.NewDMChannelInfo(9, "", false, []db.DMUser{{ID: 5}}, 5) + if info.Recipients == nil { + t.Fatal("recipients must be an empty slice, not nil") + } + if len(info.Recipients) != 0 { + t.Errorf("expected no recipients, got %d", len(info.Recipients)) + } +} diff --git a/Server/db/dm_queries.go b/Server/db/dm_queries.go index a92c73d4..4e02f940 100644 --- a/Server/db/dm_queries.go +++ b/Server/db/dm_queries.go @@ -11,14 +11,37 @@ import ( // ─── DM Models ────────────────────────────────────────────────────────────── +// MaxGroupDMParticipants is the total participant ceiling for a group DM, +// creator included. Discord's is 10; matching it keeps the fan-out per message +// bounded and keeps a "group DM" from becoming an unmoderated guild. +const MaxGroupDMParticipants = 10 + // DMChannelInfo holds a DM channel summary for the channel list. type DMChannelInfo struct { - ChannelID int64 `json:"channel_id"` - Recipient DMUser `json:"recipient"` + ChannelID int64 `json:"channel_id"` + // Recipient is the OTHER participant of a two-person DM. It is retained + // for backward compatibility with clients that predate group DMs and is + // only meaningful when IsGroup is false; for a group it carries the + // lowest-id other participant so such a client still renders something. + Recipient DMUser `json:"recipient"` + // Recipients is every participant except the viewer. This is the field + // group-aware clients read; for a 1:1 DM it holds exactly Recipient. + Recipients []DMUser `json:"recipients"` + // Name is the optional group name (channels.name). Always "" for a 1:1 DM + // — a two-person DM is named by who is in it, not by a title. + Name string `json:"name"` + // IsGroup is channels.is_group: decided once when the DM is created and + // never recomputed from the live participant count, so a group that people + // have left stays a group (see migration 028). + IsGroup bool `json:"is_group"` LastMessageID *int64 `json:"last_message_id"` LastMessage string `json:"last_message"` LastMessageAt string `json:"last_message_at"` UnreadCount int `json:"unread_count"` + // MentionCount is read_states.mention_count for this DM. It is not part of + // the GetUserDMChannels query — buildReady fills it from the unread map so + // a DM mention badge survives a reconnect. + MentionCount int `json:"mention_count"` } // DMUser is the public-facing shape for a DM participant. @@ -27,6 +50,37 @@ type DMUser struct { Username string `json:"username"` Avatar string `json:"avatar"` Status string `json:"status"` + // DisplayName is the participant's chosen nickname, "" when unset. Clients + // fall back to Username, exactly as they do everywhere else. + DisplayName string `json:"display_name"` +} + +// NewDMChannelInfo assembles the payload shape for one DM from its channel id, +// optional group name, group flag and full participant list (the viewer +// included), as seen by viewerID. +// +// It is the single place that answers "which of these is the recipient", so +// the REST list, the ready payload and the dm_channel_open event cannot +// disagree about a channel — a disagreement that would show up as a DM whose +// name changes depending on which event drew it. +func NewDMChannelInfo(channelID int64, name string, isGroup bool, participants []DMUser, viewerID int64) DMChannelInfo { + others := make([]DMUser, 0, len(participants)) + for i := range participants { + if participants[i].ID == viewerID { + continue + } + others = append(others, participants[i]) + } + info := DMChannelInfo{ + ChannelID: channelID, + Recipients: others, + Name: name, + IsGroup: isGroup, + } + if len(others) > 0 { + info.Recipient = others[0] + } + return info } // ─── GetOrCreateDMChannel ─────────────────────────────────────────────────── @@ -45,12 +99,19 @@ func (d *DB) GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) ( } // Check for an existing DM channel inside the transaction. + // + // The is_group clause is what keeps group DMs out of this lookup. Without + // it a group that happens to contain both users matches the join, and + // "message Bob" would silently drop the message into a five-person group. + // It is the stored flag rather than a live participant count because a + // group people have left can have exactly two members and must still not + // answer "the DM between these two". var existingID int64 err = tx.QueryRow( `SELECT dp1.channel_id FROM dm_participants dp1 JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id JOIN channels c ON c.id = dp1.channel_id - WHERE dp1.user_id = ? AND dp2.user_id = ? AND c.type = 'dm' + WHERE dp1.user_id = ? AND dp2.user_id = ? AND c.type = 'dm' AND c.is_group = 0 LIMIT 1`, user1ID, user2ID, ).Scan(&existingID) @@ -129,81 +190,223 @@ func (d *DB) GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) ( // ─── GetUserDMChannels ────────────────────────────────────────────────────── -// GetUserDMChannels returns all open DM channels for a user with recipient info, -// last message preview, and unread count. Ordered by most recent activity. +// GetUserDMChannels returns all open DM channels for a user with the full +// participant list, last message preview, and unread count. Ordered by most +// recent activity. // -// Note: the SQL JOIN on dm_open_state already restricts results to DM channels +// It is two queries, not one: dm_participants holds N users per channel, so a +// single joined query returns one row per (channel, participant) pair and the +// caller has to de-duplicate anyway. Fetching the participants for every open +// DM in one extra pass keeps the cost at O(1) queries rather than the O(n) a +// per-channel participant lookup would cost. +// +// Note: the JOIN on dm_open_state already restricts results to DM channels // (dm_open_state only contains rows for DM channels), and the explicit -// "c.type = 'dm'" predicate in the JOIN provides a defensive second check. -// No additional channel-type validation is needed at the Go layer. -// -// The unread count is a correlated subquery range-scanning -// idx_messages_channel per DM, replacing the old LEFT JOIN messages fan-out -// that touched every message row in every open DM. +// "c.type = 'dm'" predicate provides a defensive second check. func (d *DB) GetUserDMChannels(ctx context.Context, userID int64) ([]DMChannelInfo, error) { - rows, err := d.reader.QueryContext(ctx, - `SELECT - c.id AS channel_id, - u.id AS recipient_id, - u.username AS recipient_username, - COALESCE(u.avatar, '') AS recipient_avatar, - u.status AS recipient_status, - lm.id AS last_message_id, - COALESCE(lm.content, '') AS last_message, - COALESCE(lm.timestamp, '') AS last_message_at, - (SELECT COUNT(*) FROM messages mu - WHERE mu.channel_id = c.id AND mu.deleted = 0 - AND mu.id > COALESCE((SELECT rs.last_message_id FROM read_states rs - WHERE rs.channel_id = c.id AND rs.user_id = dos.user_id), 0) - ) AS unread_count - FROM dm_open_state dos - JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' - JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != ? - JOIN users u ON u.id = dp.user_id - LEFT JOIN messages lm ON lm.id = ( - SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = 0 - ) - WHERE dos.user_id = ? - ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC`, - userID, userID, - ) + rows, err := d.q.GetUserDMChannels(ctx, userID) if err != nil { return nil, fmt.Errorf("GetUserDMChannels: %w", err) } - defer rows.Close() //nolint:errcheck - var result []DMChannelInfo - for rows.Next() { - var info DMChannelInfo - var lastMsgID sql.NullInt64 - if scanErr := rows.Scan( - &info.ChannelID, - &info.Recipient.ID, - &info.Recipient.Username, - &info.Recipient.Avatar, - &info.Recipient.Status, - &lastMsgID, - &info.LastMessage, - &info.LastMessageAt, - &info.UnreadCount, - ); scanErr != nil { - return nil, fmt.Errorf("GetUserDMChannels scan: %w", scanErr) + parts, err := d.q.GetDMParticipantsForUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("GetUserDMChannels participants: %w", err) + } + byChannel := make(map[int64][]DMUser, len(rows)) + for i := range parts { + if parts[i].ID == userID { + continue } - if lastMsgID.Valid { - id := lastMsgID.Int64 - info.LastMessageID = &id + byChannel[parts[i].ChannelID] = append(byChannel[parts[i].ChannelID], DMUser{ + ID: parts[i].ID, + Username: parts[i].Username, + Avatar: parts[i].Avatar, + Status: StatusForViewer(parts[i].Status, parts[i].ID, userID), + DisplayName: parts[i].DisplayName, + }) + } + + result := make([]DMChannelInfo, 0, len(rows)) + for i := range rows { + recipients := byChannel[rows[i].ChannelID] + if recipients == nil { + recipients = []DMUser{} + } + info := DMChannelInfo{ + ChannelID: rows[i].ChannelID, + Recipients: recipients, + Name: rows[i].Name, + IsGroup: rows[i].IsGroup != 0, + LastMessageID: rows[i].LastMessageID, + LastMessage: rows[i].LastMessage, + LastMessageAt: rows[i].LastMessageAt, + UnreadCount: int(rows[i].UnreadCount), + } + if len(recipients) > 0 { + info.Recipient = recipients[0] } result = append(result, info) } - if rows.Err() != nil { - return nil, fmt.Errorf("GetUserDMChannels rows: %w", rows.Err()) - } - if result == nil { - result = []DMChannelInfo{} - } return result, nil } +// ─── Group DM mutation ────────────────────────────────────────────────────── + +// CreateGroupDMChannel creates a new group DM channel with the given +// participants (creator included in participantIDs) and opens it for all of +// them. It always creates: unlike a 1:1 DM there is no canonical "the DM +// between these people", because the same set of people may reasonably want +// two separate groups. +// +// The whole insert runs in one transaction so a crash cannot leave a channel +// with no participants — which would be an unreachable, undeletable row. +func (d *DB) CreateGroupDMChannel(ctx context.Context, name string, participantIDs []int64) (*Channel, error) { + if len(participantIDs) < 3 { + return nil, fmt.Errorf("CreateGroupDMChannel: need at least 3 participants, got %d", len(participantIDs)) + } + tx, err := d.writer.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return nil, fmt.Errorf("CreateGroupDMChannel begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() //nolint:errcheck // no-op after a successful Commit + + res, err := tx.ExecContext(ctx, `INSERT INTO channels (name, type, is_group) VALUES (?, 'dm', 1)`, name) + if err != nil { + return nil, fmt.Errorf("CreateGroupDMChannel insert channel: %w", err) + } + channelID, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("CreateGroupDMChannel last insert id: %w", err) + } + + for _, pid := range participantIDs { + if _, err = tx.ExecContext(ctx, + `INSERT OR IGNORE INTO dm_participants (channel_id, user_id) VALUES (?, ?)`, + channelID, pid, + ); err != nil { + return nil, fmt.Errorf("CreateGroupDMChannel insert participant: %w", err) + } + if _, err = tx.ExecContext(ctx, + `INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?)`, + pid, channelID, + ); err != nil { + return nil, fmt.Errorf("CreateGroupDMChannel open dm: %w", err) + } + } + + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("CreateGroupDMChannel commit: %w", err) + } + + ch, err := d.GetChannel(ctx, channelID) + if err != nil { + return nil, fmt.Errorf("CreateGroupDMChannel fetch new: %w", err) + } + return ch, nil +} + +// LeaveGroupDM removes userID from a group DM's participant list and from +// their open list, and reports whether that emptied the channel. +// +// When the last participant leaves, the channel row is deleted: a DM channel +// with no participants is reachable by nobody and would sit in the database +// forever, and its messages/attachments cascade off the channels row. Leaving +// is therefore destructive for the last leaver only — everyone else's leave is +// just a removal. +func (d *DB) LeaveGroupDM(ctx context.Context, userID, channelID int64) (deleted bool, err error) { + tx, err := d.writer.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return false, fmt.Errorf("LeaveGroupDM begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() //nolint:errcheck // no-op after a successful Commit + + if _, err = tx.ExecContext(ctx, + `DELETE FROM dm_participants WHERE channel_id = ? AND user_id = ?`, channelID, userID, + ); err != nil { + return false, fmt.Errorf("LeaveGroupDM remove participant: %w", err) + } + if _, err = tx.ExecContext(ctx, + `DELETE FROM dm_open_state WHERE channel_id = ? AND user_id = ?`, channelID, userID, + ); err != nil { + return false, fmt.Errorf("LeaveGroupDM close dm: %w", err) + } + + var remaining int + if err = tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM dm_participants WHERE channel_id = ?`, channelID, + ).Scan(&remaining); err != nil { + return false, fmt.Errorf("LeaveGroupDM count: %w", err) + } + if remaining == 0 { + if _, err = tx.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, channelID); err != nil { + return false, fmt.Errorf("LeaveGroupDM delete channel: %w", err) + } + deleted = true + } + + if err = tx.Commit(); err != nil { + return false, fmt.Errorf("LeaveGroupDM commit: %w", err) + } + return deleted, nil +} + +// CountDMParticipants returns how many users are in a DM channel. +func (d *DB) CountDMParticipants(ctx context.Context, channelID int64) (int, error) { + n, err := d.q.CountDMParticipants(ctx, channelID) + if err != nil { + return 0, fmt.Errorf("CountDMParticipants: %w", err) + } + return int(n), nil +} + +// IsGroupDM reports whether a DM channel was created as a group. False for a +// non-existent channel and for anything that is not a DM, which is what every +// caller wants: "treat it as a 1:1" is the conservative answer. +func (d *DB) IsGroupDM(ctx context.Context, channelID int64) (bool, error) { + flag, err := d.q.IsGroupDM(ctx, channelID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("IsGroupDM: %w", err) + } + return flag != 0, nil +} + +// SetDMChannelName sets the optional group name on a DM channel. The type +// predicate lives in the SQL so a stray channel id cannot rename a guild +// channel through the DM route. +func (d *DB) SetDMChannelName(ctx context.Context, channelID int64, name string) error { + if err := d.q.SetDMChannelName(ctx, dbgen.SetDMChannelNameParams{ + Name: name, + ID: channelID, + }); err != nil { + return fmt.Errorf("SetDMChannelName: %w", err) + } + return nil +} + +// GetDMParticipants returns every participant of a DM channel, viewer-adjusted +// (an invisible participant reads as offline to anyone but themselves). +func (d *DB) GetDMParticipants(ctx context.Context, channelID, viewerID int64) ([]DMUser, error) { + rows, err := d.q.GetDMParticipants(ctx, channelID) + if err != nil { + return nil, fmt.Errorf("GetDMParticipants: %w", err) + } + out := make([]DMUser, 0, len(rows)) + for i := range rows { + out = append(out, DMUser{ + ID: rows[i].ID, + Username: rows[i].Username, + Avatar: rows[i].Avatar, + Status: StatusForViewer(rows[i].Status, rows[i].ID, viewerID), + DisplayName: rows[i].DisplayName, + }) + } + return out, nil +} + // ─── OpenDM / CloseDM ────────────────────────────────────────────────────── // OpenDM adds a DM channel to a user's open list (idempotent). diff --git a/Server/db/emoji_queries.go b/Server/db/emoji_queries.go new file mode 100644 index 00000000..6388cf15 --- /dev/null +++ b/Server/db/emoji_queries.go @@ -0,0 +1,110 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/owncord/server/db/dbgen" +) + +// GetEmoji returns the emoji with the given id, or (nil, nil) when no such row +// exists. A missing row is not an error: DELETE races and stale client ids are +// ordinary, and the caller turns the nil into a 404. +func (d *DB) GetEmoji(ctx context.Context, id int64) (*Emoji, error) { + row, err := d.q.GetEmojiByID(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetEmoji: %w", err) + } + return &Emoji{ + ID: row.ID, + Shortcode: row.Shortcode, + StoredAs: row.Filename, + MimeType: row.MimeType, + UploadedBy: row.UploadedBy, + CreatedAt: row.CreatedAt, + }, nil +} + +// GetEmojiByShortcode returns the emoji owning `shortcode`, or (nil, nil). +// Shortcodes are stored lowercase, so the caller must normalize before calling +// -- this is the uniqueness check behind CreateEmoji. +func (d *DB) GetEmojiByShortcode(ctx context.Context, shortcode string) (*Emoji, error) { + row, err := d.q.GetEmojiByShortcode(ctx, shortcode) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetEmojiByShortcode: %w", err) + } + return &Emoji{ + ID: row.ID, + Shortcode: row.Shortcode, + StoredAs: row.Filename, + MimeType: row.MimeType, + UploadedBy: row.UploadedBy, + CreatedAt: row.CreatedAt, + }, nil +} + +// ListEmoji returns every custom emoji, ordered by shortcode. +func (d *DB) ListEmoji(ctx context.Context) ([]*Emoji, error) { + rows, err := d.q.ListEmoji(ctx) + if err != nil { + return nil, fmt.Errorf("ListEmoji: %w", err) + } + out := make([]*Emoji, 0, len(rows)) + for _, row := range rows { + out = append(out, &Emoji{ + ID: row.ID, + Shortcode: row.Shortcode, + StoredAs: row.Filename, + MimeType: row.MimeType, + UploadedBy: row.UploadedBy, + CreatedAt: row.CreatedAt, + }) + } + return out, nil +} + +// CreateEmoji inserts a custom emoji and returns the stored row. storedAs is +// the storage-layer UUID the bytes were written under; mimeType is the type +// sniffed from those bytes, never a client-supplied header. +func (d *DB) CreateEmoji(ctx context.Context, shortcode, storedAs, mimeType string, uploadedBy int64) (*Emoji, error) { + row, err := d.q.CreateEmoji(ctx, dbgen.CreateEmojiParams{ + Shortcode: shortcode, + Filename: storedAs, + MimeType: mimeType, + UploadedBy: uploadedBy, + }) + if err != nil { + return nil, fmt.Errorf("CreateEmoji: %w", err) + } + return &Emoji{ + ID: row.ID, + Shortcode: row.Shortcode, + StoredAs: row.Filename, + MimeType: row.MimeType, + UploadedBy: row.UploadedBy, + CreatedAt: row.CreatedAt, + }, nil +} + +// DeleteEmoji removes the emoji row and reports whether a row was actually +// deleted, so a concurrent double-delete answers 404 rather than pretending to +// have removed the same emoji twice. +func (d *DB) DeleteEmoji(ctx context.Context, id int64) (bool, error) { + res, err := d.q.DeleteEmoji(ctx, id) + if err != nil { + return false, fmt.Errorf("DeleteEmoji: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("DeleteEmoji rows affected: %w", err) + } + return n > 0, nil +} diff --git a/Server/db/emoji_queries_test.go b/Server/db/emoji_queries_test.go new file mode 100644 index 00000000..7a940f71 --- /dev/null +++ b/Server/db/emoji_queries_test.go @@ -0,0 +1,149 @@ +package db_test + +import ( + "context" + "testing" + + "github.com/owncord/server/db" +) + +// seedEmojiUploader inserts the user row the emoji.uploaded_by foreign key +// needs, and returns its id. +func seedEmojiUploader(t *testing.T, database *db.DB) int64 { + t.Helper() + res, err := database.ExecContext(context.Background(), + `INSERT INTO users (username, password) VALUES ('emoji-uploader', 'x')`) + if err != nil { + t.Fatalf("seed uploader: %v", err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("LastInsertId: %v", err) + } + return id +} + +// These run against the real embedded migration set: migration 026 is what +// adds the mime_type column these queries select, so the inline test schema +// would not carry it. + +func TestEmojiCRUDRoundTrip(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + uploader := seedEmojiUploader(t, database) + + created, err := database.CreateEmoji(ctx, "wave", "uuid-wave", "image/png", uploader) + if err != nil { + t.Fatalf("CreateEmoji: %v", err) + } + if created.ID == 0 { + t.Fatalf("CreateEmoji returned id 0") + } + if created.Shortcode != "wave" || created.StoredAs != "uuid-wave" || created.MimeType != "image/png" { + t.Errorf("created = %+v, want wave/uuid-wave/image/png", created) + } + if created.CreatedAt == "" { + t.Errorf("created_at is empty") + } + + byID, err := database.GetEmoji(ctx, created.ID) + if err != nil || byID == nil { + t.Fatalf("GetEmoji = %v, %v", byID, err) + } + if byID.StoredAs != "uuid-wave" { + t.Errorf("GetEmoji.StoredAs = %q, want uuid-wave", byID.StoredAs) + } + + byCode, err := database.GetEmojiByShortcode(ctx, "wave") + if err != nil || byCode == nil { + t.Fatalf("GetEmojiByShortcode = %v, %v", byCode, err) + } + if byCode.ID != created.ID { + t.Errorf("GetEmojiByShortcode.ID = %d, want %d", byCode.ID, created.ID) + } + + deleted, err := database.DeleteEmoji(ctx, created.ID) + if err != nil { + t.Fatalf("DeleteEmoji: %v", err) + } + if !deleted { + t.Errorf("DeleteEmoji reported no row removed") + } + // A second delete of the same id must report false rather than pretending. + again, err := database.DeleteEmoji(ctx, created.ID) + if err != nil { + t.Fatalf("second DeleteEmoji: %v", err) + } + if again { + t.Errorf("second DeleteEmoji reported a removal") + } +} + +func TestEmojiMissingRowsAreNilNotError(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + + e, err := database.GetEmoji(ctx, 999) + if err != nil { + t.Fatalf("GetEmoji(missing): %v", err) + } + if e != nil { + t.Errorf("GetEmoji(missing) = %v, want nil", e) + } + + e, err = database.GetEmojiByShortcode(ctx, "nosuch") + if err != nil { + t.Fatalf("GetEmojiByShortcode(missing): %v", err) + } + if e != nil { + t.Errorf("GetEmojiByShortcode(missing) = %v, want nil", e) + } +} + +func TestListEmojiIsOrderedAndEmptySliceWhenNone(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + uploader := seedEmojiUploader(t, database) + + list, err := database.ListEmoji(ctx) + if err != nil { + t.Fatalf("ListEmoji: %v", err) + } + if list == nil || len(list) != 0 { + t.Fatalf("ListEmoji on an empty table = %v, want an empty slice", list) + } + + for _, sc := range []string{"zulu", "alpha", "mike"} { + if _, err := database.CreateEmoji(ctx, sc, "uuid-"+sc, "image/png", uploader); err != nil { + t.Fatalf("CreateEmoji(%s): %v", sc, err) + } + } + list, err = database.ListEmoji(ctx) + if err != nil { + t.Fatalf("ListEmoji: %v", err) + } + want := []string{"alpha", "mike", "zulu"} + if len(list) != len(want) { + t.Fatalf("len(list) = %d, want %d", len(list), len(want)) + } + for i, sc := range want { + if list[i].Shortcode != sc { + t.Errorf("list[%d] = %q, want %q", i, list[i].Shortcode, sc) + } + } +} + +func TestCreateEmojiRejectsDuplicateShortcode(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + uploader := seedEmojiUploader(t, database) + + if _, err := database.CreateEmoji(ctx, "wave", "uuid-a", "image/png", uploader); err != nil { + t.Fatalf("first CreateEmoji: %v", err) + } + // The table's UNIQUE index is the backstop behind the service's own + // pre-check, so a lost race still cannot produce two :wave: rows. + if _, err := database.CreateEmoji(ctx, "wave", "uuid-b", "image/gif", uploader); err == nil { + t.Fatalf("duplicate CreateEmoji succeeded, want a uniqueness error") + } +} diff --git a/Server/db/mappers.go b/Server/db/mappers.go index d8a403fa..63931c13 100644 --- a/Server/db/mappers.go +++ b/Server/db/mappers.go @@ -72,6 +72,9 @@ func userFromGen(u dbgen.User) *User { BanReason: u.BanReason, BanExpires: u.BanExpires, IdentityPublicKey: u.IdentityPublicKey, + DisplayName: u.DisplayName, + About: u.About, + CustomStatus: u.CustomStatus, } } diff --git a/Server/db/mention_queries.go b/Server/db/mention_queries.go new file mode 100644 index 00000000..8c8156be --- /dev/null +++ b/Server/db/mention_queries.go @@ -0,0 +1,369 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// mentionExecer is the subset of *sql.Tx insertMentionRows needs, so the same +// row-writing loop serves both the insert and the edit transaction. +type mentionExecer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// maxMentionsPerMessage bounds how many resolved mentions a single message may +// store. The service caps its resolution at the same number; this is the +// storage-side backstop so a caller cannot widen the fan-out. +const maxMentionsPerMessage = 20 + +// MentionTarget is a candidate recipient of a mention fan-out: the user id, the +// presence status @here filters on, and the role the user holds (so the caller +// can apply the ADMINISTRATOR bypass when a per-user channel override would +// otherwise drop them). +type MentionTarget struct { + UserID int64 + Status string + RoleID int64 +} + +// CreateMessageWithMentions inserts a message and its resolved mentions in one +// writer transaction, so a reader can never observe a message whose mention set +// is still half-written. mentionedUserIDs is truncated to +// maxMentionsPerMessage and duplicates are ignored. +func (d *DB) CreateMessageWithMentions(ctx context.Context, channelID, userID int64, content string, replyTo *int64, mentionedUserIDs []int64, mentionsEveryone bool) (*Message, error) { + tx, err := d.writer.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("CreateMessageWithMentions begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + var m Message + var deleted, pinned, everyone int64 + if scanErr := tx.QueryRowContext(ctx, + `INSERT INTO messages (channel_id, user_id, content, reply_to, mentions_everyone) + VALUES (?, ?, ?, ?, ?) + RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, + timestamp, mentions_everyone`, + channelID, userID, content, replyTo, b2i64(mentionsEveryone), + ).Scan(&m.ID, &m.ChannelID, &m.UserID, &m.Content, &m.ReplyTo, &m.EditedAt, + &deleted, &pinned, &m.Timestamp, &everyone); scanErr != nil { + return nil, fmt.Errorf("CreateMessageWithMentions insert: %w", scanErr) + } + m.Deleted = deleted != 0 + m.Pinned = pinned != 0 + m.MentionsEveryone = everyone != 0 + + if err := insertMentionRows(ctx, tx, m.ID, mentionedUserIDs); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("CreateMessageWithMentions commit: %w", err) + } + return &m, nil +} + +// ReplaceMessageMentions rewrites a message's mention set and its +// mentions_everyone flag in one writer transaction. Used by edits, which +// re-resolve mentions from the new content. +func (d *DB) ReplaceMessageMentions(ctx context.Context, messageID int64, mentionedUserIDs []int64, mentionsEveryone bool) error { + tx, err := d.writer.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("ReplaceMessageMentions begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + if _, err := tx.ExecContext(ctx, `DELETE FROM message_mentions WHERE message_id = ?`, messageID); err != nil { + return fmt.Errorf("ReplaceMessageMentions delete: %w", err) + } + // Guarded so an edit that does not change the flag skips the write — an + // UPDATE on messages re-indexes the row through the messages_fts triggers. + if _, err := tx.ExecContext(ctx, + `UPDATE messages SET mentions_everyone = ? WHERE id = ? AND mentions_everyone != ?`, + b2i64(mentionsEveryone), messageID, b2i64(mentionsEveryone), + ); err != nil { + return fmt.Errorf("ReplaceMessageMentions flag: %w", err) + } + if err := insertMentionRows(ctx, tx, messageID, mentionedUserIDs); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("ReplaceMessageMentions commit: %w", err) + } + return nil +} + +// insertMentionRows writes the mention rows for one message inside tx. +// Self-mentions are stored like any other: the fan-out, not storage, is what +// excludes the author. +func insertMentionRows(ctx context.Context, tx mentionExecer, messageID int64, mentionedUserIDs []int64) error { + if len(mentionedUserIDs) > maxMentionsPerMessage { + mentionedUserIDs = mentionedUserIDs[:maxMentionsPerMessage] + } + for _, uid := range mentionedUserIDs { + if _, err := tx.ExecContext(ctx, + `INSERT OR IGNORE INTO message_mentions (message_id, mentioned_user_id) VALUES (?, ?)`, + messageID, uid, + ); err != nil { + return fmt.Errorf("insertMentionRows: %w", err) + } + } + return nil +} + +// GetMentionsByMessageIDs returns the mentioned user ids per message id. +// Messages with no mentions are absent from the map. +func (d *DB) GetMentionsByMessageIDs(ctx context.Context, msgIDs []int64) (map[int64][]int64, error) { + result := make(map[int64][]int64) + if len(msgIDs) == 0 { + return result, nil + } + + placeholders := make([]string, len(msgIDs)) + args := make([]any, len(msgIDs)) + for i, id := range msgIDs { + placeholders[i] = "?" + args[i] = id + } + + rows, err := d.reader.QueryContext(ctx, + fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input + `SELECT message_id, mentioned_user_id FROM message_mentions + WHERE message_id IN (%s) ORDER BY message_id, mentioned_user_id`, + strings.Join(placeholders, ",")), + args..., + ) + if err != nil { + return nil, fmt.Errorf("GetMentionsByMessageIDs: %w", err) + } + defer rows.Close() //nolint:errcheck + + for rows.Next() { + var msgID, userID int64 + if scanErr := rows.Scan(&msgID, &userID); scanErr != nil { + return nil, fmt.Errorf("GetMentionsByMessageIDs scan: %w", scanErr) + } + result[msgID] = append(result[msgID], userID) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetMentionsByMessageIDs rows: %w", rows.Err()) + } + return result, nil +} + +// mentionCountChunkSize bounds how many recipients IncrementMentionCounts +// upserts in a single multi-row INSERT, keeping the per-exec bound-parameter +// count (2 per row) far below SQLite's limit. Mirrors the IN-list chunking in +// GetSessionsWithBanStatusBatch. +const mentionCountChunkSize = 500 + +// IncrementMentionCounts bumps read_states.mention_count by one for each user +// in a channel, creating the read-state row when the user has none yet. +// last_message_id stays 0 for a created row: the user has read nothing, and the +// mention they were just given is unread by definition. +// +// Batched into one multi-row INSERT per chunk of mentionCountChunkSize +// recipients instead of one exec per recipient: an @everyone mention fans out +// to every reader of a channel, and the writer txn used to pay one round trip +// per reader for that. The caller has already excluded the author, so +// semantics are unchanged — each listed user id still gets exactly one +// increment (or a fresh row seeded at 1). +func (d *DB) IncrementMentionCounts(ctx context.Context, channelID int64, userIDs []int64) error { + if len(userIDs) == 0 { + return nil + } + tx, err := d.writer.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("IncrementMentionCounts begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + for start := 0; start < len(userIDs); start += mentionCountChunkSize { + chunk := userIDs[start:min(start+mentionCountChunkSize, len(userIDs))] + + rowPlaceholders := make([]string, len(chunk)) + args := make([]any, 0, len(chunk)*2) + for i, uid := range chunk { + rowPlaceholders[i] = "(?, ?, 0, 1)" + args = append(args, uid, channelID) + } + + query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input + `INSERT INTO read_states (user_id, channel_id, last_message_id, mention_count) + VALUES %s + ON CONFLICT(user_id, channel_id) DO UPDATE SET + mention_count = mention_count + 1`, + strings.Join(rowPlaceholders, ","), + ) + if _, err := tx.ExecContext(ctx, query, args...); err != nil { + return fmt.Errorf("IncrementMentionCounts: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("IncrementMentionCounts commit: %w", err) + } + return nil +} + +// GetMentionCount returns the unread mention count for a user in a channel. +func (d *DB) GetMentionCount(ctx context.Context, userID, channelID int64) (int, error) { + var count int + err := d.reader.QueryRowContext(ctx, + `SELECT COALESCE((SELECT mention_count FROM read_states + WHERE user_id = ? AND channel_id = ?), 0)`, + userID, channelID, + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf("GetMentionCount: %w", err) + } + return count, nil +} + +// GetUserIDsByUsernames resolves usernames to ids, keyed by the lowercased +// username. Matching is case-insensitive because users.username is UNIQUE +// COLLATE NOCASE, which makes the column's comparisons case-insensitive too. +func (d *DB) GetUserIDsByUsernames(ctx context.Context, usernames []string) (map[string]int64, error) { + result := make(map[string]int64) + if len(usernames) == 0 { + return result, nil + } + + placeholders := make([]string, len(usernames)) + args := make([]any, len(usernames)) + for i, name := range usernames { + placeholders[i] = "?" + args[i] = name + } + + rows, err := d.reader.QueryContext(ctx, + fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input + `SELECT id, username FROM users WHERE banned = 0 AND username IN (%s)`, + strings.Join(placeholders, ",")), + args..., + ) + if err != nil { + return nil, fmt.Errorf("GetUserIDsByUsernames: %w", err) + } + defer rows.Close() //nolint:errcheck + + for rows.Next() { + var id int64 + var name string + if scanErr := rows.Scan(&id, &name); scanErr != nil { + return nil, fmt.Errorf("GetUserIDsByUsernames scan: %w", scanErr) + } + result[strings.ToLower(name)] = id + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetUserIDsByUsernames rows: %w", rows.Err()) + } + return result, nil +} + +// ListMentionTargetsByRoles returns non-banned users holding any of the given +// roles, with the presence status @here filters on. +func (d *DB) ListMentionTargetsByRoles(ctx context.Context, roleIDs []int64) ([]MentionTarget, error) { + if len(roleIDs) == 0 { + return []MentionTarget{}, nil + } + + placeholders := make([]string, len(roleIDs)) + args := make([]any, len(roleIDs)) + for i, id := range roleIDs { + placeholders[i] = "?" + args[i] = id + } + + rows, err := d.reader.QueryContext(ctx, + fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input + `SELECT id, status, role_id FROM users WHERE banned = 0 AND role_id IN (%s)`, + strings.Join(placeholders, ",")), + args..., + ) + if err != nil { + return nil, fmt.Errorf("ListMentionTargetsByRoles: %w", err) + } + defer rows.Close() //nolint:errcheck + + targets := []MentionTarget{} + for rows.Next() { + var t MentionTarget + if scanErr := rows.Scan(&t.UserID, &t.Status, &t.RoleID); scanErr != nil { + return nil, fmt.Errorf("ListMentionTargetsByRoles scan: %w", scanErr) + } + targets = append(targets, t) + } + if rows.Err() != nil { + return nil, fmt.Errorf("ListMentionTargetsByRoles rows: %w", rows.Err()) + } + return targets, nil +} + +// ListMentionTargetsByUserIDs returns non-banned users by explicit id, with the +// same fields ListMentionTargetsByRoles returns. It backs the additive half of +// the per-user channel override layer: a member whose user override ALLOWs +// READ_MESSAGES can read a channel their role cannot, so the role walk alone +// would leave them out of an @everyone fan-out they are entitled to. +func (d *DB) ListMentionTargetsByUserIDs(ctx context.Context, userIDs []int64) ([]MentionTarget, error) { + if len(userIDs) == 0 { + return []MentionTarget{}, nil + } + + placeholders := make([]string, len(userIDs)) + args := make([]any, len(userIDs)) + for i, id := range userIDs { + placeholders[i] = "?" + args[i] = id + } + + rows, err := d.reader.QueryContext(ctx, + fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input + `SELECT id, status, role_id FROM users WHERE banned = 0 AND id IN (%s)`, + strings.Join(placeholders, ",")), + args..., + ) + if err != nil { + return nil, fmt.Errorf("ListMentionTargetsByUserIDs: %w", err) + } + defer rows.Close() //nolint:errcheck + + targets := []MentionTarget{} + for rows.Next() { + var t MentionTarget + if scanErr := rows.Scan(&t.UserID, &t.Status, &t.RoleID); scanErr != nil { + return nil, fmt.Errorf("ListMentionTargetsByUserIDs scan: %w", scanErr) + } + targets = append(targets, t) + } + if rows.Err() != nil { + return nil, fmt.Errorf("ListMentionTargetsByUserIDs rows: %w", rows.Err()) + } + return targets, nil +} + +// ListBlockersOf returns the ids of users who have blocked the given user. +// A mention from a blocked user must not raise the blocker's mention badge. +func (d *DB) ListBlockersOf(ctx context.Context, blockedID int64) ([]int64, error) { + ids, err := d.q.ListBlockersOfUser(ctx, blockedID) + if err != nil { + return nil, fmt.Errorf("ListBlockersOf: %w", err) + } + return ids, nil +} + +// GetChannelOverrides returns every role override on a channel, keyed by role +// id. The per-role reverse (GetAllChannelPermissionsForRole) backs the +// per-user permission cache; this direction backs the @everyone fan-out, which +// needs every role's verdict on one channel. +func (d *DB) GetChannelOverrides(ctx context.Context, channelID int64) (map[int64]ChannelOverride, error) { + rows, err := d.q.GetChannelOverrides(ctx, channelID) + if err != nil { + return nil, fmt.Errorf("GetChannelOverrides: %w", err) + } + result := make(map[int64]ChannelOverride, len(rows)) + for _, r := range rows { + result[r.RoleID] = ChannelOverride{Allow: r.Allow, Deny: r.Deny} + } + return result, nil +} diff --git a/Server/db/mention_queries_test.go b/Server/db/mention_queries_test.go new file mode 100644 index 00000000..cfe5d34d --- /dev/null +++ b/Server/db/mention_queries_test.go @@ -0,0 +1,327 @@ +package db_test + +import ( + "context" + "strconv" + "testing" + + "github.com/owncord/server/db" +) + +// storageMentionCap mirrors the package-internal maxMentionsPerMessage backstop. +const storageMentionCap = 20 + +// seedMentionFixture creates two users and a text channel for mention tests. +func seedMentionFixture(t *testing.T, database *db.DB) { + t.Helper() + ctx := context.Background() + for _, name := range []string{"alice", "bob", "Carol"} { + if _, err := database.CreateUser(ctx, name, "hash", 4); err != nil { + t.Fatalf("CreateUser(%s): %v", name, err) + } + } + if _, err := database.CreateChannel(ctx, "general", "text", "", "", 0); err != nil { + t.Fatalf("CreateChannel: %v", err) + } +} + +func TestCreateMessageWithMentions_StoresRowsAndFlag(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + msg, err := database.CreateMessageWithMentions(ctx, 1, 1, "hi @bob", nil, []int64{2}, true) + if err != nil { + t.Fatalf("CreateMessageWithMentions: %v", err) + } + if !msg.MentionsEveryone { + t.Error("MentionsEveryone = false, want true") + } + + got, err := database.GetMentionsByMessageIDs(ctx, []int64{msg.ID}) + if err != nil { + t.Fatalf("GetMentionsByMessageIDs: %v", err) + } + if len(got[msg.ID]) != 1 || got[msg.ID][0] != 2 { + t.Errorf("mentions = %v, want [2]", got[msg.ID]) + } + + // The flag must survive a re-read through the ordinary message getter. + reread, err := database.GetMessage(ctx, msg.ID) + if err != nil || reread == nil { + t.Fatalf("GetMessage: %v", err) + } + if !reread.MentionsEveryone { + t.Error("re-read MentionsEveryone = false, want true") + } +} + +// TestCreateMessageWithMentions_CapsStoredRows locks the storage-side backstop: +// a caller cannot widen the fan-out past storageMentionCap. +func TestCreateMessageWithMentions_CapsStoredRows(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + ids := make([]int64, 0, storageMentionCap+5) + for i := range storageMentionCap + 5 { + uid, err := database.CreateUser(ctx, "capped"+string(rune('a'+i)), "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + ids = append(ids, uid) + } + + msg, err := database.CreateMessageWithMentions(ctx, 1, 1, "spam", nil, ids, false) + if err != nil { + t.Fatalf("CreateMessageWithMentions: %v", err) + } + got, err := database.GetMentionsByMessageIDs(ctx, []int64{msg.ID}) + if err != nil { + t.Fatalf("GetMentionsByMessageIDs: %v", err) + } + if len(got[msg.ID]) != storageMentionCap { + t.Errorf("stored = %d, want %d", len(got[msg.ID]), storageMentionCap) + } +} + +func TestReplaceMessageMentions(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + msg, err := database.CreateMessageWithMentions(ctx, 1, 1, "hi @bob", nil, []int64{2}, true) + if err != nil { + t.Fatalf("CreateMessageWithMentions: %v", err) + } + if err := database.ReplaceMessageMentions(ctx, msg.ID, []int64{3}, false); err != nil { + t.Fatalf("ReplaceMessageMentions: %v", err) + } + + got, err := database.GetMentionsByMessageIDs(ctx, []int64{msg.ID}) + if err != nil { + t.Fatalf("GetMentionsByMessageIDs: %v", err) + } + if len(got[msg.ID]) != 1 || got[msg.ID][0] != 3 { + t.Errorf("mentions = %v, want [3]", got[msg.ID]) + } + reread, err := database.GetMessage(ctx, msg.ID) + if err != nil || reread == nil { + t.Fatalf("GetMessage: %v", err) + } + if reread.MentionsEveryone { + t.Error("MentionsEveryone = true, want false after replace") + } +} + +func TestIncrementMentionCounts_AndReadStateClear(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + if err := database.IncrementMentionCounts(ctx, 1, []int64{2, 3}); err != nil { + t.Fatalf("IncrementMentionCounts: %v", err) + } + if err := database.IncrementMentionCounts(ctx, 1, []int64{2}); err != nil { + t.Fatalf("IncrementMentionCounts: %v", err) + } + + if n, _ := database.GetMentionCount(ctx, 2, 1); n != 2 { + t.Errorf("user 2 mention_count = %d, want 2", n) + } + if n, _ := database.GetMentionCount(ctx, 3, 1); n != 1 { + t.Errorf("user 3 mention_count = %d, want 1", n) + } + + // Marking the channel read clears the badge, and only for that user. + if err := database.UpdateReadState(ctx, 2, 1, 99); err != nil { + t.Fatalf("UpdateReadState: %v", err) + } + if n, _ := database.GetMentionCount(ctx, 2, 1); n != 0 { + t.Errorf("after read state, user 2 mention_count = %d, want 0", n) + } + if n, _ := database.GetMentionCount(ctx, 3, 1); n != 1 { + t.Errorf("user 3 mention_count = %d, want 1", n) + } +} + +// TestIncrementMentionCounts_BatchesAcrossChunkBoundary exercises the +// multi-row upsert with a recipient count that spans more than one exec +// chunk (mentionCountChunkSize=500), pinning that every recipient still gets +// exactly one increment (or a freshly seeded row) regardless of which chunk +// it landed in — the batching must not drop or double-count a row at the +// boundary. +func TestIncrementMentionCounts_BatchesAcrossChunkBoundary(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + if _, err := database.CreateChannel(ctx, "everyone-chan", "text", "", "", 0); err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + const n = 520 // > one 500-row chunk, so the run crosses a chunk boundary. + ids := make([]int64, 0, n) + for i := range n { + uid, err := database.CreateUser(ctx, "batchuser"+strconv.Itoa(i), "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + ids = append(ids, uid) + } + + if err := database.IncrementMentionCounts(ctx, 1, ids); err != nil { + t.Fatalf("IncrementMentionCounts: %v", err) + } + + for _, uid := range []int64{ids[0], ids[499], ids[500], ids[n-1]} { + if got, err := database.GetMentionCount(ctx, uid, 1); err != nil || got != 1 { + t.Errorf("user %d mention_count = %d (err=%v), want 1", uid, got, err) + } + } + + // A second pass bumps every one of them again, chunk boundary included. + if err := database.IncrementMentionCounts(ctx, 1, ids); err != nil { + t.Fatalf("IncrementMentionCounts (second pass): %v", err) + } + for _, uid := range []int64{ids[0], ids[499], ids[500], ids[n-1]} { + if got, err := database.GetMentionCount(ctx, uid, 1); err != nil || got != 2 { + t.Errorf("user %d mention_count after second pass = %d (err=%v), want 2", uid, got, err) + } + } +} + +func TestIncrementMentionCounts_EmptyIsNoop(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + if err := database.IncrementMentionCounts(context.Background(), 1, nil); err != nil { + t.Fatalf("IncrementMentionCounts(nil): %v", err) + } +} + +func TestGetUserIDsByUsernames_CaseInsensitive(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + + got, err := database.GetUserIDsByUsernames(context.Background(), []string{"BOB", "carol", "ghost"}) + if err != nil { + t.Fatalf("GetUserIDsByUsernames: %v", err) + } + if got["bob"] != 2 { + t.Errorf("bob = %d, want 2", got["bob"]) + } + if got["carol"] != 3 { + t.Errorf("carol = %d, want 3 (stored as \"Carol\")", got["carol"]) + } + if _, ok := got["ghost"]; ok { + t.Error("unknown username must not resolve") + } +} + +func TestListMentionTargetsByRoles(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + if err := database.UpdateUserStatus(ctx, 2, "online"); err != nil { + t.Fatalf("UpdateUserStatus: %v", err) + } + + targets, err := database.ListMentionTargetsByRoles(ctx, []int64{4}) + if err != nil { + t.Fatalf("ListMentionTargetsByRoles: %v", err) + } + if len(targets) != 3 { + t.Fatalf("targets = %d, want 3", len(targets)) + } + for _, tgt := range targets { + if tgt.UserID == 2 && tgt.Status != "online" { + t.Errorf("user 2 status = %q, want online", tgt.Status) + } + } + + empty, err := database.ListMentionTargetsByRoles(ctx, nil) + if err != nil || len(empty) != 0 { + t.Errorf("no roles must yield no targets: %v %v", empty, err) + } +} + +func TestListBlockersOf(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + if err := database.BlockUser(ctx, 2, 1); err != nil { + t.Fatalf("BlockUser: %v", err) + } + blockers, err := database.ListBlockersOf(ctx, 1) + if err != nil { + t.Fatalf("ListBlockersOf: %v", err) + } + if len(blockers) != 1 || blockers[0] != 2 { + t.Errorf("blockers = %v, want [2]", blockers) + } +} + +func TestGetChannelOverrides(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + if err := database.UpsertChannelOverride(ctx, 1, 4, 0, 2); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + got, err := database.GetChannelOverrides(ctx, 1) + if err != nil { + t.Fatalf("GetChannelOverrides: %v", err) + } + if o, ok := got[4]; !ok || o.Deny != 2 { + t.Errorf("override for role 4 = %+v, want deny=2", o) + } +} + +// TestMessagesForAPI_CarryMentions locks that REST history hands back the +// resolved mention list and the everyone flag. +func TestMessagesForAPI_CarryMentions(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + if _, err := database.CreateMessageWithMentions(ctx, 1, 1, "hi @bob", nil, []int64{2}, true); err != nil { + t.Fatalf("CreateMessageWithMentions: %v", err) + } + msgs, err := database.GetMessagesForAPI(ctx, 1, 0, 10, 1) + if err != nil { + t.Fatalf("GetMessagesForAPI: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("messages = %d, want 1", len(msgs)) + } + if len(msgs[0].Mentions) != 1 || msgs[0].Mentions[0] != 2 { + t.Errorf("mentions = %v, want [2]", msgs[0].Mentions) + } + if !msgs[0].MentionsEveryone { + t.Error("mentions_everyone = false, want true") + } +} + +func TestSearchMessages_CarryMentions(t *testing.T) { + database := newMigratedTestDB(t) + seedMentionFixture(t, database) + ctx := context.Background() + + if _, err := database.CreateMessageWithMentions(ctx, 1, 1, "deployment notes", nil, []int64{2}, false); err != nil { + t.Fatalf("CreateMessageWithMentions: %v", err) + } + results, err := database.SearchMessages(ctx, "deployment", nil, 10) + if err != nil { + t.Fatalf("SearchMessages: %v", err) + } + if len(results) != 1 { + t.Fatalf("results = %d, want 1", len(results)) + } + if len(results[0].Mentions) != 1 || results[0].Mentions[0] != 2 { + t.Errorf("mentions = %v, want [2]", results[0].Mentions) + } + if results[0].MentionsEveryone { + t.Error("mentions_everyone = true, want false") + } +} diff --git a/Server/db/message_queries.go b/Server/db/message_queries.go index a6d3d74a..ee8b956c 100644 --- a/Server/db/message_queries.go +++ b/Server/db/message_queries.go @@ -14,15 +14,16 @@ import ( // messageFromGen maps a generated message row to the domain Message model. func messageFromGen(m dbgen.Message) *Message { return &Message{ - ID: m.ID, - ChannelID: m.ChannelID, - UserID: m.UserID, - Content: m.Content, - ReplyTo: m.ReplyTo, - EditedAt: m.EditedAt, - Deleted: m.Deleted != 0, - Pinned: m.Pinned != 0, - Timestamp: m.Timestamp, + ID: m.ID, + ChannelID: m.ChannelID, + UserID: m.UserID, + Content: m.Content, + ReplyTo: m.ReplyTo, + EditedAt: m.EditedAt, + Deleted: m.Deleted != 0, + Pinned: m.Pinned != 0, + Timestamp: m.Timestamp, + MentionsEveryone: m.MentionsEveryone != 0, } } @@ -179,6 +180,74 @@ func (d *DB) DeleteMessage(ctx context.Context, id, userID int64, ismod bool) er return nil } +// PurgeChannelMessages soft-deletes the newest limit non-deleted messages in a +// channel and returns their IDs, newest first. When before > 0 only messages +// with id < before are considered. +// +// Rows are marked deleted=1 and otherwise left intact, so the tombstones every +// reader already renders (and the reply_to targets pointing at them) survive a +// purge exactly as they do a single delete. Selection and update run in one +// writer transaction so a concurrent single delete cannot make the reported id +// set diverge from what was actually written. +func (d *DB) PurgeChannelMessages(ctx context.Context, channelID, before int64, limit int) ([]int64, error) { + if limit < 1 { + return []int64{}, nil + } + + tx, err := d.writer.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("PurgeChannelMessages begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + sel := `SELECT id FROM messages WHERE channel_id = ? AND deleted = 0 ORDER BY id DESC LIMIT ?` + args := []any{channelID, limit} + if before > 0 { + sel = `SELECT id FROM messages WHERE channel_id = ? AND id < ? AND deleted = 0 ORDER BY id DESC LIMIT ?` + args = []any{channelID, before, limit} + } + + rows, err := tx.QueryContext(ctx, sel, args...) + if err != nil { + return nil, fmt.Errorf("PurgeChannelMessages select: %w", err) + } + var ids []int64 + for rows.Next() { + var id int64 + if scanErr := rows.Scan(&id); scanErr != nil { + rows.Close() //nolint:errcheck + return nil, fmt.Errorf("PurgeChannelMessages scan: %w", scanErr) + } + ids = append(ids, id) + } + rows.Close() //nolint:errcheck + if rows.Err() != nil { + return nil, fmt.Errorf("PurgeChannelMessages rows: %w", rows.Err()) + } + if len(ids) == 0 { + return []int64{}, nil + } + + placeholders := make([]string, len(ids)) + updateArgs := make([]any, 0, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + updateArgs = append(updateArgs, id) + } + if _, err := tx.ExecContext(ctx, + fmt.Sprintf(`UPDATE messages SET deleted = 1 WHERE id IN (%s)`, //nolint:gosec // G201: placeholder interpolation, not user input + strings.Join(placeholders, ",")), + updateArgs..., + ); err != nil { + return nil, fmt.Errorf("PurgeChannelMessages update: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("PurgeChannelMessages commit: %w", err) + } + return ids, nil +} + // AddReaction inserts a reaction. Returns an error on duplicate (same user+emoji+message). func (d *DB) AddReaction(ctx context.Context, messageID, userID int64, emoji string) error { if err := d.q.AddReaction(ctx, dbgen.AddReactionParams{ @@ -222,6 +291,32 @@ func (d *DB) GetReactions(ctx context.Context, messageID int64) ([]ReactionCount return counts, nil } +// MaxReactionUsers bounds the who-reacted list. A reaction pill can carry +// thousands of reactors; the tooltip only ever names a handful, so the query is +// capped rather than paginated. +const MaxReactionUsers = 100 + +// GetReactionUsers returns up to limit reactors for one (message, emoji) pair, +// oldest reaction first. limit is clamped to MaxReactionUsers. +func (d *DB) GetReactionUsers(ctx context.Context, messageID int64, emoji string, limit int) ([]ReactionUser, error) { + if limit <= 0 || limit > MaxReactionUsers { + limit = MaxReactionUsers + } + rows, err := d.q.GetReactionUsers(ctx, dbgen.GetReactionUsersParams{ + MessageID: messageID, + Emoji: emoji, + Limit: int64(limit), + }) + if err != nil { + return nil, fmt.Errorf("GetReactionUsers: %w", err) + } + users := make([]ReactionUser, 0, len(rows)) + for _, r := range rows { + users = append(users, ReactionUser{ID: r.ID, Username: r.Username, Avatar: r.Avatar}) + } + return users, 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. @@ -244,7 +339,8 @@ func (d *DB) SearchMessages(ctx context.Context, query string, channelID *int64, if channelID != nil { rows, err = d.reader.QueryContext(ctx, - `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp + `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, + m.timestamp, m.mentions_everyone FROM messages_fts f JOIN messages m ON f.rowid = m.id JOIN channels c ON m.channel_id = c.id @@ -255,7 +351,8 @@ func (d *DB) SearchMessages(ctx context.Context, query string, channelID *int64, ) } else { rows, err = d.reader.QueryContext(ctx, - `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp + `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, + m.timestamp, m.mentions_everyone FROM messages_fts f JOIN messages m ON f.rowid = m.id JOIN channels c ON m.channel_id = c.id @@ -270,23 +367,11 @@ func (d *DB) SearchMessages(ctx context.Context, query string, channelID *int64, } 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) + results, err := scanSearchResults(rows, "SearchMessages") + if err != nil { + return nil, err } - if rows.Err() != nil { - return nil, fmt.Errorf("SearchMessages rows: %w", rows.Err()) - } - if results == nil { - results = []MessageSearchResult{} - } - return results, nil + return d.attachSearchMentions(ctx, results) } // SearchMessagesInChannels performs a full-text search scoped to the given @@ -316,7 +401,8 @@ func (d *DB) SearchMessagesInChannels(ctx context.Context, query string, channel rows, err := d.reader.QueryContext(ctx, fmt.Sprintf( - `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp + `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, + m.timestamp, m.mentions_everyone FROM messages_fts f JOIN messages m ON f.rowid = m.id JOIN channels c ON m.channel_id = c.id @@ -331,23 +417,11 @@ func (d *DB) SearchMessagesInChannels(ctx context.Context, query string, channel } 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("SearchMessagesInChannels scan: %w", scanErr) - } - results = append(results, r) + results, err := scanSearchResults(rows, "SearchMessagesInChannels") + if err != nil { + return nil, err } - if rows.Err() != nil { - return nil, fmt.Errorf("SearchMessagesInChannels rows: %w", rows.Err()) - } - if results == nil { - results = []MessageSearchResult{} - } - return results, nil + return d.attachSearchMentions(ctx, results) } // GetMessagesForAPI returns messages in the API.md response shape, including @@ -360,7 +434,8 @@ func (d *DB) GetMessagesForAPI(ctx context.Context, channelID, before int64, lim if before > 0 { rows, err = d.reader.QueryContext(ctx, `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 + m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp, + m.mentions_everyone 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 ?`, @@ -369,7 +444,8 @@ func (d *DB) GetMessagesForAPI(ctx context.Context, channelID, before int64, lim } else { rows, err = d.reader.QueryContext(ctx, `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 + m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp, + m.mentions_everyone 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 ?`, @@ -384,6 +460,53 @@ func (d *DB) GetMessagesForAPI(ctx context.Context, channelID, before int64, lim return d.scanAndEnrichMessages(ctx, rows, requestingUserID) } +// GetMessagesAroundForAPI returns a window of messages centred on centerID in +// the API response shape, ordered oldest-first: up to beforeCount messages +// older than the centre, the centre itself, and up to afterCount newer ones. +// +// Callers that need to know whether the channel holds more history on either +// side pass one extra on each count and inspect the returned slice — this +// query does no probing of its own. +func (d *DB) GetMessagesAroundForAPI(ctx context.Context, channelID, centerID int64, beforeCount, afterCount int, requestingUserID int64) ([]MessageAPIResponse, error) { + if beforeCount < 0 { + beforeCount = 0 + } + if afterCount < 0 { + afterCount = 0 + } + // SQLite forbids ORDER BY/LIMIT on a compound-SELECT operand, so each half + // of the window is a nested subquery. The older half includes the centre + // row itself, hence beforeCount+1. + rows, err := d.reader.QueryContext(ctx, + `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, + m.mentions_everyone + FROM messages m JOIN users u ON m.user_id = u.id + WHERE m.id IN ( + SELECT id FROM ( + SELECT id FROM messages + WHERE channel_id = ? AND deleted = 0 AND id <= ? + ORDER BY id DESC LIMIT ? + ) + UNION ALL + SELECT id FROM ( + SELECT id FROM messages + WHERE channel_id = ? AND deleted = 0 AND id > ? + ORDER BY id ASC LIMIT ? + ) + ) + ORDER BY m.id ASC`, + channelID, centerID, beforeCount+1, + channelID, centerID, afterCount, + ) + if err != nil { + return nil, fmt.Errorf("GetMessagesAroundForAPI: %w", err) + } + defer rows.Close() //nolint:errcheck + + return d.scanAndEnrichMessages(ctx, rows, requestingUserID) +} + // getReactionsBatch returns aggregated reactions for multiple messages. func (d *DB) getReactionsBatch(ctx context.Context, msgIDs []int64, requestingUserID int64) (map[int64][]ReactionInfo, error) { if len(msgIDs) == 0 { @@ -436,7 +559,8 @@ func (d *DB) getReactionsBatch(ctx context.Context, msgIDs []int64, requestingUs return result, nil } -// UpdateReadState upserts the read state for a user in a channel. +// UpdateReadState upserts the read state for a user in a channel and clears +// its mention badge — marking a channel read consumes its mentions. func (d *DB) UpdateReadState(ctx context.Context, userID, channelID, lastReadMessageID int64) error { if err := d.q.UpdateReadState(ctx, dbgen.UpdateReadStateParams{ UserID: userID, @@ -450,9 +574,12 @@ func (d *DB) UpdateReadState(ctx context.Context, userID, channelID, lastReadMes // GetChannelUnreadCounts returns per-channel unread counts and last message IDs // for a given user. Text and announcement channels are included, with 0,0 for -// channels that have no messages. Correlated subqueries range-scan -// idx_messages_channel per channel instead of the old LEFT JOIN fan-out that -// touched every message row on every WS connect. +// channels that have no messages. DM channels are included too, but only the +// ones this user participates in — without them the ready payload carried no +// mention_count for DMs, so a DM mention badge silently reset on every +// reconnect. Correlated subqueries range-scan idx_messages_channel per channel +// instead of the old LEFT JOIN fan-out that touched every message row on every +// WS connect; the DM predicate hits idx_dm_participants_user. func (d *DB) GetChannelUnreadCounts(ctx context.Context, userID int64) (map[int64]ChannelUnread, error) { rows, err := d.reader.QueryContext(ctx, `SELECT c.id, @@ -461,10 +588,14 @@ func (d *DB) GetChannelUnreadCounts(ctx context.Context, userID int64) (map[int6 (SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id AND m.deleted = 0 AND m.id > COALESCE((SELECT rs.last_message_id FROM read_states rs - WHERE rs.channel_id = c.id AND rs.user_id = ?), 0)) AS unread + WHERE rs.channel_id = c.id AND rs.user_id = ?), 0)) AS unread, + COALESCE((SELECT rs.mention_count FROM read_states rs + WHERE rs.channel_id = c.id AND rs.user_id = ?), 0) AS mentions FROM channels c - WHERE c.type IN ('text', 'announcement')`, - userID, + WHERE c.type IN ('text', 'announcement') + OR (c.type = 'dm' AND EXISTS (SELECT 1 FROM dm_participants dp + WHERE dp.channel_id = c.id AND dp.user_id = ?))`, + userID, userID, userID, ) if err != nil { return nil, fmt.Errorf("GetChannelUnreadCounts: %w", err) @@ -475,7 +606,7 @@ func (d *DB) GetChannelUnreadCounts(ctx context.Context, userID int64) (map[int6 for rows.Next() { var chID int64 var cu ChannelUnread - if scanErr := rows.Scan(&chID, &cu.LastMessageID, &cu.UnreadCount); scanErr != nil { + if scanErr := rows.Scan(&chID, &cu.LastMessageID, &cu.UnreadCount, &cu.MentionCount); scanErr != nil { return nil, fmt.Errorf("GetChannelUnreadCounts scan: %w", scanErr) } result[chID] = cu @@ -504,7 +635,8 @@ func (d *DB) GetLatestMessageID(ctx context.Context, channelID int64) (int64, er func (d *DB) GetPinnedMessages(ctx context.Context, channelID int64, requestingUserID int64) ([]MessageAPIResponse, error) { rows, err := d.reader.QueryContext(ctx, `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 + m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp, + m.mentions_everyone FROM messages m JOIN users u ON m.user_id = u.id WHERE m.channel_id = ? AND m.pinned = 1 AND m.deleted = 0 ORDER BY m.id DESC`, @@ -525,17 +657,20 @@ func (d *DB) scanAndEnrichMessages(ctx context.Context, rows *sql.Rows, requesti var msgIDs []int64 for rows.Next() { var m MessageAPIResponse - var deleted, pinned int + var deleted, pinned, everyone 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, + &everyone, ); scanErr != nil { return nil, fmt.Errorf("scanAndEnrichMessages scan: %w", scanErr) } m.Deleted = deleted != 0 m.Pinned = pinned != 0 + m.MentionsEveryone = everyone != 0 m.Attachments = []AttachmentInfo{} m.Reactions = []ReactionInfo{} + m.Mentions = []int64{} msgs = append(msgs, m) msgIDs = append(msgIDs, m.ID) } @@ -568,6 +703,17 @@ func (d *DB) scanAndEnrichMessages(ctx context.Context, rows *sql.Rows, requesti } } + // Batch-fetch resolved mentions for all message IDs. + mentionMap, err := d.GetMentionsByMessageIDs(ctx, msgIDs) + if err != nil { + return nil, fmt.Errorf("scanAndEnrichMessages mentions: %w", err) + } + for i := range msgs { + if mIDs, ok := mentionMap[msgs[i].ID]; ok { + msgs[i].Mentions = mIDs + } + } + return msgs, nil } @@ -590,6 +736,50 @@ func (d *DB) SetMessagePinned(ctx context.Context, id int64, pinned bool) error // ─── helpers ────────────────────────────────────────────────────────────────── +// scanSearchResults scans FTS search rows. label names the calling query in +// error messages. Never returns a nil slice. +func scanSearchResults(rows *sql.Rows, label string) ([]MessageSearchResult, error) { + results := []MessageSearchResult{} + for rows.Next() { + var r MessageSearchResult + var everyone int + if scanErr := rows.Scan(&r.MessageID, &r.ChannelID, &r.ChannelName, + &r.User.ID, &r.User.Username, &r.User.Avatar, + &r.Content, &r.Timestamp, &everyone); scanErr != nil { + return nil, fmt.Errorf("%s scan: %w", label, scanErr) + } + r.MentionsEveryone = everyone != 0 + r.Mentions = []int64{} + results = append(results, r) + } + if rows.Err() != nil { + return nil, fmt.Errorf("%s rows: %w", label, rows.Err()) + } + return results, nil +} + +// attachSearchMentions fills in the resolved mention ids for search hits in one +// batch query, mirroring how scanAndEnrichMessages enriches history rows. +func (d *DB) attachSearchMentions(ctx context.Context, results []MessageSearchResult) ([]MessageSearchResult, error) { + if len(results) == 0 { + return results, nil + } + ids := make([]int64, len(results)) + for i := range results { + ids[i] = results[i].MessageID + } + mentionMap, err := d.GetMentionsByMessageIDs(ctx, ids) + if err != nil { + return nil, fmt.Errorf("attachSearchMentions: %w", err) + } + for i := range results { + if m, ok := mentionMap[results[i].MessageID]; ok { + results[i].Mentions = m + } + } + return results, nil +} + // scanMessageWithUser scans a MessageWithUser from *sql.Rows. func scanMessageWithUser(rows *sql.Rows) (MessageWithUser, error) { var mwu MessageWithUser diff --git a/Server/db/message_queries_test.go b/Server/db/message_queries_test.go index 07e49910..42ce5f83 100644 --- a/Server/db/message_queries_test.go +++ b/Server/db/message_queries_test.go @@ -2,6 +2,8 @@ package db_test import ( "context" + "slices" + "strconv" "testing" "github.com/owncord/server/db" @@ -335,6 +337,149 @@ func TestDeleteMessage_NotFound(t *testing.T) { } } +// ─── PurgeChannelMessages ───────────────────────────────────────────────────── + +// purgeSeed inserts n messages into a fresh channel and returns the database, +// the channel id, and the message ids in insertion (oldest-first) order. +func purgeSeed(t *testing.T, n int) (*db.DB, int64, []int64) { + t.Helper() + database := openMigratedMemory(t) + userID := seedUser(t, database, "purger") + chID := seedChannel(t, database, "purge-ch") + ids := make([]int64, 0, n) + for i := range n { + id, err := database.CreateMessage(context.Background(), chID, userID, + "msg"+strconv.Itoa(i), nil) + if err != nil { + t.Fatalf("CreateMessage %d: %v", i, err) + } + ids = append(ids, id) + } + return database, chID, ids +} + +func TestPurgeChannelMessages_DeletesNewestFirst(t *testing.T) { + database, chID, ids := purgeSeed(t, 5) + + got, err := database.PurgeChannelMessages(context.Background(), chID, 0, 2) + if err != nil { + t.Fatalf("PurgeChannelMessages: %v", err) + } + want := []int64{ids[4], ids[3]} + if !slices.Equal(got, want) { + t.Fatalf("purged ids = %v, want %v", got, want) + } + for _, id := range want { + msg, _ := database.GetMessage(context.Background(), id) + if msg == nil || !msg.Deleted { + t.Errorf("message %d should be soft-deleted", id) + } + } + for _, id := range ids[:3] { + msg, _ := database.GetMessage(context.Background(), id) + if msg == nil || msg.Deleted { + t.Errorf("message %d should be untouched", id) + } + } +} + +func TestPurgeChannelMessages_PreservesTombstones(t *testing.T) { + database, chID, ids := purgeSeed(t, 3) + + if _, err := database.PurgeChannelMessages(context.Background(), chID, 0, 3); err != nil { + t.Fatalf("PurgeChannelMessages: %v", err) + } + + // The rows must survive with their content, so tombstones render and + // reply_to targets still resolve — exactly as a single soft delete. + for _, id := range ids { + msg, err := database.GetMessage(context.Background(), id) + if err != nil { + t.Fatalf("GetMessage(%d): %v", id, err) + } + if msg == nil { + t.Fatalf("message %d was hard-deleted", id) + } + if !msg.Deleted { + t.Errorf("message %d not marked deleted", id) + } + if msg.Content == "" { + t.Errorf("message %d lost its content", id) + } + } +} + +func TestPurgeChannelMessages_SkipsAlreadyDeleted(t *testing.T) { + database, chID, ids := purgeSeed(t, 4) + if _, err := database.PurgeChannelMessages(context.Background(), chID, 0, 1); err != nil { + t.Fatalf("first purge: %v", err) + } + + got, err := database.PurgeChannelMessages(context.Background(), chID, 0, 10) + if err != nil { + t.Fatalf("second purge: %v", err) + } + want := []int64{ids[2], ids[1], ids[0]} + if !slices.Equal(got, want) { + t.Fatalf("second purge ids = %v, want %v", got, want) + } +} + +func TestPurgeChannelMessages_BeforeCursor(t *testing.T) { + database, chID, ids := purgeSeed(t, 5) + + got, err := database.PurgeChannelMessages(context.Background(), chID, ids[2], 10) + if err != nil { + t.Fatalf("PurgeChannelMessages: %v", err) + } + want := []int64{ids[1], ids[0]} + if !slices.Equal(got, want) { + t.Fatalf("purged ids = %v, want %v", got, want) + } + for _, id := range ids[2:] { + msg, _ := database.GetMessage(context.Background(), id) + if msg.Deleted { + t.Errorf("message %d at/after the cursor should be untouched", id) + } + } +} + +func TestPurgeChannelMessages_OtherChannelsUntouched(t *testing.T) { + database, chID, ids := purgeSeed(t, 2) + otherCh := seedChannel(t, database, "other") + otherID, _ := database.CreateMessage(context.Background(), otherCh, ids[0], "keep", nil) + + if _, err := database.PurgeChannelMessages(context.Background(), chID, 0, 100); err != nil { + t.Fatalf("PurgeChannelMessages: %v", err) + } + + msg, _ := database.GetMessage(context.Background(), otherID) + if msg == nil || msg.Deleted { + t.Error("a message in another channel was purged") + } +} + +func TestPurgeChannelMessages_EmptyChannelAndZeroLimit(t *testing.T) { + database, chID, _ := purgeSeed(t, 1) + + got, err := database.PurgeChannelMessages(context.Background(), chID, 0, 0) + if err != nil { + t.Fatalf("zero limit: %v", err) + } + if len(got) != 0 { + t.Fatalf("zero limit purged %v, want none", got) + } + + emptyCh := seedChannel(t, database, "empty") + got, err = database.PurgeChannelMessages(context.Background(), emptyCh, 0, 50) + if err != nil { + t.Fatalf("empty channel: %v", err) + } + if got == nil || len(got) != 0 { + t.Fatalf("empty channel returned %v, want empty non-nil slice", got) + } +} + // ─── Reactions ──────────────────────────────────────────────────────────────── func TestAddReaction_Success(t *testing.T) { @@ -681,6 +826,147 @@ func TestGetMessagesForAPI_ExcludesDeleted(t *testing.T) { } } +// ─── GetMessagesAroundForAPI ──────────────────────────────────────────────── + +// seedAroundMessages fills a channel with n messages and returns their ids in +// ascending order. +func seedAroundMessages(t *testing.T, database *db.DB, chID, userID int64, n int) []int64 { + t.Helper() + ids := make([]int64, 0, n) + for i := range n { + id, err := database.CreateMessage(context.Background(), chID, userID, "m"+strconv.Itoa(i), nil) + if err != nil { + t.Fatalf("CreateMessage %d: %v", i, err) + } + ids = append(ids, id) + } + return ids +} + +func TestGetMessagesAroundForAPI_CentersAndOrdersAscending(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "aroundu1") + chID := seedChannel(t, database, "aroundc1") + ids := seedAroundMessages(t, database, chID, userID, 20) + + msgs, err := database.GetMessagesAroundForAPI(context.Background(), chID, ids[10], 3, 2, userID) + if err != nil { + t.Fatalf("GetMessagesAroundForAPI: %v", err) + } + // 3 older + centre + 2 newer. + want := []int64{ids[7], ids[8], ids[9], ids[10], ids[11], ids[12]} + got := make([]int64, 0, len(msgs)) + for _, m := range msgs { + got = append(got, m.ID) + } + if !slices.Equal(got, want) { + t.Errorf("window = %v, want %v", got, want) + } +} + +func TestGetMessagesAroundForAPI_ClampsAtChannelEdges(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "aroundu2") + chID := seedChannel(t, database, "aroundc2") + ids := seedAroundMessages(t, database, chID, userID, 4) + + first, err := database.GetMessagesAroundForAPI(context.Background(), chID, ids[0], 10, 10, userID) + if err != nil { + t.Fatalf("GetMessagesAroundForAPI(first): %v", err) + } + if len(first) != 4 || first[0].ID != ids[0] { + t.Errorf("window at the first message = %d entries starting at %d, want 4 starting at %d", + len(first), first[0].ID, ids[0]) + } + + last, err := database.GetMessagesAroundForAPI(context.Background(), chID, ids[3], 10, 10, userID) + if err != nil { + t.Fatalf("GetMessagesAroundForAPI(last): %v", err) + } + if len(last) != 4 || last[len(last)-1].ID != ids[3] { + t.Errorf("window at the last message = %d entries ending at %d, want 4 ending at %d", + len(last), last[len(last)-1].ID, ids[3]) + } +} + +func TestGetMessagesAroundForAPI_ExcludesDeleted(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "aroundu3") + chID := seedChannel(t, database, "aroundc3") + ids := seedAroundMessages(t, database, chID, userID, 5) + if err := database.DeleteMessage(context.Background(), ids[1], userID, false); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + + msgs, err := database.GetMessagesAroundForAPI(context.Background(), chID, ids[2], 5, 5, userID) + if err != nil { + t.Fatalf("GetMessagesAroundForAPI: %v", err) + } + for _, m := range msgs { + if m.ID == ids[1] { + t.Fatalf("deleted message %d present in the window", ids[1]) + } + } + if len(msgs) != 4 { + t.Errorf("window size = %d, want 4", len(msgs)) + } +} + +func TestGetMessagesAroundForAPI_ScopedToChannel(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "aroundu4") + chA := seedChannel(t, database, "aroundc4a") + chB := seedChannel(t, database, "aroundc4b") + idsA := seedAroundMessages(t, database, chA, userID, 3) + seedAroundMessages(t, database, chB, userID, 3) + + msgs, err := database.GetMessagesAroundForAPI(context.Background(), chA, idsA[1], 10, 10, userID) + if err != nil { + t.Fatalf("GetMessagesAroundForAPI: %v", err) + } + if len(msgs) != 3 { + t.Fatalf("window size = %d, want the 3 messages of channel A only", len(msgs)) + } + for _, m := range msgs { + if m.ChannelID != chA { + t.Errorf("message %d belongs to channel %d, not %d", m.ID, m.ChannelID, chA) + } + } +} + +func TestGetMessagesAroundForAPI_NegativeCountsClampToZero(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "aroundu5") + chID := seedChannel(t, database, "aroundc5") + ids := seedAroundMessages(t, database, chID, userID, 5) + + // A negative count must not become a negative SQL LIMIT (which SQLite + // reads as "no limit" and would silently return the whole channel). + msgs, err := database.GetMessagesAroundForAPI(context.Background(), chID, ids[2], -4, -4, userID) + if err != nil { + t.Fatalf("GetMessagesAroundForAPI: %v", err) + } + if len(msgs) != 1 || msgs[0].ID != ids[2] { + t.Errorf("window = %d entries, want just the centre %d", len(msgs), ids[2]) + } +} + +func TestGetMessagesAroundForAPI_UnknownCentreIsEmpty(t *testing.T) { + database := openMigratedMemory(t) + userID := seedUser(t, database, "aroundu6") + chID := seedChannel(t, database, "aroundc6") + seedAroundMessages(t, database, chID, userID, 3) + + msgs, err := database.GetMessagesAroundForAPI(context.Background(), chID, 999999, 5, 5, userID) + if err != nil { + t.Fatalf("GetMessagesAroundForAPI: %v", err) + } + // Nothing is <= the centre in this channel below it, and nothing above it. + if len(msgs) != 3 { + t.Errorf("window size = %d; an out-of-range centre should still be bounded by the channel", len(msgs)) + } +} + // ─── GetChannelUnreadCounts ───────────────────────────────────────────────── func TestGetChannelUnreadCounts_NoMessages(t *testing.T) { @@ -852,3 +1138,166 @@ func TestGetLatestMessageID_ExcludesDeleted(t *testing.T) { t.Errorf("GetLatestMessageID = %d, want %d (deleted excluded)", latestID, id1) } } + +// ─── GetReactionUsers ─────────────────────────────────────────────────────── + +func TestGetReactionUsers_ReturnsReactorsInReactionOrder(t *testing.T) { + database := openMigratedMemory(t) + chID := seedChannel(t, database, "reactusers") + author := seedUser(t, database, "reactauthor") + first := seedUser(t, database, "reactfirst") + second := seedUser(t, database, "reactsecond") + msgID, _ := database.CreateMessage(context.Background(), chID, author, "react to me", nil) + + // second reacts before first, so reaction order (not user id) decides. + if err := database.AddReaction(context.Background(), msgID, second, "👍"); err != nil { + t.Fatalf("AddReaction(second): %v", err) + } + if err := database.AddReaction(context.Background(), msgID, first, "👍"); err != nil { + t.Fatalf("AddReaction(first): %v", err) + } + // A different emoji on the same message must not leak in. + if err := database.AddReaction(context.Background(), msgID, author, "🎉"); err != nil { + t.Fatalf("AddReaction(other emoji): %v", err) + } + + users, err := database.GetReactionUsers(context.Background(), msgID, "👍", 100) + if err != nil { + t.Fatalf("GetReactionUsers: %v", err) + } + if len(users) != 2 { + t.Fatalf("len(users) = %d, want 2 (%+v)", len(users), users) + } + if users[0].Username != "reactsecond" || users[1].Username != "reactfirst" { + t.Errorf("order = [%s %s], want [reactsecond reactfirst]", users[0].Username, users[1].Username) + } + if users[0].ID != second { + t.Errorf("users[0].ID = %d, want %d", users[0].ID, second) + } +} + +func TestGetReactionUsers_UnknownEmojiIsEmptyNotError(t *testing.T) { + database := openMigratedMemory(t) + chID := seedChannel(t, database, "reactempty") + userID := seedUser(t, database, "reactnone") + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) + + users, err := database.GetReactionUsers(context.Background(), msgID, "🐉", 100) + if err != nil { + t.Fatalf("GetReactionUsers: %v", err) + } + if len(users) != 0 { + t.Errorf("len(users) = %d, want 0", len(users)) + } +} + +func TestGetReactionUsers_ClampsLimitToMax(t *testing.T) { + database := openMigratedMemory(t) + chID := seedChannel(t, database, "reactclamp") + author := seedUser(t, database, "clampauthor") + msgID, _ := database.CreateMessage(context.Background(), chID, author, "many", nil) + + const reactors = db.MaxReactionUsers + 5 + for i := range reactors { + uid := seedUser(t, database, "clamper"+strconv.Itoa(i)) + if err := database.AddReaction(context.Background(), msgID, uid, "👍"); err != nil { + t.Fatalf("AddReaction(%d): %v", i, err) + } + } + + // A caller asking for more than the cap still gets at most the cap. + users, err := database.GetReactionUsers(context.Background(), msgID, "👍", 10_000) + if err != nil { + t.Fatalf("GetReactionUsers: %v", err) + } + if len(users) != db.MaxReactionUsers { + t.Errorf("len(users) = %d, want %d", len(users), db.MaxReactionUsers) + } + + // A non-positive limit means "the cap", not "nothing". + users, err = database.GetReactionUsers(context.Background(), msgID, "👍", 0) + if err != nil { + t.Fatalf("GetReactionUsers(0): %v", err) + } + if len(users) != db.MaxReactionUsers { + t.Errorf("len(users) with limit 0 = %d, want %d", len(users), db.MaxReactionUsers) + } +} + +func TestGetReactionUsers_RespectsSmallerLimit(t *testing.T) { + database := openMigratedMemory(t) + chID := seedChannel(t, database, "reactsmall") + author := seedUser(t, database, "smallauthor") + msgID, _ := database.CreateMessage(context.Background(), chID, author, "some", nil) + for i := range 5 { + uid := seedUser(t, database, "smaller"+strconv.Itoa(i)) + _ = database.AddReaction(context.Background(), msgID, uid, "👍") + } + + users, err := database.GetReactionUsers(context.Background(), msgID, "👍", 2) + if err != nil { + t.Fatalf("GetReactionUsers: %v", err) + } + if len(users) != 2 { + t.Errorf("len(users) = %d, want 2", len(users)) + } +} + +// ─── GetChannelUnreadCounts: DM rows ──────────────────────────────────────── + +// DM channels the user participates in must appear, so the ready payload can +// ship a real mention_count for them instead of resetting the badge to 0 on +// every reconnect. +func TestGetChannelUnreadCounts_IncludesParticipatingDMs(t *testing.T) { + database := openMigratedMemory(t) + alice := seedUser(t, database, "dmunreadalice") + bob := seedUser(t, database, "dmunreadbob") + + ch, _, err := database.GetOrCreateDMChannel(context.Background(), alice, bob) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + msgID, _ := database.CreateMessage(context.Background(), ch.ID, bob, "hey", nil) + if err := database.IncrementMentionCounts(context.Background(), ch.ID, []int64{alice}); err != nil { + t.Fatalf("IncrementMentionCounts: %v", err) + } + + counts, err := database.GetChannelUnreadCounts(context.Background(), alice) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + cu, ok := counts[ch.ID] + if !ok { + t.Fatalf("DM channel %d missing from unread counts for a participant", ch.ID) + } + if cu.LastMessageID != msgID { + t.Errorf("LastMessageID = %d, want %d", cu.LastMessageID, msgID) + } + if cu.UnreadCount != 1 { + t.Errorf("UnreadCount = %d, want 1", cu.UnreadCount) + } + if cu.MentionCount != 1 { + t.Errorf("MentionCount = %d, want 1", cu.MentionCount) + } +} + +func TestGetChannelUnreadCounts_ExcludesForeignDMs(t *testing.T) { + database := openMigratedMemory(t) + alice := seedUser(t, database, "dmforeignalice") + bob := seedUser(t, database, "dmforeignbob") + outsider := seedUser(t, database, "dmforeignoutsider") + + ch, _, err := database.GetOrCreateDMChannel(context.Background(), alice, bob) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + _, _ = database.CreateMessage(context.Background(), ch.ID, bob, "private", nil) + + counts, err := database.GetChannelUnreadCounts(context.Background(), outsider) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + if _, ok := counts[ch.ID]; ok { + t.Errorf("DM channel %d leaked into a non-participant's unread counts", ch.ID) + } +} diff --git a/Server/db/migrate_test.go b/Server/db/migrate_test.go index f3529284..2509a3ee 100644 --- a/Server/db/migrate_test.go +++ b/Server/db/migrate_test.go @@ -694,3 +694,54 @@ func TestMigrate_LargeNumberOfMigrations(t *testing.T) { t.Errorf("schema_versions has %d rows, want %d", got, n) } } + +// TestMigrate_022SeedsMentionEveryone locks migration 022: the privileged +// seeded roles gain MENTION_EVERYONE (bit 21) and the plain Member role does +// not, so @everyone stays gated after an upgrade of an existing database. +func TestMigrate_022SeedsMentionEveryone(t *testing.T) { + database := openMemory(t) + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + const mentionEveryone = int64(0x200000) + for _, tc := range []struct { + roleID int64 + name string + want bool + }{ + {1, "Owner", true}, + {2, "Admin", true}, + {3, "Moderator", true}, + {4, "Member", false}, + } { + var perms int64 + if err := database.QueryRowContext(context.Background(), + `SELECT permissions FROM roles WHERE id = ?`, tc.roleID).Scan(&perms); err != nil { + t.Fatalf("read role %s: %v", tc.name, err) + } + if got := perms&mentionEveryone != 0; got != tc.want { + t.Errorf("%s MENTION_EVERYONE = %v, want %v (perms=0x%X)", tc.name, got, tc.want, perms) + } + } +} + +// TestMigrate_022CreatesMentionSchema locks the storage phase 3 relies on. +func TestMigrate_022CreatesMentionSchema(t *testing.T) { + database := openMemory(t) + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + if !tableExists(t, database, "message_mentions") { + t.Error("message_mentions table not created") + } + var n int + if err := database.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM pragma_table_info('messages') WHERE name = 'mentions_everyone'`, + ).Scan(&n); err != nil { + t.Fatalf("pragma_table_info: %v", err) + } + if n != 1 { + t.Error("messages.mentions_everyone column not added") + } +} diff --git a/Server/db/migrate_upgrade_test.go b/Server/db/migrate_upgrade_test.go new file mode 100644 index 00000000..d1e93932 --- /dev/null +++ b/Server/db/migrate_upgrade_test.go @@ -0,0 +1,311 @@ +package db_test + +// migrate_upgrade_test.go — schema-coherence and upgrade round-trip tests for +// the tracked migration system. These extend migrate_test.go (which locks +// MigrateFS's tracking mechanics) with two higher-level guarantees: +// +// 1. The full embedded migration chain produces a coherent end schema: every +// table/column added by phases 2-6 exists, and the MENTION_EVERYONE bit +// seeded by migration 022 lands on the privileged roles only. +// 2. A database that was created at an older schema point (migrations +// 001..019 only, before any of the phase 2-6 additions) and already has +// data in it can be upgraded by applying the remaining migrations +// (020..028) without error, and every pre-existing row survives with sane +// defaults for the newly added columns. +// +// TestMigrate_022SeedsMentionEveryone and TestMigrate_022CreatesMentionSchema +// in migrate_test.go already lock the mention-specific pieces in isolation; +// this file's TestMigrate_FullChainSchemaIsCoherent asserts the same facts as +// part of one end-to-end pass over the whole chain rather than duplicating +// those tests. + +import ( + "context" + "io/fs" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/migrations" +) + +// migrationCutoffFS presents a filtered view of an underlying migrations FS +// that only exposes files sorting lexicographically before cutoff. Migration +// filenames are zero-padded ("019_perf_indexes.sql", "020_..."), so a string +// cutoff of "020_" exposes exactly 001..019 and hides 020 and everything +// after it. It implements fs.ReadDirFS and fs.ReadFileFS directly so +// fs.ReadDir/fs.ReadFile use the filtered listing without needing Open to be +// exercised. +type migrationCutoffFS struct { + underlying fs.FS + cutoff string +} + +func (m migrationCutoffFS) included(name string) bool { + return name < m.cutoff +} + +func (m migrationCutoffFS) Open(name string) (fs.File, error) { + if name != "." && !m.included(name) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + return m.underlying.Open(name) +} + +func (m migrationCutoffFS) ReadDir(name string) ([]fs.DirEntry, error) { + entries, err := fs.ReadDir(m.underlying, name) + if err != nil { + return nil, err + } + out := make([]fs.DirEntry, 0, len(entries)) + for _, e := range entries { + if m.included(e.Name()) { + out = append(out, e) + } + } + return out, nil +} + +func (m migrationCutoffFS) ReadFile(name string) ([]byte, error) { + if !m.included(name) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + return fs.ReadFile(m.underlying, name) +} + +// columnExists reports whether a table has a column with the given name, +// using pragma_table_info so it works for columns added by ALTER TABLE ADD +// COLUMN as well as ones present since CREATE TABLE. +func columnExists(t *testing.T, database *db.DB, table, column string) bool { + t.Helper() + var n int + err := database.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, column, + ).Scan(&n) + if err != nil { + t.Fatalf("pragma_table_info(%s) for column %s: %v", table, column, err) + } + return n > 0 +} + +// TestMigrate_FullChainSchemaIsCoherent applies the full embedded migration +// chain to a fresh in-memory database and asserts that every table/column +// added across phases 2-6 exists, and that the MENTION_EVERYONE permission +// bit landed on exactly the privileged seeded roles. +func TestMigrate_FullChainSchemaIsCoherent(t *testing.T) { + database := openMemory(t) + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + for _, tbl := range []string{"message_mentions", "channel_user_overrides"} { + if !tableExists(t, database, tbl) { + t.Errorf("table %q not created by the full migration chain", tbl) + } + } + + type colCheck struct{ table, column string } + for _, c := range []colCheck{ + {"channels", "nsfw"}, + {"channels", "is_group"}, + {"users", "display_name"}, + {"users", "about"}, + {"users", "custom_status"}, + {"voice_states", "server_muted"}, + {"voice_states", "server_deafened"}, + {"emoji", "mime_type"}, + {"messages", "mentions_everyone"}, + } { + if !columnExists(t, database, c.table, c.column) { + t.Errorf("column %s.%s not added by the full migration chain", c.table, c.column) + } + } + + const mentionEveryone = int64(0x200000) + for _, tc := range []struct { + roleID int64 + name string + want bool + }{ + {1, "Owner", true}, + {2, "Admin", true}, + {3, "Moderator", true}, + {4, "Member", false}, + } { + var perms int64 + if err := database.QueryRowContext(context.Background(), + `SELECT permissions FROM roles WHERE id = ?`, tc.roleID).Scan(&perms); err != nil { + t.Fatalf("read role %s: %v", tc.name, err) + } + if got := perms&mentionEveryone != 0; got != tc.want { + t.Errorf("%s MENTION_EVERYONE = %v, want %v (perms=0x%X)", tc.name, got, tc.want, perms) + } + } +} + +// TestMigrate_UpgradeFromMigration019PreservesData simulates upgrading a +// database that was last migrated at 019_perf_indexes.sql: it builds that +// schema via a filtered view of the real embedded migrations, inserts a row +// each into users/roles/channels/messages/voice_states/emoji (the tables the +// 020..028 migrations touch), then applies the full chain and asserts: +// +// - the upgrade completes without error, +// - the pre-existing rows are all still present (by primary key), and +// - the new columns those rows gained have the migration's stated defaults +// (0/NULL), not some other value — i.e. old data is not silently +// backfilled with something other than the documented default. +func TestMigrate_UpgradeFromMigration019PreservesData(t *testing.T) { + database := openMemory(t) + ctx := context.Background() + + oldFS := migrationCutoffFS{underlying: migrations.FS, cutoff: "020_"} + if err := db.MigrateFS(database, oldFS); err != nil { + t.Fatalf("MigrateFS() building pre-020 schema: %v", err) + } + + // Sanity: none of the phase 2-6 additions exist yet. + for _, tbl := range []string{"message_mentions", "channel_user_overrides"} { + if tableExists(t, database, tbl) { + t.Fatalf("table %q already exists before migration 020+ ran — cutoff FS leaked later migrations", tbl) + } + } + if columnExists(t, database, "channels", "nsfw") { + t.Fatal("channels.nsfw already exists before migration 025 ran — cutoff FS leaked later migrations") + } + + // Seed rows on the pre-upgrade schema. The default seeded roles (1-4) and + // their permission masks already exist from 001; add one custom role, + // one user, one channel, and one message referencing them, plus a + // voice_states row and an emoji row so every 020..028-touched table has a + // pre-existing row to check survival on. + if _, err := database.ExecContext(ctx, + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (5, 'Veteran', '#00FF00', 0x00000663, 50, 0)`); err != nil { + t.Fatalf("seed role: %v", err) + } + if _, err := database.ExecContext(ctx, + `INSERT INTO users (id, username, password, role_id) VALUES (1, 'alice', 'hash', 5)`); err != nil { + t.Fatalf("seed user: %v", err) + } + if _, err := database.ExecContext(ctx, + `INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`); err != nil { + t.Fatalf("seed channel: %v", err) + } + if _, err := database.ExecContext(ctx, + `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, 1, 'hello from before the upgrade')`); err != nil { + t.Fatalf("seed message: %v", err) + } + if _, err := database.ExecContext(ctx, + `INSERT INTO voice_states (user_id, channel_id, muted, deafened) VALUES (1, 1, 1, 0)`); err != nil { + t.Fatalf("seed voice_states: %v", err) + } + if _, err := database.ExecContext(ctx, + `INSERT INTO emoji (id, shortcode, filename, uploaded_by) VALUES (1, 'partyparrot', 'stored-uuid', 1)`); err != nil { + t.Fatalf("seed emoji: %v", err) + } + + // Apply the remaining migrations (020..028) via the real production path. + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() upgrading from 019 to head: %v", err) + } + + // Every pre-existing row must still be present. + for _, tc := range []struct { + query string + args []any + desc string + }{ + {"SELECT 1 FROM roles WHERE id = ?", []any{5}, "custom role"}, + {"SELECT 1 FROM users WHERE id = ?", []any{1}, "user"}, + {"SELECT 1 FROM channels WHERE id = ?", []any{1}, "channel"}, + {"SELECT 1 FROM messages WHERE id = ?", []any{1}, "message"}, + {"SELECT 1 FROM voice_states WHERE user_id = ?", []any{1}, "voice_states"}, + {"SELECT 1 FROM emoji WHERE id = ?", []any{1}, "emoji"}, + } { + var one int + if err := database.QueryRowContext(ctx, tc.query, tc.args...).Scan(&one); err != nil { + t.Errorf("%s row did not survive the upgrade: %v", tc.desc, err) + } + } + + // New columns on the surviving rows must carry the documented defaults, + // not be silently backfilled with something else. + var nsfw, isGroup int + if err := database.QueryRowContext(ctx, + `SELECT nsfw, is_group FROM channels WHERE id = 1`).Scan(&nsfw, &isGroup); err != nil { + t.Fatalf("reading upgraded channel: %v", err) + } + if nsfw != 0 { + t.Errorf("channels.nsfw = %d for pre-existing channel, want 0", nsfw) + } + if isGroup != 0 { + t.Errorf("channels.is_group = %d for pre-existing channel, want 0", isGroup) + } + + var displayName, about, customStatus *string + if err := database.QueryRowContext(ctx, + `SELECT display_name, about, custom_status FROM users WHERE id = 1`).Scan(&displayName, &about, &customStatus); err != nil { + t.Fatalf("reading upgraded user: %v", err) + } + if displayName != nil || about != nil || customStatus != nil { + t.Errorf("upgraded user gained non-NULL profile fields: display_name=%v about=%v custom_status=%v", + displayName, about, customStatus) + } + + var serverMuted, serverDeafened int + if err := database.QueryRowContext(ctx, + `SELECT server_muted, server_deafened FROM voice_states WHERE user_id = 1`).Scan(&serverMuted, &serverDeafened); err != nil { + t.Fatalf("reading upgraded voice_states: %v", err) + } + if serverMuted != 0 || serverDeafened != 0 { + t.Errorf("voice_states gained non-zero server_muted/server_deafened: %d/%d", serverMuted, serverDeafened) + } + + var mimeType string + if err := database.QueryRowContext(ctx, + `SELECT mime_type FROM emoji WHERE id = 1`).Scan(&mimeType); err != nil { + t.Fatalf("reading upgraded emoji: %v", err) + } + if mimeType != "image/png" { + t.Errorf("emoji.mime_type = %q for pre-existing row, want the migration's documented default %q", mimeType, "image/png") + } + + var mentionsEveryone int + if err := database.QueryRowContext(ctx, + `SELECT mentions_everyone FROM messages WHERE id = 1`).Scan(&mentionsEveryone); err != nil { + t.Fatalf("reading upgraded message: %v", err) + } + if mentionsEveryone != 0 { + t.Errorf("messages.mentions_everyone = %d for pre-existing message, want 0", mentionsEveryone) + } + + // The custom role (id 5, not in the seeded 1-3 set) must NOT have gained + // MENTION_EVERYONE — migration 022 only updates roles 1-3. + const mentionEveryone = int64(0x200000) + var perms int64 + if err := database.QueryRowContext(ctx, `SELECT permissions FROM roles WHERE id = 5`).Scan(&perms); err != nil { + t.Fatalf("reading upgraded custom role: %v", err) + } + if perms&mentionEveryone != 0 { + t.Errorf("custom role id=5 gained MENTION_EVERYONE from the upgrade (perms=0x%X), want unchanged", perms) + } + + // New tables introduced by 022/024 must now exist and be queryable. + for _, tbl := range []string{"message_mentions", "channel_user_overrides"} { + if !tableExists(t, database, tbl) { + t.Errorf("table %q missing after upgrade", tbl) + } + } + + // Every migration file, old and new, must be recorded — this is the + // upgrade path's real contract: 001..019 came from the seed/normal path + // during the first MigrateFS call, 020..028 from the second. + all, err := fs.ReadDir(migrations.FS, ".") + if err != nil { + t.Fatalf("reading embedded migrations dir: %v", err) + } + for _, e := range all { + if !hasVersion(t, database, e.Name()) { + t.Errorf("migration %q not recorded in schema_versions after upgrade", e.Name()) + } + } +} diff --git a/Server/db/models.go b/Server/db/models.go index 5dc9f6e6..70eaf387 100644 --- a/Server/db/models.go +++ b/Server/db/models.go @@ -20,6 +20,28 @@ type User struct { // ECDSA P-256) used for TOFU pinning of voice E2EE announces. Nil = not // published (legacy client). IdentityPublicKey *string + // DisplayName is the optional nickname shown instead of Username. Nil = + // unset, and every renderer falls back to Username. Mentions still resolve + // against Username alone — it is the unique key. + DisplayName *string + // About is the optional profile bio shown in the profile popup. Nil = unset. + About *string + // CustomStatus is the optional free-text status line shown under the name. + // Nil = unset. Set over the WebSocket presence path and cleared on logout. + CustomStatus *string +} + +// EffectiveDisplayName returns the name to render for the user: the display +// name when set and non-empty, the username otherwise. Every payload builder +// goes through this so the fallback cannot be spelled three different ways. +func (u *User) EffectiveDisplayName() string { + if u == nil { + return "" + } + if u.DisplayName != nil && *u.DisplayName != "" { + return *u.DisplayName + } + return u.Username } // Session represents a row in the sessions table. @@ -98,6 +120,11 @@ type Channel struct { VoiceQuality *string `json:"voice_quality,omitempty"` MixingThreshold *int `json:"mixing_threshold,omitempty"` VoiceMaxVideo int `json:"voice_max_video"` + // NSFW marks the channel as possibly carrying sensitive content. It is + // metadata only: the server stores, ships and audits it but imposes no + // content behaviour of its own (see migration 025). Clients decide what + // to do with it — the desktop client shows a per-session age gate. + NSFW bool `json:"nsfw"` } // Message represents a row in the messages table. @@ -111,6 +138,10 @@ type Message struct { Deleted bool Pinned bool Timestamp string + // MentionsEveryone is set when the message resolved an @everyone or @here + // token and the author held MENTION_EVERYONE. Per-user mentions live in + // message_mentions. + MentionsEveryone bool } // MessageWithUser joins a Message with the author's public fields. @@ -129,12 +160,14 @@ type ReactionCount struct { // 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"` + 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"` + Mentions []int64 `json:"mentions"` + MentionsEveryone bool `json:"mentions_everyone"` } // UserPublic is the public-facing user shape for API responses. @@ -157,6 +190,10 @@ type MessageAPIResponse struct { EditedAt *string `json:"edited_at"` Deleted bool `json:"deleted"` Timestamp string `json:"timestamp"` + // Mentions is the resolved user ids the message mentions (never nil); + // MentionsEveryone reports an authorized @everyone/@here. + Mentions []int64 `json:"mentions"` + MentionsEveryone bool `json:"mentions_everyone"` } // AttachmentInfo is the attachment shape in API responses. @@ -177,24 +214,45 @@ type ReactionInfo struct { Me bool `json:"me"` } +// ReactionUser is one reactor in the who-reacted list returned by +// GET /channels/{id}/messages/{messageId}/reactions/{emoji}/users. Avatar is a +// plain string ("" = none) rather than UserPublic's pointer: the tooltip that +// consumes this never distinguishes null from empty. +type ReactionUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar"` +} + // VoiceState represents a row in the voice_states table. // It tracks which voice channel a user is in and their current audio state. +// +// ServerMuted/ServerDeafened are moderator-imposed and, unlike Muted/Deafened, +// the user cannot clear them: while set, their own voice_mute/voice_deafen +// unmute attempts are refused. They are scoped to the voice session — the row +// is deleted on leave — but survive a channel switch. 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"` - JoinedAt string `json:"-"` + 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"` + ServerMuted bool `json:"server_muted"` + ServerDeafened bool `json:"server_deafened"` + JoinedAt string `json:"-"` } // ChannelUnread holds per-user unread data for a single channel. type ChannelUnread struct { LastMessageID int64 `json:"last_message_id"` UnreadCount int `json:"unread_count"` + // MentionCount is read_states.mention_count: unread messages that mention + // this user directly or via an authorized @everyone/@here. Zeroed by + // channel_focus, never advanced by an edit. + MentionCount int `json:"mention_count"` } // ServerStats contains aggregate counts for the admin dashboard. @@ -226,5 +284,21 @@ type AuditEntry struct { CreatedAt string `json:"created_at"` } +// Emoji represents a row in the emoji table: one server-wide custom emoji. +// +// StoredAs is the storage-layer UUID the image bytes live under (the table's +// legacy column name is `filename`); it is never shown to a user and never +// derived from anything the uploader sent. Shortcode is always lowercase -- +// the only spelling the validator admits -- which is what makes the table's +// plain UNIQUE index a case-insensitive one. +type Emoji struct { + ID int64 `json:"id"` + Shortcode string `json:"shortcode"` + StoredAs string `json:"-"` + MimeType string `json:"-"` + UploadedBy int64 `json:"uploaded_by"` + 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/profile_queries.go b/Server/db/profile_queries.go index 9310a9f0..b2188c04 100644 --- a/Server/db/profile_queries.go +++ b/Server/db/profile_queries.go @@ -7,14 +7,18 @@ import ( "github.com/owncord/server/db/dbgen" ) -// UpdateUserProfile updates the username and avatar for the given user. +// UpdateUserProfile updates the username, avatar, display name and about text +// for the given user. All four are written unconditionally, so the caller is +// responsible for merging a partial PATCH against the current row. // Returns ErrNotFound if the user does not exist. Returns an error wrapping // a UNIQUE constraint violation if the username is already taken. -func (d *DB) UpdateUserProfile(ctx context.Context, userID int64, username string, avatar *string) error { +func (d *DB) UpdateUserProfile(ctx context.Context, userID int64, username string, avatar, displayName, about *string) error { result, err := d.q.UpdateUserProfile(ctx, dbgen.UpdateUserProfileParams{ - Username: username, - Avatar: avatar, - ID: userID, + Username: username, + Avatar: avatar, + DisplayName: displayName, + About: about, + ID: userID, }) if err != nil { return fmt.Errorf("UpdateUserProfile: %w", err) @@ -29,6 +33,31 @@ func (d *DB) UpdateUserProfile(ctx context.Context, userID int64, username strin return nil } +// UpdateUserCustomStatus sets (or clears, with nil) the user's custom status +// line. Kept separate from UpdateUserProfile because it arrives on the +// presence path and must not overwrite a concurrent profile edit. +func (d *DB) UpdateUserCustomStatus(ctx context.Context, userID int64, customStatus *string) error { + if err := d.q.UpdateUserCustomStatus(ctx, dbgen.UpdateUserCustomStatusParams{ + CustomStatus: customStatus, + ID: userID, + }); err != nil { + return fmt.Errorf("UpdateUserCustomStatus: %w", err) + } + return nil +} + +// IsAvatarFileURL reports whether url is currently some user's avatar. It is +// the authorization check that lets an uploaded avatar — an attachment with no +// channel, and therefore private to its uploader by default — be served to +// every authenticated user for exactly as long as it is in use. +func (d *DB) IsAvatarFileURL(ctx context.Context, url string) (bool, error) { + n, err := d.q.CountUsersWithAvatar(ctx, &url) + if err != nil { + return false, fmt.Errorf("IsAvatarFileURL: %w", err) + } + return n > 0, nil +} + // UpdateUserPassword sets a new password hash for the given user. func (d *DB) UpdateUserPassword(ctx context.Context, userID int64, newPasswordHash string) error { if err := d.q.UpdateUserPassword(ctx, dbgen.UpdateUserPasswordParams{ diff --git a/Server/db/profile_queries_test.go b/Server/db/profile_queries_test.go index 8ba2f669..ae6a9be3 100644 --- a/Server/db/profile_queries_test.go +++ b/Server/db/profile_queries_test.go @@ -15,7 +15,7 @@ func TestUpdateUserProfile_UsernameAndAvatar(t *testing.T) { } avatar := "https://example.com/avatar.png" - if err := database.UpdateUserProfile(context.Background(), id, "newname", &avatar); err != nil { + if err := database.UpdateUserProfile(context.Background(), id, "newname", &avatar, nil, nil); err != nil { t.Fatalf("UpdateUserProfile: %v", err) } @@ -35,7 +35,7 @@ func TestUpdateUserProfile_UsernameOnly(t *testing.T) { database := newTestDB(t) id, _ := database.CreateUser(context.Background(), "keepavatar", "hash", 4) - if err := database.UpdateUserProfile(context.Background(), id, "renamed", nil); err != nil { + if err := database.UpdateUserProfile(context.Background(), id, "renamed", nil, nil, nil); err != nil { t.Fatalf("UpdateUserProfile: %v", err) } @@ -53,7 +53,7 @@ func TestUpdateUserProfile_DuplicateUsername(t *testing.T) { database.CreateUser(context.Background(), "existing", "hash", 4) id2, _ := database.CreateUser(context.Background(), "changeme", "hash", 4) - err := database.UpdateUserProfile(context.Background(), id2, "existing", nil) + err := database.UpdateUserProfile(context.Background(), id2, "existing", nil, nil, nil) if err == nil { t.Error("UpdateUserProfile with duplicate username should return error") } @@ -61,7 +61,7 @@ func TestUpdateUserProfile_DuplicateUsername(t *testing.T) { func TestUpdateUserProfile_NonExistentUser(t *testing.T) { database := newTestDB(t) - err := database.UpdateUserProfile(context.Background(), 99999, "ghost", nil) + err := database.UpdateUserProfile(context.Background(), 99999, "ghost", nil, nil, nil) if err == nil { t.Error("UpdateUserProfile for non-existent user should return error") } diff --git a/Server/db/queries/sqlite/apitokens.sql b/Server/db/queries/sqlite/apitokens.sql index c82357d7..0197be5e 100644 --- a/Server/db/queries/sqlite/apitokens.sql +++ b/Server/db/queries/sqlite/apitokens.sql @@ -42,6 +42,7 @@ UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?; -- (and an explicit LIMIT 1 is mis-emitted by sqlc here). ORDER BY puts the -- highest-position role first, so that first row is the owner. SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key, + display_name, about, custom_status FROM users ORDER BY (SELECT r.position FROM roles r WHERE r.id = users.role_id) DESC, id ASC; diff --git a/Server/db/queries/sqlite/blocks.sql b/Server/db/queries/sqlite/blocks.sql index 3944b0fc..214038a1 100644 --- a/Server/db/queries/sqlite/blocks.sql +++ b/Server/db/queries/sqlite/blocks.sql @@ -15,3 +15,6 @@ LIMIT 1; -- name: ListBlockedUsers :many SELECT blocked_id FROM user_blocks WHERE blocker_id = ? ORDER BY created_at DESC; + +-- name: ListBlockersOfUser :many +SELECT blocker_id FROM user_blocks WHERE blocked_id = ?; diff --git a/Server/db/queries/sqlite/channels.sql b/Server/db/queries/sqlite/channels.sql index 67f3ed12..ce4c6134 100644 --- a/Server/db/queries/sqlite/channels.sql +++ b/Server/db/queries/sqlite/channels.sql @@ -4,7 +4,8 @@ SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') A COALESCE(voice_max_users, 0) AS voice_max_users, voice_quality, mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video + COALESCE(voice_max_video, 0) AS voice_max_video, + nsfw FROM channels ORDER BY position ASC, id ASC; -- name: GetChannel :one @@ -13,7 +14,8 @@ SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') A COALESCE(voice_max_users, 0) AS voice_max_users, voice_quality, mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video + COALESCE(voice_max_video, 0) AS voice_max_video, + nsfw FROM channels WHERE id = ?; -- name: CreateChannel :execresult @@ -33,7 +35,8 @@ DELETE FROM channels WHERE id = ?; -- name: AdminUpdateChannel :exec UPDATE channels -SET name = ?, topic = ?, slow_mode = ?, position = ?, archived = ? +SET name = ?, topic = ?, category = ?, slow_mode = ?, position = ?, archived = ?, + nsfw = ?, voice_max_users = ?, voice_max_video = ? WHERE id = ?; -- name: UpsertChannelPermission :exec @@ -46,8 +49,30 @@ ON CONFLICT(channel_id, role_id) DO UPDATE SET -- name: GetChannelPermission :one SELECT allow, deny FROM channel_overrides WHERE channel_id = ? AND role_id = ?; +-- name: GetChannelOverrides :many +SELECT role_id, allow, deny FROM channel_overrides WHERE channel_id = ?; + -- name: GetRoleChannelPermissions :many SELECT channel_id, allow, deny FROM channel_overrides WHERE role_id = ?; -- name: DeleteChannelPermission :exec DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = ?; + +-- name: UpsertChannelUserPermission :exec +INSERT INTO channel_user_overrides (channel_id, user_id, allow, deny) +VALUES (?, ?, ?, ?) +ON CONFLICT(channel_id, user_id) DO UPDATE SET + allow = excluded.allow, + deny = excluded.deny; + +-- name: GetChannelUserPermission :one +SELECT allow, deny FROM channel_user_overrides WHERE channel_id = ? AND user_id = ?; + +-- name: GetChannelUserOverrides :many +SELECT user_id, allow, deny FROM channel_user_overrides WHERE channel_id = ?; + +-- name: GetUserChannelPermissions :many +SELECT channel_id, allow, deny FROM channel_user_overrides WHERE user_id = ?; + +-- name: DeleteChannelUserPermission :exec +DELETE FROM channel_user_overrides WHERE channel_id = ? AND user_id = ?; diff --git a/Server/db/queries/sqlite/dm.sql b/Server/db/queries/sqlite/dm.sql index 752c56e3..6ef376d9 100644 --- a/Server/db/queries/sqlite/dm.sql +++ b/Server/db/queries/sqlite/dm.sql @@ -10,16 +10,42 @@ SELECT user_id FROM dm_participants WHERE user_id = ? AND channel_id = ?; -- name: GetDMParticipantIDs :many SELECT user_id FROM dm_participants WHERE channel_id = ?; +-- name: CountDMParticipants :one +SELECT COUNT(*) FROM dm_participants WHERE channel_id = ?; + +-- name: IsGroupDM :one +SELECT is_group FROM channels WHERE id = ? AND type = 'dm'; + +-- name: RemoveDMParticipant :exec +DELETE FROM dm_participants WHERE channel_id = ? AND user_id = ?; + +-- name: SetDMChannelName :exec +UPDATE channels SET name = ? WHERE id = ? AND type = 'dm'; + +-- name: GetDMParticipants :many +SELECT + u.id AS id, + u.username AS username, + COALESCE(u.display_name, '') AS display_name, + COALESCE(u.avatar, '') AS avatar, + u.status AS status +FROM dm_participants dp +JOIN users u ON u.id = dp.user_id +WHERE dp.channel_id = ? +ORDER BY u.id ASC; + -- name: GetUserDMChannelIDs :many SELECT channel_id FROM dm_open_state WHERE user_id = ?; +-- A DM row carries no recipient any more: dm_participants holds N users, so +-- "the other one" is only well defined for a two-person DM. The participant +-- set comes from GetDMParticipantsForUser below, one extra query for the whole +-- list rather than one per channel, and the Go layer stitches them together. -- name: GetUserDMChannels :many SELECT c.id AS channel_id, - u.id AS recipient_id, - u.username AS recipient_username, - COALESCE(u.avatar, '') AS recipient_avatar, - u.status AS recipient_status, + c.name AS name, + c.is_group AS is_group, lm.id AS last_message_id, COALESCE(lm.content, '') AS last_message, COALESCE(lm.timestamp, '') AS last_message_at, @@ -30,10 +56,25 @@ SELECT ) AS unread_count FROM dm_open_state dos JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' -JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != ? -JOIN users u ON u.id = dp.user_id LEFT JOIN messages lm ON lm.id = ( SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = 0 ) WHERE dos.user_id = ? ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC; + +-- Every participant of every DM the user has open, in one pass. Includes the +-- user themselves so a caller can tell "group of three" from "group of three +-- others"; the Go layer filters when it needs the others. +-- name: GetDMParticipantsForUser :many +SELECT + dp.channel_id AS channel_id, + u.id AS id, + u.username AS username, + COALESCE(u.display_name, '') AS display_name, + COALESCE(u.avatar, '') AS avatar, + u.status AS status +FROM dm_open_state dos +JOIN dm_participants dp ON dp.channel_id = dos.channel_id +JOIN users u ON u.id = dp.user_id +WHERE dos.user_id = ? +ORDER BY dp.channel_id ASC, u.id ASC; diff --git a/Server/db/queries/sqlite/emoji.sql b/Server/db/queries/sqlite/emoji.sql new file mode 100644 index 00000000..6ffdbe09 --- /dev/null +++ b/Server/db/queries/sqlite/emoji.sql @@ -0,0 +1,19 @@ +-- name: ListEmoji :many +SELECT id, shortcode, filename, mime_type, uploaded_by, created_at +FROM emoji ORDER BY shortcode ASC; + +-- name: GetEmojiByID :one +SELECT id, shortcode, filename, mime_type, uploaded_by, created_at +FROM emoji WHERE id = ?; + +-- name: GetEmojiByShortcode :one +SELECT id, shortcode, filename, mime_type, uploaded_by, created_at +FROM emoji WHERE shortcode = ?; + +-- name: CreateEmoji :one +INSERT INTO emoji (shortcode, filename, mime_type, uploaded_by) +VALUES (?, ?, ?, ?) +RETURNING id, shortcode, filename, mime_type, uploaded_by, created_at; + +-- name: DeleteEmoji :execresult +DELETE FROM emoji WHERE id = ?; diff --git a/Server/db/queries/sqlite/messages.sql b/Server/db/queries/sqlite/messages.sql index f0ca8187..bdc81a96 100644 --- a/Server/db/queries/sqlite/messages.sql +++ b/Server/db/queries/sqlite/messages.sql @@ -1,9 +1,11 @@ -- name: CreateMessage :one INSERT INTO messages (channel_id, user_id, content, reply_to) VALUES (?, ?, ?, ?) -RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp; +RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp, + mentions_everyone; -- name: GetMessage :one -SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp +SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp, + mentions_everyone FROM messages WHERE id = ?; -- name: GetMessagesForAPI :many @@ -15,7 +17,8 @@ ORDER BY m.id DESC LIMIT ?; -- name: EditMessageContent :one UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ? -RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp; +RETURNING id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp, + mentions_everyone; -- name: SoftDeleteMessage :exec UPDATE messages SET deleted = 1 WHERE id = ?; @@ -27,9 +30,13 @@ UPDATE messages SET pinned = ? WHERE id = ? AND deleted = 0; SELECT COALESCE(MAX(id), 0) FROM messages WHERE channel_id = ? AND deleted = 0; -- name: UpdateReadState :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; +-- Marking a channel read also clears its mention badge: channel_focus is the +-- only caller, and a focused channel has no outstanding mentions by definition. +INSERT INTO read_states (user_id, channel_id, last_message_id, mention_count) +VALUES (?, ?, ?, 0) +ON CONFLICT(user_id, channel_id) DO UPDATE SET + last_message_id = excluded.last_message_id, + mention_count = 0; -- name: GetChannelUnreadCounts :many SELECT c.id, @@ -38,9 +45,13 @@ SELECT c.id, (SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id AND m.deleted = 0 AND m.id > COALESCE((SELECT rs.last_message_id FROM read_states rs - WHERE rs.channel_id = c.id AND rs.user_id = ?), 0)) AS unread + WHERE rs.channel_id = c.id AND rs.user_id = ?), 0)) AS unread, + COALESCE((SELECT rs.mention_count FROM read_states rs + WHERE rs.channel_id = c.id AND rs.user_id = ?), 0) AS mentions FROM channels c -WHERE c.type IN ('text', 'announcement'); +WHERE c.type IN ('text', 'announcement') + OR (c.type = 'dm' AND EXISTS (SELECT 1 FROM dm_participants dp + WHERE dp.channel_id = c.id AND dp.user_id = ?)); -- SearchMessages and SearchMessagesInChannel use the messages_fts FTS5 virtual -- table which sqlc cannot introspect. Those queries remain as hand-written Go diff --git a/Server/db/queries/sqlite/profile.sql b/Server/db/queries/sqlite/profile.sql index dee1708e..9215bb7f 100644 --- a/Server/db/queries/sqlite/profile.sql +++ b/Server/db/queries/sqlite/profile.sql @@ -1,5 +1,19 @@ -- name: UpdateUserProfile :execresult -UPDATE users SET username = ?, avatar = ? WHERE id = ?; +UPDATE users +SET username = ?, avatar = ?, display_name = ?, about = ? +WHERE id = ?; -- name: UpdateUserPassword :exec UPDATE users SET password = ? WHERE id = ?; + +-- name: UpdateUserCustomStatus :exec +-- Separate from UpdateUserProfile because a custom status arrives over the +-- WebSocket presence path, not the REST profile PATCH, and must not be able to +-- clobber the username/avatar of a profile edit racing it. +UPDATE users SET custom_status = ? WHERE id = ?; + +-- name: CountUsersWithAvatar :one +-- Authorization probe for the file route: an unlinked attachment is readable by +-- everyone exactly while some user's avatar points at it. Covered by the +-- partial index on users(avatar) added in migration 027. +SELECT COUNT(*) FROM users WHERE avatar = ?; diff --git a/Server/db/queries/sqlite/reactions.sql b/Server/db/queries/sqlite/reactions.sql index f9e7b3ff..c18c590c 100644 --- a/Server/db/queries/sqlite/reactions.sql +++ b/Server/db/queries/sqlite/reactions.sql @@ -8,3 +8,13 @@ DELETE FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?; SELECT emoji, COUNT(*) AS count FROM reactions WHERE message_id = ? GROUP BY emoji; + +-- name: GetReactionUsers :many +-- Reactors for one (message, emoji) pair, oldest reaction first. The reactions +-- table has no timestamp column, so the autoincrement id carries the order. +SELECT u.id, u.username, COALESCE(u.avatar, '') AS avatar +FROM reactions r +JOIN users u ON u.id = r.user_id +WHERE r.message_id = ? AND r.emoji = ? +ORDER BY r.id +LIMIT ?; diff --git a/Server/db/queries/sqlite/roles.sql b/Server/db/queries/sqlite/roles.sql index 194f42bf..2007907e 100644 --- a/Server/db/queries/sqlite/roles.sql +++ b/Server/db/queries/sqlite/roles.sql @@ -3,8 +3,16 @@ SELECT id, name, color, permissions, position, is_default FROM roles WHERE id = ?; -- name: ListRoles :many +-- Highest rank first. Positions are only "unique enough": reorder normalizes +-- them, but creating a role inserts just below the actor and may tie with an +-- existing role, so id is a tiebreaker. Without it SQLite may return tied rows +-- in any order, and the admin panel derives its reorder payload from this +-- order, so a single move-up would silently shuffle the tied roles. +-- NOTE: keep comments in this file ASCII-only. sqlc mixes byte and rune +-- offsets when stripping them, so a non-ASCII character here truncates the +-- generated SQL of THIS and every following query by the byte/rune delta. SELECT id, name, color, permissions, position, is_default -FROM roles ORDER BY position DESC; +FROM roles ORDER BY position DESC, id ASC; -- name: GetRoleForUser :one SELECT r.id, r.name, r.color, r.permissions, r.position, r.is_default @@ -21,3 +29,35 @@ FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?; +-- name: GetRoleByName :one +-- Case-insensitive by design: migration 023 enforces uniqueness under the same +-- collation, so this is the lookup that agrees with the constraint. +SELECT id, name, color, permissions, position, is_default +FROM roles WHERE name = ? COLLATE NOCASE; + +-- name: GetDefaultRole :one +-- The fallback role every member lands on when their role is deleted. Highest +-- position wins if a database somehow carries more than one default. +SELECT id, name, color, permissions, position, is_default +FROM roles WHERE is_default = 1 ORDER BY position DESC, id ASC LIMIT 1; + +-- name: CreateRole :one +INSERT INTO roles (name, color, permissions, position, is_default) +VALUES (?, ?, ?, ?, 0) +RETURNING id, name, color, permissions, position, is_default; + +-- name: UpdateRole :exec +UPDATE roles SET name = ?, color = ?, permissions = ?, position = ? WHERE id = ?; + +-- name: SetRolePosition :exec +UPDATE roles SET position = ? WHERE id = ?; + +-- name: DeleteRole :exec +DELETE FROM roles WHERE id = ?; + +-- name: CountRoleMembers :many +SELECT role_id, COUNT(*) AS member_count FROM users GROUP BY role_id; + +-- name: ListUserIDsByRole :many +SELECT id FROM users WHERE role_id = ?; + diff --git a/Server/db/queries/sqlite/users.sql b/Server/db/queries/sqlite/users.sql index 4dd800fe..147ff156 100644 --- a/Server/db/queries/sqlite/users.sql +++ b/Server/db/queries/sqlite/users.sql @@ -1,11 +1,13 @@ -- name: GetUserByUsername :one SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key, + display_name, about, custom_status FROM users WHERE username = ? COLLATE NOCASE; -- name: GetUserByID :one SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key + created_at, last_seen, banned, ban_reason, ban_expires, identity_public_key, + display_name, about, custom_status FROM users WHERE id = ?; -- name: CreateUser :execresult @@ -20,8 +22,22 @@ UPDATE users SET totp_secret = ? WHERE id = ?; -- name: UpdateUserIdentityKey :exec UPDATE users SET identity_public_key = ? WHERE id = ?; +-- name: MarkUserDisconnected :exec +-- Disconnect bookkeeping. It clears only 'online', which is the one status +-- that means "has a live session"; idle, dnd and invisible are choices the +-- user made and are what the next connect reads instead of stamping online +-- (db.ConnectStatus). A stale choice never renders as "present" because the +-- read path treats a member with no live connection as offline regardless. +UPDATE users +SET status = CASE WHEN status = 'online' THEN 'offline' ELSE status END, + last_seen = datetime('now') +WHERE id = ?; + -- name: ResetAllUserStatuses :exec -UPDATE users SET status = 'offline' WHERE status != 'offline'; +-- Startup reset: nothing is connected yet, so every 'online' is a leftover +-- from the previous process. Chosen statuses survive for the same reason they +-- survive a disconnect. +UPDATE users SET status = 'offline' WHERE status = 'online'; -- name: BanUser :exec UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?; @@ -30,7 +46,8 @@ UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?; UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?; -- name: ListMembers :many -SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name), u.identity_public_key +SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name), u.identity_public_key, + u.display_name, u.custom_status FROM users u JOIN roles r ON u.role_id = r.id WHERE u.banned = 0 diff --git a/Server/db/queries/sqlite/voice.sql b/Server/db/queries/sqlite/voice.sql index 60fdff85..06b8ec26 100644 --- a/Server/db/queries/sqlite/voice.sql +++ b/Server/db/queries/sqlite/voice.sql @@ -1,3 +1,8 @@ +-- server_muted / server_deafened are deliberately absent from both upserts' +-- reset lists: a moderator-imposed mute must survive a channel switch, which +-- reaches the ON CONFLICT branch. It is scoped to the voice session: +-- leaving voice deletes the row, so a rejoin starts clean. + -- name: JoinVoiceChannel :exec INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) VALUES (?, ?, 0, 0, 0, 0, 0, ?) @@ -32,7 +37,8 @@ DELETE FROM voice_states WHERE user_id = ? AND channel_id = ? AND joined_at = ?; -- name: GetUserVoiceState :one SELECT vs.user_id, vs.channel_id, u.username, vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at + vs.camera, vs.screenshare, + vs.server_muted, vs.server_deafened, vs.joined_at FROM voice_states vs JOIN users u ON u.id = vs.user_id WHERE vs.user_id = ?; @@ -40,7 +46,8 @@ WHERE vs.user_id = ?; -- name: GetChannelVoiceStates :many SELECT vs.user_id, vs.channel_id, u.username, vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at + vs.camera, vs.screenshare, + vs.server_muted, vs.server_deafened, vs.joined_at FROM voice_states vs JOIN users u ON u.id = vs.user_id WHERE vs.channel_id = ? @@ -49,7 +56,8 @@ ORDER BY vs.joined_at ASC; -- name: GetAllVoiceStates :many SELECT vs.user_id, vs.channel_id, u.username, vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at + vs.camera, vs.screenshare, + vs.server_muted, vs.server_deafened, vs.joined_at FROM voice_states vs JOIN users u ON u.id = vs.user_id ORDER BY vs.channel_id, vs.joined_at ASC; @@ -66,6 +74,18 @@ UPDATE voice_states SET camera = ? WHERE user_id = ?; -- name: UpdateVoiceScreenshare :exec UPDATE voice_states SET screenshare = ? WHERE user_id = ?; +-- name: ApplyVoiceServerMute :exec +UPDATE voice_states SET server_muted = 1, muted = 1 WHERE user_id = ?; + +-- name: ClearVoiceServerMute :exec +UPDATE voice_states SET server_muted = 0 WHERE user_id = ?; + +-- name: ApplyVoiceServerDeafen :exec +UPDATE voice_states SET server_deafened = 1, deafened = 1 WHERE user_id = ?; + +-- name: ClearVoiceServerDeafen :exec +UPDATE voice_states SET server_deafened = 0 WHERE user_id = ?; + -- name: EnableCameraIfUnderLimit :execresult UPDATE voice_states SET camera = 1 WHERE voice_states.user_id = ? AND voice_states.channel_id = ? diff --git a/Server/db/queries_ascii_test.go b/Server/db/queries_ascii_test.go new file mode 100644 index 00000000..0f7eb1c5 --- /dev/null +++ b/Server/db/queries_ascii_test.go @@ -0,0 +1,62 @@ +package db_test + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" +) + +// sqlc mixes byte and rune offsets when it strips the `-- name:` comments from +// a query file, so a single non-ASCII character in a comment silently +// truncates the generated SQL of that query and every query after it in the +// file by the byte/rune delta. The damage is invisible at review time (the +// .sql reads fine) and only shows up as a runtime "SQL logic error" from +// whichever query happened to lose its tail. +// +// An em-dash in a comment cost `ORDER BY ... id ASC` its `ASC` and left the +// next query as a spliced fragment of two others. Keeping these files ASCII is +// the cheapest way to make that class of corruption impossible. +func TestQueryFilesAreASCIIOnly(t *testing.T) { + root := "queries" + if _, err := os.Stat(root); err != nil { + t.Skipf("no %s directory: %v", root, err) + } + + var checked int + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".sql") { + return nil + } + checked++ + content, readErr := os.ReadFile(path) + if readErr != nil { + t.Errorf("read %s: %v", path, readErr) + return nil + } + for lineNo, line := range strings.Split(string(content), "\n") { + if utf8.RuneCountInString(line) == len(line) { + continue + } + for _, r := range line { + if r > 127 { + t.Errorf("%s:%d contains non-ASCII %q (U+%04X); sqlc truncates generated SQL on non-ASCII. Use ASCII (e.g. \"-\" for an em-dash).", + path, lineNo+1, r, r) + break + } + } + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + if checked == 0 { + t.Fatalf("no .sql files found under %s; the guard would pass vacuously", root) + } +} diff --git a/Server/db/role_crud_queries_test.go b/Server/db/role_crud_queries_test.go new file mode 100644 index 00000000..b3888d4d --- /dev/null +++ b/Server/db/role_crud_queries_test.go @@ -0,0 +1,264 @@ +package db_test + +import ( + "context" + "testing" +) + +// These run against the real embedded migration set (newMigratedTestDB) rather +// than the inline subset: migration 023 adds the case-insensitive name index +// and the delete path touches channel_overrides, neither of which the inline +// schema carries. The migrations seed Owner(1)/Admin(2)/Moderator(3)/Member(4, +// default). + +func TestGetRoleByName_IsCaseInsensitive(t *testing.T) { + database := newMigratedTestDB(t) + + for _, name := range []string{"Member", "member", "MEMBER"} { + role, err := database.GetRoleByName(context.Background(), name) + if err != nil { + t.Fatalf("GetRoleByName(%q): %v", name, err) + } + if role == nil || role.ID != 4 { + t.Errorf("GetRoleByName(%q) = %v, want the Member role", name, role) + } + } + role, err := database.GetRoleByName(context.Background(), "nobody") + if err != nil { + t.Fatalf("GetRoleByName(missing): %v", err) + } + if role != nil { + t.Errorf("GetRoleByName(missing) = %v, want nil", role) + } +} + +func TestRoleNameUniquenessIsEnforcedCaseInsensitively(t *testing.T) { + database := newMigratedTestDB(t) + + // Migration 023's index is what makes "moderator" and "Moderator" the same + // name — without it the two roles would coexist and the client's + // case-insensitive lookup would pick one arbitrarily. + _, err := database.ExecContext(context.Background(), + `INSERT INTO roles (name, permissions, position, is_default) VALUES ('moderator', 0, 5, 0)`) + if err == nil { + t.Fatal("inserting a case-colliding role name succeeded, want a UNIQUE violation") + } +} + +func TestGetDefaultRole(t *testing.T) { + database := newMigratedTestDB(t) + + role, err := database.GetDefaultRole(context.Background()) + if err != nil { + t.Fatalf("GetDefaultRole: %v", err) + } + if role == nil || role.ID != 4 || !role.IsDefault { + t.Fatalf("GetDefaultRole = %v, want the seeded Member role", role) + } +} + +func TestCreateAndUpdateRole(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + + color := "#ABCDEF" + created, err := database.CreateRole(ctx, "Helper", &color, 0x3, 55) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + if created.ID == 0 || created.Name != "Helper" || created.Position != 55 { + t.Fatalf("created = %+v", created) + } + if created.IsDefault { + t.Error("CreateRole must never produce a default role") + } + + if err := database.UpdateRole(ctx, created.ID, "Helpers", nil, 0x7, 56); err != nil { + t.Fatalf("UpdateRole: %v", err) + } + got, err := database.GetRoleByID(ctx, created.ID) + if err != nil || got == nil { + t.Fatalf("GetRoleByID: %v", err) + } + if got.Name != "Helpers" || got.Color != nil || got.Permissions != 0x7 || got.Position != 56 { + t.Errorf("updated role = %+v", got) + } +} + +func TestSetRolePositions(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + + if err := database.SetRolePositions(ctx, map[int64]int{2: 3, 3: 2, 4: 1}); err != nil { + t.Fatalf("SetRolePositions: %v", err) + } + for id, want := range map[int64]int{1: 100, 2: 3, 3: 2, 4: 1} { + role, err := database.GetRoleByID(ctx, id) + if err != nil || role == nil { + t.Fatalf("GetRoleByID(%d): %v", id, err) + } + if role.Position != want { + t.Errorf("role %d position = %d, want %d", id, role.Position, want) + } + } + // An empty map is a no-op rather than an empty transaction. + if err := database.SetRolePositions(ctx, nil); err != nil { + t.Errorf("SetRolePositions(nil): %v", err) + } +} + +func TestListRoles_OrdersByPositionThenIDDeterministically(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + + // Positions are only "unique enough": reorder normalizes them, but creating + // a role inserts just below the actor and may tie with an existing role. A + // tie must still order deterministically — the admin panel derives its + // reorder payload from this order, so an unstable one would make a single + // move-up silently shuffle the tied roles among themselves. + tied := []string{"Tie-A", "Tie-B", "Tie-C"} + created := make([]int64, 0, len(tied)) + for _, name := range tied { + role, err := database.CreateRole(ctx, name, nil, 0, 30) + if err != nil { + t.Fatalf("CreateRole %s: %v", name, err) + } + created = append(created, role.ID) + } + + var lastOrder []int64 + for range 5 { + roles, err := database.ListRoles(ctx) + if err != nil { + t.Fatalf("ListRoles: %v", err) + } + order := make([]int64, 0, len(roles)) + var prev = -1 + for _, r := range roles { + if prev >= 0 && r.Position > prev { + t.Fatalf("ListRoles is not position-descending: %d after %d", r.Position, prev) + } + prev = r.Position + if r.Position == 30 { + order = append(order, r.ID) + } + } + // Ties come back in ascending id order, every time. + if len(order) != len(created) { + t.Fatalf("found %d roles at position 30, want %d", len(order), len(created)) + } + for i, id := range created { + if order[i] != id { + t.Errorf("tied role %d = id %d, want %d (order %v)", i, order[i], id, order) + } + } + if lastOrder != nil { + for i := range order { + if order[i] != lastOrder[i] { + t.Fatalf("tied ordering is unstable across calls: %v then %v", lastOrder, order) + } + } + } + lastOrder = order + } +} + +func TestCountRoleMembersAndListUserIDsByRole(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + + a, err := database.CreateUser(ctx, "counta", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := database.CreateUser(ctx, "countb", "hash", 4); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := database.CreateUser(ctx, "countc", "hash", 3); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + counts, err := database.CountRoleMembers(ctx) + if err != nil { + t.Fatalf("CountRoleMembers: %v", err) + } + if counts[4] != 2 || counts[3] != 1 { + t.Errorf("counts = %v, want 2 members on role 4 and 1 on role 3", counts) + } + // A role nobody holds is absent rather than present with a zero. + if _, present := counts[1]; present { + t.Errorf("counts carries an entry for the memberless owner role: %v", counts) + } + + ids, err := database.ListUserIDsByRole(ctx, 4) + if err != nil { + t.Fatalf("ListUserIDsByRole: %v", err) + } + if len(ids) != 2 { + t.Fatalf("ListUserIDsByRole = %v, want 2 ids", ids) + } + found := false + for _, id := range ids { + if id == a { + found = true + } + } + if !found { + t.Errorf("ListUserIDsByRole = %v, missing user %d", ids, a) + } +} + +func TestDeleteRoleReassigning(t *testing.T) { + database := newMigratedTestDB(t) + ctx := context.Background() + + role, err := database.CreateRole(ctx, "Temp", nil, 0x3, 50) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + moved, err := database.CreateUser(ctx, "tempuser", "hash", int(role.ID)) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + stayer, err := database.CreateUser(ctx, "stayer", "hash", 3) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + chID, err := database.CreateChannel(ctx, "general", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.UpsertChannelOverride(ctx, chID, role.ID, 0, 0x2); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + ids, err := database.DeleteRoleReassigning(ctx, role.ID, 4) + if err != nil { + t.Fatalf("DeleteRoleReassigning: %v", err) + } + if len(ids) != 1 || ids[0] != moved { + t.Fatalf("reassigned ids = %v, want [%d]", ids, moved) + } + + user, err := database.GetUserByID(ctx, moved) + if err != nil || user == nil { + t.Fatalf("GetUserByID: %v", err) + } + if user.RoleID != 4 { + t.Errorf("moved user role = %d, want the fallback 4", user.RoleID) + } + other, _ := database.GetUserByID(ctx, stayer) + if other.RoleID != 3 { + t.Errorf("unrelated user role = %d, want 3 — the UPDATE must be scoped", other.RoleID) + } + if gone, err := database.GetRoleByID(ctx, role.ID); err != nil || gone != nil { + t.Errorf("role survived the delete: %v, %v", gone, err) + } + overrides, err := database.GetChannelOverrides(ctx, chID) + if err != nil { + t.Fatalf("GetChannelOverrides: %v", err) + } + if _, present := overrides[role.ID]; present { + t.Error("the deleted role's channel_overrides row survived") + } +} diff --git a/Server/db/role_queries.go b/Server/db/role_queries.go index 4d500903..be5a9dce 100644 --- a/Server/db/role_queries.go +++ b/Server/db/role_queries.go @@ -5,6 +5,8 @@ import ( "database/sql" "errors" "fmt" + "maps" + "slices" "github.com/owncord/server/db/dbgen" ) @@ -63,6 +65,164 @@ func (d *DB) GetRoleForUser(ctx context.Context, userID int64) (*Role, error) { return roleFromGen(r), nil } +// GetRoleByName returns the role whose name matches name case-insensitively, +// or nil if there is none. Case-insensitive because migration 023 enforces +// uniqueness under the same collation — the lookup and the constraint must +// agree, or "Moderator" and "moderator" become two roles the client (which +// matches names case-insensitively) cannot tell apart. +func (d *DB) GetRoleByName(ctx context.Context, name string) (*Role, error) { + r, err := d.q.GetRoleByName(ctx, name) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetRoleByName: %w", err) + } + return roleFromGen(r), nil +} + +// GetDefaultRole returns the role new members are created with and deleted +// roles' members fall back to. Returns (nil, nil) when no role is flagged +// default — callers must treat that as a configuration error, not a licence to +// leave members pointing at a deleted role. +func (d *DB) GetDefaultRole(ctx context.Context) (*Role, error) { + r, err := d.q.GetDefaultRole(ctx) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetDefaultRole: %w", err) + } + return roleFromGen(r), nil +} + +// CreateRole inserts a role and returns it. is_default is always 0: exactly one +// role is the default and it is seeded, never created through the API. +func (d *DB) CreateRole(ctx context.Context, name string, color *string, perms int64, position int) (*Role, error) { + r, err := d.q.CreateRole(ctx, dbgen.CreateRoleParams{ + Name: name, + Color: color, + Permissions: perms, + Position: int64(position), + }) + if err != nil { + return nil, fmt.Errorf("CreateRole: %w", err) + } + return roleFromGen(r), nil +} + +// UpdateRole overwrites a role's mutable columns. is_default is deliberately +// not writable: which role is the fallback is a schema decision. +func (d *DB) UpdateRole(ctx context.Context, id int64, name string, color *string, perms int64, position int) error { + if err := d.q.UpdateRole(ctx, dbgen.UpdateRoleParams{ + Name: name, + Color: color, + Permissions: perms, + Position: int64(position), + ID: id, + }); err != nil { + return fmt.Errorf("UpdateRole: %w", err) + } + return nil +} + +// ListUserIDsByRole returns the ids of every user currently holding roleID. +// Used to invalidate exactly the permission-cache entries a role change +// affects instead of dropping the whole cache. +func (d *DB) ListUserIDsByRole(ctx context.Context, roleID int64) ([]int64, error) { + ids, err := d.q.ListUserIDsByRole(ctx, roleID) + if err != nil { + return nil, fmt.Errorf("ListUserIDsByRole: %w", err) + } + return ids, nil +} + +// CountRoleMembers returns member counts keyed by role id. Roles with no +// members are absent from the map rather than present with a zero. +func (d *DB) CountRoleMembers(ctx context.Context) (map[int64]int, error) { + rows, err := d.q.CountRoleMembers(ctx) + if err != nil { + return nil, fmt.Errorf("CountRoleMembers: %w", err) + } + counts := make(map[int64]int, len(rows)) + for _, row := range rows { + counts[row.RoleID] = int(row.MemberCount) + } + return counts, nil +} + +// SetRolePositions writes new positions for several roles in one writer +// transaction, so a reader can never observe a half-applied reorder (which +// would briefly duplicate or invert the hierarchy the permission checks read). +func (d *DB) SetRolePositions(ctx context.Context, positions map[int64]int) error { + if len(positions) == 0 { + return nil + } + tx, err := d.writer.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("SetRolePositions begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + q := d.q.WithTx(tx) + // Sorted so concurrent reorders always take the rows in the same order. + for _, id := range slices.Sorted(maps.Keys(positions)) { + if err := q.SetRolePosition(ctx, dbgen.SetRolePositionParams{ + Position: int64(positions[id]), + ID: id, + }); err != nil { + return fmt.Errorf("SetRolePositions update %d: %w", id, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("SetRolePositions commit: %w", err) + } + return nil +} + +// DeleteRoleReassigning deletes roleID after moving every member onto +// fallbackRoleID and dropping the role's channel_overrides rows, all in one +// writer transaction: a member must never be observable pointing at a role row +// that no longer exists, and a stale override would silently apply to whichever +// role reused the id. +// +// Returns the ids of the reassigned members so the caller can invalidate their +// cached permissions and re-sync their clients. +func (d *DB) DeleteRoleReassigning(ctx context.Context, roleID, fallbackRoleID int64) ([]int64, error) { + tx, err := d.writer.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("DeleteRoleReassigning begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + q := d.q.WithTx(tx) + moved, err := q.ListUserIDsByRole(ctx, roleID) + if err != nil { + return nil, fmt.Errorf("DeleteRoleReassigning list members: %w", err) + } + // One UPDATE for every member, not one per member. + if _, err := tx.ExecContext(ctx, + `UPDATE users SET role_id = ? WHERE role_id = ?`, fallbackRoleID, roleID, + ); err != nil { + return nil, fmt.Errorf("DeleteRoleReassigning reassign: %w", err) + } + // channel_overrides cascades on delete in the production schema, but the + // delete is explicit so the behaviour does not depend on the foreign-key + // pragma being on for this connection. + if _, err := tx.ExecContext(ctx, + `DELETE FROM channel_overrides WHERE role_id = ?`, roleID, + ); err != nil { + return nil, fmt.Errorf("DeleteRoleReassigning drop overrides: %w", err) + } + if err := q.DeleteRole(ctx, roleID); err != nil { + return nil, fmt.Errorf("DeleteRoleReassigning delete: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("DeleteRoleReassigning commit: %w", err) + } + return moved, nil +} + // GetUserWithRole returns the user and their role in a single query. // Returns (nil, nil, nil) when the user is not found. func (d *DB) GetUserWithRole(ctx context.Context, userID int64) (*User, *Role, error) { diff --git a/Server/db/sanitize_fuzz_test.go b/Server/db/sanitize_fuzz_test.go new file mode 100644 index 00000000..55e2860c --- /dev/null +++ b/Server/db/sanitize_fuzz_test.go @@ -0,0 +1,101 @@ +package db + +import ( + "context" + "strings" + "testing" + "unicode" + "unicode/utf8" +) + +// fuzzTestHelper is the slice of *testing.T / *testing.F that +// fuzzOpenMigratedMemory needs; both embed testing.common and satisfy it. +type fuzzTestHelper interface { + Helper() + Fatalf(format string, args ...any) + Cleanup(func()) +} + +// fuzzOpenMigratedMemory opens an in-memory DB with the full schema (including +// messages_fts) applied, so the fuzz target can run sanitizeFTSQuery's output +// through a real FTS5 MATCH and catch anything that still makes SQLite choke +// -- not just anything that looks dangerous on paper. +func fuzzOpenMigratedMemory(t fuzzTestHelper) *DB { + t.Helper() + database, err := Open(":memory:") + if err != nil { + t.Fatalf("Open(':memory:'): %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := Migrate(database); err != nil { + t.Fatalf("Migrate: %v", err) + } + return database +} + +// FuzzSanitizeFTSQuery guards BUG-090 (byte-boundary truncation producing +// invalid UTF-8) against regressions and checks the broader contract: the +// output is always valid UTF-8, built only from the documented charset, at +// most 200 runes, and never makes messages_fts MATCH return a syntax error. +func FuzzSanitizeFTSQuery(f *testing.F) { + seeds := []string{ + "", + "hello world", + `hello "world" AND (test) NOT foo*`, + "foo NEAR bar", + "foo NEAR/2 bar", + "content:foo", + "*", + "**", + "((()))", + "-", + "- - -", + "AND", + "OR", + "NOT", + "NEAR", + "a OR b AND c", + strings.Repeat("漢", 199) + "x", // 200 runes, 3-byte each: right at the boundary + strings.Repeat("漢", 200), // exactly 200 CJK runes + strings.Repeat("漢", 210), // BUG-090: truncation used to split a rune + strings.Repeat("a", 199) + "漢", // ASCII run then one multi-byte rune at the cut + strings.Repeat("😀", 210), // 4-byte runes (surrogate-pair range) + strings.Repeat("a-b ", 100), + "col1:foo col2:bar", + "\"unterminated quote", + "a\x00b", + } + for _, s := range seeds { + f.Add(s) + } + + database := fuzzOpenMigratedMemory(f) + + f.Fuzz(func(t *testing.T, q string) { + got := sanitizeFTSQuery(q) + + if !utf8.ValidString(got) { + t.Fatalf("sanitizeFTSQuery(%q) produced invalid UTF-8: %q", q, got) + } + if n := utf8.RuneCountInString(got); n > 200 { + t.Fatalf("sanitizeFTSQuery(%q) returned %d runes, want <= 200", q, n) + } + for _, r := range got { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != ' ' && r != '-' { + t.Fatalf("sanitizeFTSQuery(%q) kept disallowed rune %q in %q", q, r, got) + } + } + // NOTE: the 200-rune truncation happens AFTER the TrimSpace call, so + // truncated output may legitimately end in whitespace (or, more + // interestingly, a lone trailing "-") -- that is not itself a + // contract violation as long as FTS5 still accepts it below. + + // The real contract: whatever comes out must not make FTS5 choke on + // the MATCH clause. A "no rows" result is fine; a query-syntax error + // is the bug (an FTS5 operator keyword or bare "-" slipping through + // unescaped). + if _, err := database.SearchMessages(context.Background(), got, nil, 10); err != nil { + t.Fatalf("SearchMessages with sanitized query %q (from %q) errored: %v", got, q, err) + } + }) +} diff --git a/Server/db/status.go b/Server/db/status.go new file mode 100644 index 00000000..2f8ed0e1 --- /dev/null +++ b/Server/db/status.go @@ -0,0 +1,79 @@ +package db + +// ─── Presence status vocabulary ───────────────────────────────────────────── +// +// `users.status` stores the status the user actually chose. "invisible" is one +// of those choices and is stored as itself — it is deliberately NOT collapsed +// to "offline" at the write, because the server has to be able to tell "chose +// to appear offline" from "is not connected" on the next connect (the first +// must survive a reconnect, the second must not). +// +// The collapse happens at every read that another user can see: +// BroadcastStatus maps invisible -> offline, and the owner's own payloads keep +// the true value. That split is the whole "real invisible" model — one place +// decides what others see, so a new payload cannot accidentally leak it. + +const ( + // StatusOnline is the default presence for a connected session. + StatusOnline = "online" + // StatusIdle is set manually or by the client's inactivity timer. + StatusIdle = "idle" + // StatusDND suppresses desktop notifications client-side. + StatusDND = "dnd" + // StatusInvisible means "connected, but shown to everyone else as offline". + StatusInvisible = "invisible" + // StatusOffline means "not connected". It is no longer a status a user can + // pick — StatusInvisible replaced that — but it is still what disconnect + // writes and what BroadcastStatus maps invisible to. + StatusOffline = "offline" +) + +// ValidStatuses is the set a client may set via presence_update. "offline" is +// still accepted so an older client that sends it (the pre-invisible spelling +// of "appear offline") is not answered with an error; it is treated as the +// plain offline it says. +var ValidStatuses = map[string]bool{ + StatusOnline: true, + StatusIdle: true, + StatusDND: true, + StatusInvisible: true, + StatusOffline: true, +} + +// BroadcastStatus maps a stored status to what OTHER users may see. Only +// invisible is rewritten; everything else is already public. +func BroadcastStatus(status string) string { + if status == StatusInvisible { + return StatusOffline + } + return status +} + +// StatusForViewer returns the status `subjectID` should appear as to +// `viewerID`. The owner of a status always sees its true value — a client that +// was told it was offline would render its own picker wrong and re-send +// "online" on the next reconnect, which is exactly the flash-online bug real +// invisible exists to kill. +func StatusForViewer(status string, subjectID, viewerID int64) string { + if subjectID == viewerID { + return status + } + return BroadcastStatus(status) +} + +// ConnectStatus returns the status a session should come online as, given the +// status saved from the user's last session. +// +// idle, dnd and invisible are deliberate choices and survive a reconnect; +// anything else (online, offline, or an unknown legacy value) becomes online. +// "offline" cannot be honoured here because it is also what a disconnect +// writes, so it carries no intent — that ambiguity is why invisible is its own +// value rather than a flag on offline. +func ConnectStatus(saved string) string { + switch saved { + case StatusIdle, StatusDND, StatusInvisible: + return saved + default: + return StatusOnline + } +} diff --git a/Server/db/status_test.go b/Server/db/status_test.go new file mode 100644 index 00000000..bc629464 --- /dev/null +++ b/Server/db/status_test.go @@ -0,0 +1,236 @@ +package db_test + +import ( + "context" + "testing" + + "github.com/owncord/server/db" +) + +// The invisible model lives in three tiny pure functions, and every payload +// builder on the server delegates to one of them. Locking their behaviour here +// is what makes "an invisible user never leaks" a property of the codebase +// rather than of each individual call site. + +func TestBroadcastStatus_MapsOnlyInvisible(t *testing.T) { + cases := map[string]string{ + db.StatusOnline: db.StatusOnline, + db.StatusIdle: db.StatusIdle, + db.StatusDND: db.StatusDND, + db.StatusOffline: db.StatusOffline, + db.StatusInvisible: db.StatusOffline, + } + for in, want := range cases { + if got := db.BroadcastStatus(in); got != want { + t.Errorf("BroadcastStatus(%q) = %q, want %q", in, got, want) + } + } +} + +func TestStatusForViewer_OwnerSeesTruthOthersSeeOffline(t *testing.T) { + const subject int64 = 7 + if got := db.StatusForViewer(db.StatusInvisible, subject, subject); got != db.StatusInvisible { + t.Errorf("owner view = %q, want invisible", got) + } + if got := db.StatusForViewer(db.StatusInvisible, subject, 8); got != db.StatusOffline { + t.Errorf("other view = %q, want offline", got) + } + // A non-invisible status is identical for both. + if got := db.StatusForViewer(db.StatusDND, subject, 8); got != db.StatusDND { + t.Errorf("other view of dnd = %q, want dnd", got) + } +} + +func TestConnectStatus_HonoursChoicesAndDefaultsOnline(t *testing.T) { + cases := map[string]string{ + db.StatusIdle: db.StatusIdle, + db.StatusDND: db.StatusDND, + db.StatusInvisible: db.StatusInvisible, + // offline carries no intent (it is also what a disconnect writes), so + // it cannot mean "appear offline" — that is what invisible is for. + db.StatusOffline: db.StatusOnline, + db.StatusOnline: db.StatusOnline, + "": db.StatusOnline, + "bogus": db.StatusOnline, + } + for in, want := range cases { + if got := db.ConnectStatus(in); got != want { + t.Errorf("ConnectStatus(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValidStatuses_AcceptsInvisible(t *testing.T) { + if !db.ValidStatuses[db.StatusInvisible] { + t.Error("invisible must be a settable status") + } + if db.ValidStatuses["afk"] { + t.Error("unknown statuses must not be settable") + } +} + +func TestMemberSummary_ForViewer(t *testing.T) { + m := db.MemberSummary{ID: 3, Username: "ghost", Status: db.StatusInvisible} + if got := m.ForViewer(3).Status; got != db.StatusInvisible { + t.Errorf("self view = %q, want invisible", got) + } + if got := m.ForViewer(4).Status; got != db.StatusOffline { + t.Errorf("other view = %q, want offline", got) + } + // ForViewer must not mutate the receiver — it is called in a loop over a + // shared slice. + if m.Status != db.StatusInvisible { + t.Errorf("receiver mutated to %q", m.Status) + } +} + +func TestMarkUserDisconnected_PreservesChosenStatus(t *testing.T) { + database := newTestDB(t) + ctx := context.Background() + + onlineID, err := database.CreateUser(ctx, "went_online", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + dndID, err := database.CreateUser(ctx, "went_dnd", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + invisID, err := database.CreateUser(ctx, "went_invisible", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + _ = database.UpdateUserStatus(ctx, onlineID, db.StatusOnline) + _ = database.UpdateUserStatus(ctx, dndID, db.StatusDND) + _ = database.UpdateUserStatus(ctx, invisID, db.StatusInvisible) + + for _, id := range []int64{onlineID, dndID, invisID} { + if err := database.MarkUserDisconnected(ctx, id); err != nil { + t.Fatalf("MarkUserDisconnected(%d): %v", id, err) + } + } + + got := func(id int64) string { + u, err := database.GetUserByID(ctx, id) + if err != nil || u == nil { + t.Fatalf("GetUserByID(%d): %v", id, err) + } + return u.Status + } + if s := got(onlineID); s != db.StatusOffline { + t.Errorf("online -> %q, want offline", s) + } + // The whole point: the next connect reads this column, so a chosen status + // has to still be there. + if s := got(dndID); s != db.StatusDND { + t.Errorf("dnd -> %q, want dnd preserved", s) + } + if s := got(invisID); s != db.StatusInvisible { + t.Errorf("invisible -> %q, want invisible preserved", s) + } +} + +func TestUpdateUserCustomStatus_RoundTripAndClear(t *testing.T) { + database := newTestDB(t) + ctx := context.Background() + id, err := database.CreateUser(ctx, "statusy", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + text := "shipping phase 6" + if err := database.UpdateUserCustomStatus(ctx, id, &text); err != nil { + t.Fatalf("UpdateUserCustomStatus: %v", err) + } + u, _ := database.GetUserByID(ctx, id) + if u.CustomStatus == nil || *u.CustomStatus != text { + t.Fatalf("custom status = %v, want %q", u.CustomStatus, text) + } + + if err := database.UpdateUserCustomStatus(ctx, id, nil); err != nil { + t.Fatalf("clear: %v", err) + } + u, _ = database.GetUserByID(ctx, id) + if u.CustomStatus != nil { + t.Fatalf("custom status = %v, want nil after clear", *u.CustomStatus) + } +} + +func TestIsAvatarFileURL(t *testing.T) { + database := newTestDB(t) + ctx := context.Background() + id, err := database.CreateUser(ctx, "pfp", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + const url = "/api/v1/files/abc-123" + if inUse, err := database.IsAvatarFileURL(ctx, url); err != nil || inUse { + t.Fatalf("IsAvatarFileURL before = %v, %v; want false, nil", inUse, err) + } + + avatar := url + if err := database.UpdateUserProfile(ctx, id, "pfp", &avatar, nil, nil); err != nil { + t.Fatalf("UpdateUserProfile: %v", err) + } + if inUse, err := database.IsAvatarFileURL(ctx, url); err != nil || !inUse { + t.Fatalf("IsAvatarFileURL in use = %v, %v; want true, nil", inUse, err) + } + + // Replacing the avatar revokes the old file's public readability. + other := "/api/v1/files/def-456" + if err := database.UpdateUserProfile(ctx, id, "pfp", &other, nil, nil); err != nil { + t.Fatalf("UpdateUserProfile: %v", err) + } + if inUse, _ := database.IsAvatarFileURL(ctx, url); inUse { + t.Error("replaced avatar must stop being publicly readable") + } +} + +func TestEffectiveDisplayName(t *testing.T) { + name := "Ada L." + empty := "" + cases := []struct { + user *db.User + want string + }{ + {&db.User{Username: "ada", DisplayName: &name}, "Ada L."}, + {&db.User{Username: "ada"}, "ada"}, + {&db.User{Username: "ada", DisplayName: &empty}, "ada"}, + {nil, ""}, + } + for _, c := range cases { + if got := c.user.EffectiveDisplayName(); got != c.want { + t.Errorf("EffectiveDisplayName() = %q, want %q", got, c.want) + } + } +} + +func TestUpdateUserProfile_WritesDisplayNameAndAbout(t *testing.T) { + database := newTestDB(t) + ctx := context.Background() + id, err := database.CreateUser(ctx, "bio", "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + name, about := "Bio Person", "likes long walks" + if err := database.UpdateUserProfile(ctx, id, "bio", nil, &name, &about); err != nil { + t.Fatalf("UpdateUserProfile: %v", err) + } + u, _ := database.GetUserByID(ctx, id) + if u.DisplayName == nil || *u.DisplayName != name { + t.Errorf("display_name = %v, want %q", u.DisplayName, name) + } + if u.About == nil || *u.About != about { + t.Errorf("about = %v, want %q", u.About, about) + } + + if err := database.UpdateUserProfile(ctx, id, "bio", nil, nil, nil); err != nil { + t.Fatalf("clear: %v", err) + } + u, _ = database.GetUserByID(ctx, id) + if u.DisplayName != nil || u.About != nil { + t.Errorf("expected both cleared, got %v / %v", u.DisplayName, u.About) + } +} diff --git a/Server/db/voice_queries.go b/Server/db/voice_queries.go index 2d5aa46f..f695b5fa 100644 --- a/Server/db/voice_queries.go +++ b/Server/db/voice_queries.go @@ -97,15 +97,17 @@ func (d *DB) GetVoiceState(ctx context.Context, userID int64) (*VoiceState, erro return nil, fmt.Errorf("GetVoiceState: %w", err) } vs := VoiceState{ - UserID: r.UserID, - ChannelID: r.ChannelID, - Username: r.Username, - Muted: r.Muted != 0, - Deafened: r.Deafened != 0, - Speaking: r.Speaking != 0, - Camera: r.Camera != 0, - Screenshare: r.Screenshare != 0, - JoinedAt: r.JoinedAt, + UserID: r.UserID, + ChannelID: r.ChannelID, + Username: r.Username, + Muted: r.Muted != 0, + Deafened: r.Deafened != 0, + Speaking: r.Speaking != 0, + Camera: r.Camera != 0, + Screenshare: r.Screenshare != 0, + ServerMuted: r.ServerMuted != 0, + ServerDeafened: r.ServerDeafened != 0, + JoinedAt: r.JoinedAt, } return &vs, nil } @@ -120,15 +122,17 @@ func (d *DB) GetChannelVoiceStates(ctx context.Context, channelID int64) ([]Voic states := make([]VoiceState, 0, len(rows)) for _, r := range rows { states = append(states, VoiceState{ - UserID: r.UserID, - ChannelID: r.ChannelID, - Username: r.Username, - Muted: r.Muted != 0, - Deafened: r.Deafened != 0, - Speaking: r.Speaking != 0, - Camera: r.Camera != 0, - Screenshare: r.Screenshare != 0, - JoinedAt: r.JoinedAt, + UserID: r.UserID, + ChannelID: r.ChannelID, + Username: r.Username, + Muted: r.Muted != 0, + Deafened: r.Deafened != 0, + Speaking: r.Speaking != 0, + Camera: r.Camera != 0, + Screenshare: r.Screenshare != 0, + ServerMuted: r.ServerMuted != 0, + ServerDeafened: r.ServerDeafened != 0, + JoinedAt: r.JoinedAt, }) } return states, nil @@ -144,15 +148,17 @@ func (d *DB) GetAllVoiceStates(ctx context.Context) ([]VoiceState, error) { states := make([]VoiceState, 0, len(rows)) for _, r := range rows { states = append(states, VoiceState{ - UserID: r.UserID, - ChannelID: r.ChannelID, - Username: r.Username, - Muted: r.Muted != 0, - Deafened: r.Deafened != 0, - Speaking: r.Speaking != 0, - Camera: r.Camera != 0, - Screenshare: r.Screenshare != 0, - JoinedAt: r.JoinedAt, + UserID: r.UserID, + ChannelID: r.ChannelID, + Username: r.Username, + Muted: r.Muted != 0, + Deafened: r.Deafened != 0, + Speaking: r.Speaking != 0, + Camera: r.Camera != 0, + Screenshare: r.Screenshare != 0, + ServerMuted: r.ServerMuted != 0, + ServerDeafened: r.ServerDeafened != 0, + JoinedAt: r.JoinedAt, }) } return states, nil @@ -182,6 +188,38 @@ func (d *DB) UpdateVoiceDeafen(ctx context.Context, userID int64, deafened bool) return nil } +// SetVoiceServerMute applies or clears the moderator-imposed mute. Applying it +// also sets muted so the client state matches immediately; clearing it leaves +// muted alone, so a user who was muted before the moderator acted stays muted +// until they unmute themselves. +func (d *DB) SetVoiceServerMute(ctx context.Context, userID int64, serverMuted bool) error { + var err error + if serverMuted { + err = d.q.ApplyVoiceServerMute(ctx, userID) + } else { + err = d.q.ClearVoiceServerMute(ctx, userID) + } + if err != nil { + return fmt.Errorf("SetVoiceServerMute: %w", err) + } + return nil +} + +// SetVoiceServerDeafen applies or clears the moderator-imposed deafen. +// Mirrors SetVoiceServerMute, including the asymmetric handling of deafened. +func (d *DB) SetVoiceServerDeafen(ctx context.Context, userID int64, serverDeafened bool) error { + var err error + if serverDeafened { + err = d.q.ApplyVoiceServerDeafen(ctx, userID) + } else { + err = d.q.ClearVoiceServerDeafen(ctx, userID) + } + if err != nil { + return fmt.Errorf("SetVoiceServerDeafen: %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(ctx context.Context, userID int64) error { diff --git a/Server/db/voice_queries_test.go b/Server/db/voice_queries_test.go index 7fc94d3b..7cfa0963 100644 --- a/Server/db/voice_queries_test.go +++ b/Server/db/voice_queries_test.go @@ -22,7 +22,9 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, - voice_max_video INTEGER NOT NULL DEFAULT 10 + voice_max_video INTEGER NOT NULL DEFAULT 10, + nsfw INTEGER NOT NULL DEFAULT 0, + is_group INTEGER NOT NULL DEFAULT 0 ); `) @@ -47,6 +49,8 @@ CREATE TABLE IF NOT EXISTS voice_states ( speaking INTEGER NOT NULL DEFAULT 0, camera INTEGER NOT NULL DEFAULT 0, screenshare INTEGER NOT NULL DEFAULT 0, + server_muted INTEGER NOT NULL DEFAULT 0, + server_deafened 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/021_voice_server_moderation.sql b/Server/migrations/021_voice_server_moderation.sql new file mode 100644 index 00000000..0706a7b9 --- /dev/null +++ b/Server/migrations/021_voice_server_moderation.sql @@ -0,0 +1,6 @@ +-- Phase 2 (moderation depth): moderator-imposed voice state. +-- server_muted / server_deafened are distinct from the self-service muted / +-- deafened columns: only a MUTE_MEMBERS holder may clear them, and while set +-- the user's own voice_mute / voice_deafen unmute attempts are refused. +ALTER TABLE voice_states ADD COLUMN server_muted INTEGER NOT NULL DEFAULT 0; +ALTER TABLE voice_states ADD COLUMN server_deafened INTEGER NOT NULL DEFAULT 0; diff --git a/Server/migrations/022_message_mentions.sql b/Server/migrations/022_message_mentions.sql new file mode 100644 index 00000000..932f4711 --- /dev/null +++ b/Server/migrations/022_message_mentions.sql @@ -0,0 +1,21 @@ +-- Phase 3 (mentions): resolved mention storage and the MENTION_EVERYONE bit. +-- +-- message_mentions holds user-id mentions only. @everyone/@here is a per-message +-- boolean rather than a sentinel row, so the mention list never carries an id +-- that is not a real user. Both are rewritten wholesale when a message is edited. +CREATE TABLE IF NOT EXISTS message_mentions ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, mentioned_user_id) +); + +-- The primary key already covers the per-message direction. This index backs +-- the per-user "which messages mention me" direction. +CREATE INDEX IF NOT EXISTS idx_message_mentions_user ON message_mentions(mentioned_user_id); + +ALTER TABLE messages ADD COLUMN mentions_everyone INTEGER NOT NULL DEFAULT 0; + +-- MENTION_EVERYONE (bit 21, 0x200000) for the seeded privileged roles. Owner +-- (0x7FFFFFFF) and Admin (0x3FFFFFFF) already hold every bit below 30. The +-- Moderator mask (0x000FFFFF) stops at bit 19, so this is the bit it gains. +UPDATE roles SET permissions = permissions | 0x200000 WHERE id IN (1, 2, 3); diff --git a/Server/migrations/023_role_management.sql b/Server/migrations/023_role_management.sql new file mode 100644 index 00000000..cb2a4ad8 --- /dev/null +++ b/Server/migrations/023_role_management.sql @@ -0,0 +1,14 @@ +-- Phase 5 (role management): make role names case-insensitively unique. +-- +-- roles.name already carries a UNIQUE constraint, but with SQLite's default +-- BINARY collation — so "Moderator" and "moderator" were two distinct roles. +-- Role CRUD resolves names case-insensitively (the client matches role names +-- case-insensitively too, see getRoleIdByName), so the uniqueness rule has to +-- agree with the lookup rule or two roles could shadow each other. +-- +-- A partial rebuild of the table to change the column collation would rewrite +-- every FK-referencing row, while a second unique index costs one B-tree and is +-- exactly as strong. Creating it fails loudly if an existing database already +-- holds a case-colliding pair, which is the correct outcome — the operator has +-- to rename one before role management can be trusted. +CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name_nocase ON roles(name COLLATE NOCASE); diff --git a/Server/migrations/024_channel_user_overrides.sql b/Server/migrations/024_channel_user_overrides.sql new file mode 100644 index 00000000..cd81cfb3 --- /dev/null +++ b/Server/migrations/024_channel_user_overrides.sql @@ -0,0 +1,28 @@ +-- Phase 5 (channel management): per-user channel permission overrides. +-- +-- channel_overrides answers "what may this ROLE do here". Discord's resolution +-- order has a second, narrower layer on top of it — a single member can be +-- granted or refused a bit in one channel without minting a role for them: +-- +-- base role permissions -> role override -> user override +-- +-- with the user layer applied last, so a user deny beats a role allow and a +-- user allow beats a user deny (ADMINISTRATOR still bypasses everything). +-- +-- The shape mirrors channel_overrides exactly (allow/deny masks, cascade on +-- both parents) so the two layers can be fetched and merged by the same code +-- paths. The PRIMARY KEY replaces channel_overrides' surrogate id plus UNIQUE +-- pair because nothing references an override row by id. +CREATE TABLE IF NOT EXISTS channel_user_overrides ( + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (channel_id, user_id) +); + +-- The per-user direction ("every override this user carries") is the one the +-- permission cache populates from on every connect. The PK only covers the +-- per-channel direction. +CREATE INDEX IF NOT EXISTS idx_channel_user_overrides_user + ON channel_user_overrides(user_id); diff --git a/Server/migrations/025_channel_nsfw.sql b/Server/migrations/025_channel_nsfw.sql new file mode 100644 index 00000000..1ae08510 --- /dev/null +++ b/Server/migrations/025_channel_nsfw.sql @@ -0,0 +1,13 @@ +-- Phase 5 (channel management): the per-channel NSFW / age-gate flag. +-- +-- The flag is metadata and nothing else. The server stores it, ships it in +-- `ready` and in the channel_create/channel_update broadcasts, and audits the +-- edit — it deliberately imposes NO content behaviour of its own: no filtering, +-- no age check, no restriction on who may read or post. Every client is free +-- to decide what to do with it (the desktop client shows a one-time-per-session +-- "may contain sensitive content" gate and marks the sidebar row), which is the +-- only honest contract on a self-hosted server that knows nobody's age. +-- +-- Stored as INTEGER 0/1 to match `archived` — SQLite has no boolean type, and +-- every other flag column on this table already uses that shape. +ALTER TABLE channels ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0; diff --git a/Server/migrations/026_emoji_mime.sql b/Server/migrations/026_emoji_mime.sql new file mode 100644 index 00000000..a8772db0 --- /dev/null +++ b/Server/migrations/026_emoji_mime.sql @@ -0,0 +1,15 @@ +-- Phase 6 (custom emoji): give the long-dormant `emoji` table the one column it +-- was missing to be servable. +-- +-- The table has existed since 001 with (shortcode, filename, uploaded_by) and +-- no server code at all. `filename` now holds the storage UUID the file was +-- written under (same convention as attachments.stored_as), which is enough to +-- find the bytes but not enough to serve them: the emoji image route has to +-- send a Content-Type, and re-sniffing the file on every GET would mean opening +-- and reading it before ServeContent does the same again. +-- +-- The sniffed type is decided once at upload (png/jpeg/gif/webp only, from the +-- magic bytes, never from the client's header) and recorded here. The DEFAULT +-- exists only so the ALTER is legal on a table that in practice has no rows -- +-- nothing has ever written to it. +ALTER TABLE emoji ADD COLUMN mime_type TEXT NOT NULL DEFAULT 'image/png'; diff --git a/Server/migrations/027_user_profile_fields.sql b/Server/migrations/027_user_profile_fields.sql new file mode 100644 index 00000000..808966e0 --- /dev/null +++ b/Server/migrations/027_user_profile_fields.sql @@ -0,0 +1,28 @@ +-- Phase 6 (profiles & presence depth): the three profile columns the users +-- table never had, plus the index that makes avatar-file authorization cheap. +-- +-- display_name is a *display* handle only. It is nullable and falls back to +-- username everywhere, and mentions keep resolving against username alone -- +-- username is the unique, case-insensitive key, so making @mentions look at a +-- non-unique nickname would make "@alice" ambiguous the moment two people pick +-- the same one. +-- +-- Bounds (1-32 for display_name, 300 for about, 128 for custom_status) are +-- enforced in the service layer, which is where the sanitizer runs and where a +-- violation can answer 400 instead of a constraint error. The columns are +-- plain TEXT so a future bound change is not a table rebuild. +ALTER TABLE users ADD COLUMN display_name TEXT; +ALTER TABLE users ADD COLUMN about TEXT; +ALTER TABLE users ADD COLUMN custom_status TEXT; + +-- Avatars uploaded through POST /api/v1/users/me/avatar land in the attachments +-- table with no channel, and an unlinked attachment is readable only by its +-- uploader -- which would make every avatar invisible to everyone but its +-- owner. The file route therefore also admits an unlinked attachment that some +-- user's avatar column currently points at: an avatar is readable exactly while +-- it is somebody's avatar, and stops being readable the moment it is replaced. +-- +-- That check is an equality lookup on users.avatar on every avatar fetch, so it +-- gets an index. Partial (avatar IS NOT NULL) because the rows that matter are +-- the ones with a value and the default is NULL. +CREATE INDEX IF NOT EXISTS idx_users_avatar ON users(avatar) WHERE avatar IS NOT NULL; diff --git a/Server/migrations/028_group_dms.sql b/Server/migrations/028_group_dms.sql new file mode 100644 index 00000000..18499c4e --- /dev/null +++ b/Server/migrations/028_group_dms.sql @@ -0,0 +1,29 @@ +-- Migration 028 (phase 6, group DMs): mark which DM channels are groups. +-- +-- dm_participants has always held N rows per channel, so a group DM needed no +-- new table. What it did need is a way to tell a group from a two-person DM +-- that does NOT count participants, because the count changes underneath you: +-- +-- * A group of three that two people leave has two participants, and the 1:1 +-- lookup in GetOrCreateDMChannel -- "the dm channel both of these users are +-- in" -- would then match it. "Message Bob" would silently deliver into the +-- remnants of a group, in front of whoever else is still there. +-- * Leaving is destructive for a group (you come out of dm_participants) and +-- non-destructive for a 1:1 (you only hide it). Deriving which one to run +-- from the live count means the third-from-last leaver runs a different +-- operation than the second-from-last, for no reason the user can see. +-- +-- So group-ness is a property of the channel, decided once at creation and +-- never recomputed. is_group = 0 for every pre-existing row, which is correct: +-- every DM that existed before this migration was created by the 1:1 path. +-- +-- The column lives on channels rather than a dm_groups side table because it +-- is one bit about a channel, and every read that needs it is already loading +-- the channel row. +ALTER TABLE channels ADD COLUMN is_group INTEGER NOT NULL DEFAULT 0; + +-- The 1:1 DM lookup filters on it (c.type = 'dm' AND c.is_group = 0) on every +-- "open a DM with this person", which is a hot path on the DM sidebar. Partial +-- on type so the index covers only the DM rows -- guild channels are never +-- groups and never looked up this way. +CREATE INDEX IF NOT EXISTS idx_channels_dm_group ON channels(is_group) WHERE type = 'dm'; diff --git a/Server/permissions/checker.go b/Server/permissions/checker.go index 7c174781..75195bbe 100644 --- a/Server/permissions/checker.go +++ b/Server/permissions/checker.go @@ -16,24 +16,32 @@ var ErrPermissionDenied = errors.New("permission denied") // ─── DB interface ─────────────────────────────────────────────────────────── -// ChannelOverride holds the allow/deny permission bits for a single channel. +// ChannelOverride holds both override layers for a single channel: Allow/Deny +// are the ROLE layer (channel_overrides), UserAllow/UserDeny the per-member +// layer (channel_user_overrides) that is applied on top of it. The zero value +// means "no override at either layer", which is why a missing map entry is +// always the correct answer. type ChannelOverride struct { - Allow int64 - Deny int64 + Allow int64 + Deny int64 + UserAllow int64 + UserDeny int64 } // ChannelRef is the minimal channel description VisibleChannelIDs needs. // Declared here (not imported from db) so the permissions package stays free // of a db dependency; callers map their []db.Channel down to []ChannelRef. type ChannelRef struct { - ID int64 - Type string + ID int64 + Type string + Archived bool } // DB is the minimal database interface the Checker needs. // Defined at the consumer (per Go convention: accept interfaces, return structs). type DB interface { GetChannelPermissions(ctx context.Context, channelID, roleID int64) (allow, deny int64, err error) + GetUserChannelPermissions(ctx context.Context, channelID, userID int64) (allow, deny int64, err error) IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) } @@ -50,11 +58,15 @@ func NewChecker(db DB) *Checker { return &Checker{db: db} } -// HasChannelPerm reports whether the role (identified by rolePerms and roleID) -// has all the given permission bits on the specified channel. Administrator -// roles bypass all checks. Channel overrides (allow/deny) are fetched from the -// database per call. -func (ck *Checker) HasChannelPerm(ctx context.Context, rolePerms int64, roleID, channelID, perm int64) bool { +// HasChannelPerm reports whether the member (identified by rolePerms, roleID +// and userID) has all the given permission bits on the specified channel. +// Administrator roles bypass all checks. Both override layers — the role's and +// the user's — are fetched from the database per call and resolved by +// EffectiveChannelPerms. +// +// userID may be 0 for a check that is genuinely role-only (no member in hand); +// the per-user layer is then skipped rather than queried for a nonexistent id. +func (ck *Checker) HasChannelPerm(ctx context.Context, rolePerms int64, roleID, userID, channelID, perm int64) bool { if HasAdmin(rolePerms) { return true } @@ -62,25 +74,33 @@ func (ck *Checker) HasChannelPerm(ctx context.Context, rolePerms int64, roleID, if err != nil { return false } - effective := EffectivePerms(rolePerms, allow, deny) - return effective&perm == perm + o := ChannelOverride{Allow: allow, Deny: deny} + if userID != 0 { + uAllow, uDeny, uErr := ck.db.GetUserChannelPermissions(ctx, channelID, userID) + if uErr != nil { + return false + } + o.UserAllow, o.UserDeny = uAllow, uDeny + } + return EffectiveChannelPerms(rolePerms, o)&perm == perm } -// HasChannelPermBatch reports whether the role has the given permission on the -// channel using a pre-fetched overrides map. This avoids N+1 queries when -// filtering many channels in bulk. The zero-value ChannelOverride (no entry in -// map) is correct -- it means no override exists. +// HasChannelPermBatch reports whether the member has the given permission on +// the channel using a pre-fetched overrides map carrying BOTH layers (build it +// with db.GetChannelOverridesFor). This avoids N+1 queries when filtering many +// channels in bulk. The zero-value ChannelOverride (no entry in map) is correct +// -- it means no override exists at either layer. func (ck *Checker) HasChannelPermBatch(rolePerms int64, overrides map[int64]ChannelOverride, channelID, perm int64) bool { if HasAdmin(rolePerms) { return true } - o := overrides[channelID] // zero-value (0, 0) when no override exists - effective := EffectivePerms(rolePerms, o.Allow, o.Deny) - return effective&perm == perm + o := overrides[channelID] // zero value when no override exists + return EffectiveChannelPerms(rolePerms, o)&perm == perm } -// VisibleChannelIDs returns the set of non-DM channel IDs the role (identified -// by rolePerms) may READ, using a pre-fetched overrides map. It is the single +// VisibleChannelIDs returns the set of non-DM channel IDs the member +// (identified by rolePerms plus the two-layer overrides map) may READ, using a +// pre-fetched overrides map. It is the single // predicate behind every "which channels does this role see" site (REST // ListVisibleChannels, the ws ready payload, and reconnect replay filtering) so // they can never drift apart. DM channels are skipped — their visibility is @@ -93,6 +113,12 @@ func (ck *Checker) VisibleChannelIDs(rolePerms int64, channels []ChannelRef, ove if ch.Type == "dm" { continue } + // Archived channels are hidden from every client surface (admins + // included) — they stay manageable from the admin panel, which lists + // channels without this predicate. + if ch.Archived { + continue + } if ck.HasChannelPermBatch(rolePerms, overrides, ch.ID, ReadMessages) { visible[ch.ID] = true } @@ -118,7 +144,7 @@ func (ck *Checker) RequireChannelAccess(ctx context.Context, userID, rolePerms, return nil } - if !ck.HasChannelPerm(ctx, rolePerms, roleID, channelID, perm) { + if !ck.HasChannelPerm(ctx, rolePerms, roleID, userID, channelID, perm) { return ErrPermissionDenied } return nil diff --git a/Server/permissions/checker_test.go b/Server/permissions/checker_test.go index e4563f7d..ce678d33 100644 --- a/Server/permissions/checker_test.go +++ b/Server/permissions/checker_test.go @@ -11,13 +11,16 @@ import ( type mockDB struct { channelPerms map[chanRoleKey]chanPerm + userPerms map[chanUserKey]chanPerm dmParticipants map[dmKey]bool chanErr error + userErr error dmErr error } type ( chanRoleKey struct{ channelID, roleID int64 } + chanUserKey struct{ channelID, userID int64 } chanPerm struct{ allow, deny int64 } dmKey struct{ userID, channelID int64 } ) @@ -25,6 +28,7 @@ type ( func newMockDB() *mockDB { return &mockDB{ channelPerms: make(map[chanRoleKey]chanPerm), + userPerms: make(map[chanUserKey]chanPerm), dmParticipants: make(map[dmKey]bool), } } @@ -41,6 +45,17 @@ func (m *mockDB) GetChannelPermissions(_ context.Context, channelID, roleID int6 return p.allow, p.deny, nil } +func (m *mockDB) GetUserChannelPermissions(_ context.Context, channelID, userID int64) (int64, int64, error) { + if m.userErr != nil { + return 0, 0, m.userErr + } + p, ok := m.userPerms[chanUserKey{channelID, userID}] + if !ok { + return 0, 0, nil + } + return p.allow, p.deny, nil +} + func (m *mockDB) IsDMParticipant(_ context.Context, userID, channelID int64) (bool, error) { if m.dmErr != nil { return false, m.dmErr @@ -125,7 +140,7 @@ func TestHasChannelPerm(t *testing.T) { maps.Copy(db.channelPerms, tt.overrides) ck := NewChecker(db) - got := ck.HasChannelPerm(context.Background(), tt.rolePerms, tt.roleID, tt.channelID, tt.perm) + got := ck.HasChannelPerm(context.Background(), tt.rolePerms, tt.roleID, 0, tt.channelID, tt.perm) if got != tt.want { t.Errorf("HasChannelPerm() = %v, want %v", got, tt.want) } @@ -200,7 +215,8 @@ func TestVisibleChannelIDs(t *testing.T) { {ID: 1, Type: "text"}, {ID: 2, Type: "announcement"}, {ID: 3, Type: "voice"}, - {ID: 4, Type: "dm"}, // always skipped + {ID: 4, Type: "dm"}, // always skipped + {ID: 5, Type: "text", Archived: true}, // always skipped, even for admins } tests := []struct { @@ -248,6 +264,9 @@ func TestVisibleChannelIDs(t *testing.T) { if got[4] { t.Errorf("dm channel 4 must never be visible, got %v", got) } + if got[5] { + t.Errorf("archived channel 5 must never be visible, got %v", got) + } if len(got) != len(tt.want) { t.Fatalf("VisibleChannelIDs() = %v, want %v", got, tt.want) } diff --git a/Server/permissions/permissions.go b/Server/permissions/permissions.go index 5d75d575..24a4abd7 100644 --- a/Server/permissions/permissions.go +++ b/Server/permissions/permissions.go @@ -6,24 +6,25 @@ 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 - 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 + SendMessages = int64(0x0001) // bit 0 + ReadMessages = int64(0x0002) // bit 1 + AttachFiles = int64(0x0020) // bit 5 + AddReactions = int64(0x0040) // bit 6 + 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 + MentionEveryone = int64(0x200000) // bit 21 + 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 ) // AllPerms is the union of every defined permission bit. Use it to mask @@ -31,7 +32,48 @@ const ( const AllPerms = SendMessages | ReadMessages | AttachFiles | AddReactions | ConnectVoice | SpeakVoice | UseVideo | ShareScreen | ManageMessages | ManageChannels | KickMembers | BanMembers | MuteMembers | - ManageRoles | ManageServer | ManageInvites | ViewAuditLog | Administrator + MentionEveryone | ManageRoles | ManageServer | ManageInvites | ViewAuditLog | + Administrator + +// AdminPerimeter is the set of bits that admits a principal to the /admin/api +// surface. Holding ANY one of them is enough to pass the perimeter; each route +// group then re-checks the specific bit it needs. ManageMessages and +// ManageInvites are excluded: neither has an admin-panel route. +const AdminPerimeter = Administrator | ManageChannels | ManageRoles | + ManageServer | ViewAuditLog | KickMembers | BanMembers | MuteMembers + +// bitNames maps each single permission bit to its SCHEMA.md name. Used for +// authorization error messages so the wording lives in one place. +var bitNames = map[int64]string{ + SendMessages: "SEND_MESSAGES", + ReadMessages: "READ_MESSAGES", + AttachFiles: "ATTACH_FILES", + AddReactions: "ADD_REACTIONS", + ConnectVoice: "CONNECT_VOICE", + SpeakVoice: "SPEAK_VOICE", + UseVideo: "USE_VIDEO", + ShareScreen: "SHARE_SCREEN", + ManageMessages: "MANAGE_MESSAGES", + ManageChannels: "MANAGE_CHANNELS", + KickMembers: "KICK_MEMBERS", + BanMembers: "BAN_MEMBERS", + MuteMembers: "MUTE_MEMBERS", + MentionEveryone: "MENTION_EVERYONE", + ManageRoles: "MANAGE_ROLES", + ManageServer: "MANAGE_SERVER", + ManageInvites: "MANAGE_INVITES", + ViewAuditLog: "VIEW_AUDIT_LOG", + Administrator: "ADMINISTRATOR", +} + +// Name returns the SCHEMA.md name of a single permission bit, or "UNKNOWN" for +// a zero, multi-bit, or undefined value. +func Name(bit int64) string { + if name, ok := bitNames[bit]; ok { + return name + } + return "UNKNOWN" +} // ─── Role ID constants (default roles inserted on first run) ───────────────── @@ -58,6 +100,17 @@ func HasPerm(rolePerms, requiredPerm int64) bool { return rolePerms&requiredPerm == requiredPerm } +// HasAnyPerm reports whether rolePerms contains at least one bit of mask. +// Unlike HasPerm (ALL-of) this is ANY-of; a zero mask is never satisfied. +// Administrator is NOT implied — callers that want the bypass pass a mask +// that already includes it (e.g. AdminPerimeter). +func HasAnyPerm(rolePerms, mask int64) bool { + if mask == 0 { + return false + } + return rolePerms&mask != 0 +} + // HasAdmin reports whether rolePerms includes the Administrator bit, which // grants unconditional access to all operations. func HasAdmin(rolePerms int64) bool { @@ -72,13 +125,40 @@ func HasServerPerm(rolePerms, perm int64) bool { return HasAdmin(rolePerms) || HasPerm(rolePerms, perm) } -// EffectivePerms computes the resolved permission set for a channel override. +// EffectivePerms computes the resolved permission set for ONE override layer. // 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. +// +// Prefer EffectiveChannelPerms, which applies both layers in order; this is the +// primitive it is built from. func EffectivePerms(rolePerm, allow, deny int64) int64 { return (rolePerm &^ deny) | allow } + +// EffectiveChannelPerms resolves a member's permissions in ONE channel, in +// Discord's order: +// +// base role permissions -> role override -> user override +// +// Each layer is EffectivePerms, so within a layer allow beats deny, and across +// layers the later (narrower) layer wins: a per-user deny beats a per-role +// allow, and a per-user allow beats a per-user deny. +// +// ADMINISTRATOR is deliberately NOT handled here — it is a bypass, not a bit +// that survives an override, and every caller short-circuits on HasAdmin before +// reaching this. Keeping the bypass at the call site means a channel override +// can still strip ADMINISTRATOR from a non-admin's computed mask (it never +// grants it) without this function quietly re-granting it. +// +// This is the single resolution formula: Checker.HasChannelPerm, +// Checker.HasChannelPermBatch (and through it VisibleChannelIDs) and +// service.PermissionService all route through it, so no call site can be left +// resolving only the role layer. +func EffectiveChannelPerms(basePerms int64, o ChannelOverride) int64 { + roleLayer := EffectivePerms(basePerms, o.Allow, o.Deny) + return EffectivePerms(roleLayer, o.UserAllow, o.UserDeny) +} diff --git a/Server/permissions/permissions_fuzz_test.go b/Server/permissions/permissions_fuzz_test.go new file mode 100644 index 00000000..6a36aae4 --- /dev/null +++ b/Server/permissions/permissions_fuzz_test.go @@ -0,0 +1,124 @@ +package permissions + +import "testing" + +// FuzzEffectivePerms checks EffectivePerms(rolePerm, allow, deny) against the +// formula its own doc comment states -- effective = (rolePerm &^ deny) | +// allow -- bit by bit, for arbitrary int64 inputs (not just the defined +// permission bits): a future edit that flips deny/allow precedence, or that +// drops a bit somewhere, shows up as a bit-level mismatch instead of only +// failing on the handful of bits the table tests happen to cover. +func FuzzEffectivePerms(f *testing.F) { + seeds := []int64{0, -1, AllPerms, Administrator, SendMessages, 0x1, 0x7FFFFFFF, 0x3FFFFFFF} + for _, a := range seeds { + for _, b := range seeds { + for _, c := range seeds { + f.Add(a, b, c) + } + } + } + + f.Fuzz(func(t *testing.T, rolePerm, allow, deny int64) { + got := EffectivePerms(rolePerm, allow, deny) + + for bit := range 64 { + mask := int64(1) << uint(bit) + gotBit := got&mask != 0 + allowBit := allow&mask != 0 + denyBit := deny&mask != 0 + roleBit := rolePerm&mask != 0 + + switch { + case allowBit: + // Allow always wins, even against a deny on the same bit. + if !gotBit { + t.Fatalf("EffectivePerms(%#x,%#x,%#x): bit %d in allow but clear in result %#x", rolePerm, allow, deny, bit, got) + } + case denyBit: + // Deny (without allow) always clears the bit, regardless of + // what the base role held. + if gotBit { + t.Fatalf("EffectivePerms(%#x,%#x,%#x): bit %d in deny (not allow) but set in result %#x", rolePerm, allow, deny, bit, got) + } + default: + // Untouched by either override: the base role's bit passes + // through unchanged. + if gotBit != roleBit { + t.Fatalf("EffectivePerms(%#x,%#x,%#x): bit %d untouched by overrides, want %v (from base) got %v in result %#x", rolePerm, allow, deny, bit, roleBit, gotBit, got) + } + } + } + + // No-op overrides never change the base. + if EffectivePerms(rolePerm, 0, 0) != rolePerm { + t.Fatalf("EffectivePerms(%#x,0,0) = %#x, want %#x unchanged", rolePerm, EffectivePerms(rolePerm, 0, 0), rolePerm) + } + }) +} + +// FuzzEffectiveChannelPerms checks the two-layer resolution +// (base -> role override -> user override) against the ordering its doc +// comment promises: each layer is EffectivePerms, and the layers compose by +// feeding one into the next -- so this locks EffectiveChannelPerms to being +// exactly that composition, not an inlined-and-drifted copy of it, and +// re-derives the "user allow beats everything, user deny beats the role +// layer, role layer beats base" precedence bit by bit. +func FuzzEffectiveChannelPerms(f *testing.F) { + seeds := []int64{0, -1, AllPerms, Administrator, SendMessages, ReadMessages, 0x1, 0x7FFFFFFF} + for _, a := range seeds { + for _, b := range seeds { + f.Add(a, b, int64(0), int64(0), int64(0)) + f.Add(a, int64(0), b, int64(0), int64(0)) + f.Add(a, int64(0), int64(0), b, int64(0)) + f.Add(a, int64(0), int64(0), int64(0), b) + f.Add(a, b, b, b, b) + } + } + + f.Fuzz(func(t *testing.T, base, allow, deny, userAllow, userDeny int64) { + o := ChannelOverride{Allow: allow, Deny: deny, UserAllow: userAllow, UserDeny: userDeny} + got := EffectiveChannelPerms(base, o) + + // The function must be exactly the two-layer composition its doc + // comment describes: role layer over base, user layer over that. + roleLayer := EffectivePerms(base, allow, deny) + want := EffectivePerms(roleLayer, userAllow, userDeny) + if got != want { + t.Fatalf("EffectiveChannelPerms(%#x, %+v) = %#x, want %#x (= EffectivePerms(EffectivePerms(base,Allow,Deny),UserAllow,UserDeny))", base, o, got, want) + } + + for bit := range 64 { + mask := int64(1) << uint(bit) + gotBit := got&mask != 0 + userAllowBit := userAllow&mask != 0 + userDenyBit := userDeny&mask != 0 + roleBit := roleLayer&mask != 0 + + switch { + case userAllowBit: + // A user allow beats a user deny on the same bit, and beats + // whatever the role layer decided. + if !gotBit { + t.Fatalf("EffectiveChannelPerms(%#x,%+v): bit %d in UserAllow but clear in result %#x", base, o, bit, got) + } + case userDenyBit: + // A user deny (without a user allow) beats a role-layer + // allow: the narrower, later layer always wins. + if gotBit { + t.Fatalf("EffectiveChannelPerms(%#x,%+v): bit %d in UserDeny (not UserAllow) but set in result %#x", base, o, bit, got) + } + default: + // No user-layer opinion: the role layer's bit passes through. + if gotBit != roleBit { + t.Fatalf("EffectiveChannelPerms(%#x,%+v): bit %d untouched by user layer, want %v (role layer) got %v in result %#x", base, o, bit, roleBit, gotBit, got) + } + } + } + + // A channel override that touches nothing must be a pure pass-through + // of the base role permissions. + if noop := EffectiveChannelPerms(base, ChannelOverride{}); noop != base { + t.Fatalf("EffectiveChannelPerms(%#x, zero override) = %#x, want %#x unchanged", base, noop, base) + } + }) +} diff --git a/Server/permissions/permissions_test.go b/Server/permissions/permissions_test.go index 3a610056..eb1b438b 100644 --- a/Server/permissions/permissions_test.go +++ b/Server/permissions/permissions_test.go @@ -420,3 +420,101 @@ func TestPermissionBits_AreDistinctPowersOfTwo(t *testing.T) { seen[b] = true } } + +// ─── HasAnyPerm / AdminPerimeter / Name tests ──────────────────────────────── + +func TestHasAnyPerm_AnyOfSemantics(t *testing.T) { + rolePerms := permissions.KickMembers | permissions.SendMessages + // One matching bit is enough, unlike HasPerm's ALL-of. + if !permissions.HasAnyPerm(rolePerms, permissions.KickMembers|permissions.ManageServer) { + t.Error("expected HasAnyPerm to match on KickMembers") + } + if permissions.HasPerm(rolePerms, permissions.KickMembers|permissions.ManageServer) { + t.Error("HasPerm must stay ALL-of") + } + if permissions.HasAnyPerm(rolePerms, permissions.ManageServer|permissions.ViewAuditLog) { + t.Error("expected HasAnyPerm to reject a disjoint mask") + } + if permissions.HasAnyPerm(rolePerms, 0) { + t.Error("a zero mask must never be satisfied") + } + if permissions.HasAnyPerm(0, permissions.AdminPerimeter) { + t.Error("a zero role must never satisfy the perimeter") + } +} + +func TestAdminPerimeter_Membership(t *testing.T) { + admitted := []int64{ + permissions.Administrator, permissions.ManageChannels, permissions.ManageRoles, + permissions.ManageServer, permissions.ViewAuditLog, permissions.KickMembers, + permissions.BanMembers, permissions.MuteMembers, + } + for _, p := range admitted { + if !permissions.HasAnyPerm(p, permissions.AdminPerimeter) { + t.Errorf("%s should admit to the admin perimeter", permissions.Name(p)) + } + } + // Bits with no admin-panel route must not open the perimeter. + refused := []int64{ + permissions.SendMessages, permissions.ReadMessages, permissions.ManageMessages, + permissions.ManageInvites, permissions.ConnectVoice, + } + for _, p := range refused { + if permissions.HasAnyPerm(p, permissions.AdminPerimeter) { + t.Errorf("%s must not admit to the admin perimeter", permissions.Name(p)) + } + } + // The seeded Moderator role (migration 001) gets in. + if !permissions.HasAnyPerm(0x000FFFFF, permissions.AdminPerimeter) { + t.Error("the seeded Moderator mask should admit to the admin perimeter") + } + // The seeded Member role does not. + if permissions.HasAnyPerm(1635, permissions.AdminPerimeter) { + t.Error("the seeded Member mask must not admit to the admin perimeter") + } +} + +func TestName_KnownAndUnknownBits(t *testing.T) { + if got := permissions.Name(permissions.ManageChannels); got != "MANAGE_CHANNELS" { + t.Errorf("Name(ManageChannels) = %q", got) + } + if got := permissions.Name(permissions.ViewAuditLog); got != "VIEW_AUDIT_LOG" { + t.Errorf("Name(ViewAuditLog) = %q", got) + } + // Zero, multi-bit and undefined values have no single name. + for _, bit := range []int64{0, permissions.KickMembers | permissions.BanMembers, 0x4} { + if got := permissions.Name(bit); got != "UNKNOWN" { + t.Errorf("Name(0x%X) = %q, want UNKNOWN", bit, got) + } + } +} + +// TestMentionEveryone_BitIsFreeAndNamed locks phase 3's new bit: 21 was +// unassigned, it is part of AllPerms so an externally supplied mask keeps it, +// and it stays out of the admin perimeter (mentioning is not moderation). +func TestMentionEveryone_BitIsFreeAndNamed(t *testing.T) { + if permissions.MentionEveryone != 0x200000 { + t.Errorf("MentionEveryone = 0x%X, want 0x200000 (bit 21)", permissions.MentionEveryone) + } + for _, other := range []int64{ + permissions.SendMessages, permissions.ReadMessages, permissions.AttachFiles, + permissions.AddReactions, 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, + } { + if other&permissions.MentionEveryone != 0 { + t.Errorf("bit 21 collides with 0x%X", other) + } + } + if permissions.AllPerms&permissions.MentionEveryone == 0 { + t.Error("AllPerms must include MentionEveryone") + } + if permissions.AdminPerimeter&permissions.MentionEveryone != 0 { + t.Error("MentionEveryone must not admit to the admin perimeter") + } + if got := permissions.Name(permissions.MentionEveryone); got != "MENTION_EVERYONE" { + t.Errorf("Name(MentionEveryone) = %q", got) + } +} diff --git a/Server/permissions/user_override_test.go b/Server/permissions/user_override_test.go new file mode 100644 index 00000000..b40a4ba4 --- /dev/null +++ b/Server/permissions/user_override_test.go @@ -0,0 +1,212 @@ +package permissions + +import ( + "context" + "errors" + "testing" +) + +// ─── Resolution precedence ─────────────────────────────────────────────────── +// +// Discord's order is: base role permissions -> role override -> user override. +// Within a layer allow beats deny; across layers the later (narrower) layer +// wins. These cases pin every crossing of the two rules, because getting one +// of them backwards is exactly how a "private" channel leaks. + +func TestEffectiveChannelPerms_ResolutionOrder(t *testing.T) { + tests := []struct { + name string + base int64 + o ChannelOverride + perm int64 + want bool + }{ + { + name: "base grant with no overrides", + base: ReadMessages | SendMessages, + perm: SendMessages, + want: true, + }, + { + name: "role deny strips a base grant", + base: ReadMessages | SendMessages, + o: ChannelOverride{Deny: SendMessages}, + perm: SendMessages, + want: false, + }, + { + name: "role allow adds a bit the base lacks", + base: ReadMessages, + o: ChannelOverride{Allow: SendMessages}, + perm: SendMessages, + want: true, + }, + { + name: "user deny beats a role allow", + base: ReadMessages, + o: ChannelOverride{Allow: SendMessages, UserDeny: SendMessages}, + perm: SendMessages, + want: false, + }, + { + name: "user deny beats a base grant", + base: ReadMessages | SendMessages, + o: ChannelOverride{UserDeny: SendMessages}, + perm: SendMessages, + want: false, + }, + { + name: "user allow beats a role deny", + base: ReadMessages | SendMessages, + o: ChannelOverride{Deny: SendMessages, UserAllow: SendMessages}, + perm: SendMessages, + want: true, + }, + { + name: "user allow grants a bit neither the base nor the role has", + base: ReadMessages, + o: ChannelOverride{UserAllow: SendMessages}, + perm: SendMessages, + want: true, + }, + { + name: "user allow beats user deny on the same bit", + base: ReadMessages, + o: ChannelOverride{UserAllow: SendMessages, UserDeny: SendMessages}, + perm: SendMessages, + want: true, + }, + { + name: "user deny of READ hides a channel a role allow revealed", + base: 0, + o: ChannelOverride{Allow: ReadMessages, UserDeny: ReadMessages}, + perm: ReadMessages, + want: false, + }, + { + name: "layers are independent per bit", + base: ReadMessages | SendMessages, + o: ChannelOverride{UserDeny: SendMessages}, + perm: ReadMessages, + want: true, + }, + { + name: "multi-bit check is ALL-of", + base: ReadMessages | SendMessages, + o: ChannelOverride{UserDeny: SendMessages}, + perm: ReadMessages | SendMessages, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eff := EffectiveChannelPerms(tt.base, tt.o) + if got := eff&tt.perm == tt.perm; got != tt.want { + t.Errorf("EffectiveChannelPerms(0x%X, %+v)&0x%X = %v, want %v", + tt.base, tt.o, tt.perm, got, tt.want) + } + }) + } +} + +// An ADMINISTRATOR role bypasses BOTH layers. The bypass lives at the call +// site, not inside EffectiveChannelPerms, so this is asserted through the +// Checker — the surface every caller actually uses. +func TestHasChannelPerm_AdminBypassesUserOverride(t *testing.T) { + mock := newMockDB() + mock.userPerms[chanUserKey{10, 7}] = chanPerm{deny: ReadMessages | SendMessages} + ck := NewChecker(mock) + + if !ck.HasChannelPerm(context.Background(), Administrator, 1, 7, 10, ReadMessages|SendMessages) { + t.Error("ADMINISTRATOR must bypass a per-user deny") + } + if !ck.HasChannelPermBatch(Administrator, map[int64]ChannelOverride{ + 10: {UserDeny: ReadMessages}, + }, 10, ReadMessages) { + t.Error("ADMINISTRATOR must bypass a per-user deny in the batch path too") + } +} + +func TestHasChannelPerm_AppliesUserLayer(t *testing.T) { + const ( + chID = 10 + roleID = int64(4) + aliceI = int64(7) + bobID = int64(8) + ) + mock := newMockDB() + // The role may read but not send here. + mock.channelPerms[chanRoleKey{chID, roleID}] = chanPerm{deny: SendMessages} + // Alice is individually granted SEND back; Bob is individually denied READ. + mock.userPerms[chanUserKey{chID, aliceI}] = chanPerm{allow: SendMessages} + mock.userPerms[chanUserKey{chID, bobID}] = chanPerm{deny: ReadMessages} + ck := NewChecker(mock) + + base := ReadMessages | SendMessages + ctx := context.Background() + + if !ck.HasChannelPerm(ctx, base, roleID, aliceI, chID, SendMessages) { + t.Error("alice: user allow must beat the role deny") + } + if ck.HasChannelPerm(ctx, base, roleID, bobID, chID, ReadMessages) { + t.Error("bob: user deny must beat the base READ grant") + } + // A third member with no user override falls back to the role verdict. + if ck.HasChannelPerm(ctx, base, roleID, 99, chID, SendMessages) { + t.Error("carol: role deny still applies without a user override") + } + if !ck.HasChannelPerm(ctx, base, roleID, 99, chID, ReadMessages) { + t.Error("carol: base READ still applies without a user override") + } +} + +// userID 0 means "no member in hand" — the per-user layer is skipped rather +// than queried for an id that cannot exist. +func TestHasChannelPerm_ZeroUserSkipsUserLayer(t *testing.T) { + mock := newMockDB() + mock.userPerms[chanUserKey{10, 0}] = chanPerm{deny: ReadMessages} + ck := NewChecker(mock) + + if !ck.HasChannelPerm(context.Background(), ReadMessages, 4, 0, 10, ReadMessages) { + t.Error("userID 0 must not consult channel_user_overrides") + } +} + +// A failed per-user lookup must deny, exactly as a failed per-role lookup does: +// substituting a zero override would restore every bit a user deny stripped. +func TestHasChannelPerm_UserLookupErrorDenies(t *testing.T) { + mock := newMockDB() + mock.userErr = errors.New("boom") + ck := NewChecker(mock) + + if ck.HasChannelPerm(context.Background(), ReadMessages, 4, 7, 10, ReadMessages) { + t.Error("a per-user override lookup failure must fail closed") + } +} + +func TestVisibleChannelIDs_UserOverrideLayer(t *testing.T) { + ck := NewChecker(newMockDB()) + channels := []ChannelRef{ + {ID: 1, Type: "text"}, + {ID: 2, Type: "text"}, + {ID: 3, Type: "voice"}, + } + overrides := map[int64]ChannelOverride{ + // Role can read #2, but this member is individually denied. + 2: {UserDeny: ReadMessages}, + // Role is denied #3, but this member is individually allowed. + 3: {Deny: ReadMessages, UserAllow: ReadMessages}, + } + + got := ck.VisibleChannelIDs(ReadMessages, channels, overrides) + if !got[1] { + t.Error("channel 1 (no override) must be visible") + } + if got[2] { + t.Error("channel 2 must be hidden by the per-user deny") + } + if !got[3] { + t.Error("channel 3 must be revealed by the per-user allow") + } +} diff --git a/Server/plugin/host_ui.go b/Server/plugin/host_ui.go index 453c7b6a..553abf30 100644 --- a/Server/plugin/host_ui.go +++ b/Server/plugin/host_ui.go @@ -43,45 +43,56 @@ func (r *Registry) RegisterUI(inst *Instance) error { // inst, rooted at the plugin's directory. Defense in depth: // 1. Manifest validation rejects absolute paths and "..". // 2. The handler only serves files explicitly declared by a manifest tab. -// 3. After resolving the on-disk path we use filepath.Rel and reject any -// result containing ".." or that is absolute, which catches symlink -// escapes and the prefix-without-separator class of bug. +// 3. Asset paths are resolved once at construction, not per request: the +// request path is used solely as a map key, so no on-disk path is ever +// built from user input. Each declared asset is checked with +// filepath.Rel and dropped if the result contains ".." or is absolute, +// which catches symlink escapes and the prefix-without-separator bug. // 4. A serve-time os.Lstat check rejects symlinks that were created AFTER // install (the install-time rejectSymlinksUnder walk only runs once). // This closes the TOCTOU window where a malicious or buggy process // swaps a regular file for a symlink post-install — http.ServeFile // would otherwise follow the link and leak host files. func (r *Registry) AssetHandler(inst *Instance) http.Handler { - allowed := make(map[string]bool, len(inst.Manifest.UI.Tabs)) - for _, t := range inst.Manifest.UI.Tabs { - allowed[t.Asset] = true - } pluginDir, dirErr := filepath.Abs(filepath.Dir(inst.WASMPath)) + + // Resolve and traversal-check every declared asset ONCE, here at + // construction, mapping the manifest's asset name to its absolute path. + // At serve time the request path is only ever used as a map key, never + // as a path component, so no filesystem path is built from user input + // at all. An asset that fails validation is simply absent from the map + // and 404s, exactly as an undeclared file does. + allowed := make(map[string]string, len(inst.Manifest.UI.Tabs)) + if dirErr == nil { + for _, t := range inst.Manifest.UI.Tabs { + full, absErr := filepath.Abs(filepath.Join(pluginDir, t.Asset)) + if absErr != nil { + continue + } + rel, relErr := filepath.Rel(pluginDir, full) + if relErr != nil || rel == "" || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + continue + } + allowed[t.Asset] = full + } + } + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if dirErr != nil { http.Error(w, "plugin asset root unavailable", http.StatusInternalServerError) return } rel := strings.TrimPrefix(req.URL.Path, "/") - if !allowed[rel] { + full, ok := allowed[rel] + if !ok { http.NotFound(w, req) return } - full, absErr := filepath.Abs(filepath.Join(pluginDir, rel)) - if absErr != nil { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - relCheck, relErr := filepath.Rel(pluginDir, full) - if relErr != nil || relCheck == "" || relCheck == "." || strings.HasPrefix(relCheck, "..") || filepath.IsAbs(relCheck) { - http.Error(w, "forbidden", http.StatusForbidden) - return - } // Lstat (not Stat) so a symlink is detected instead of followed. // This runs on every request — cheap relative to the file read — // and closes the TOCTOU gap between install-time validation and // runtime serving. - info, lerr := os.Lstat(full) //nolint:gosec // path traversal blocked above: rel validated and cleaned + info, lerr := os.Lstat(full) //nolint:gosec // not user input: full comes from the construction-time allowlist if lerr != nil { http.NotFound(w, req) return @@ -94,7 +105,7 @@ func (r *Registry) AssetHandler(inst *Instance) http.Handler { http.Error(w, "forbidden", http.StatusForbidden) return } - f, openErr := os.Open(full) //nolint:gosec // path traversal blocked above: rel validated and cleaned + f, openErr := os.Open(full) //nolint:gosec // not user input: full comes from the construction-time allowlist if openErr != nil { http.NotFound(w, req) return diff --git a/Server/plugin/manifest_relpath_fuzz_test.go b/Server/plugin/manifest_relpath_fuzz_test.go new file mode 100644 index 00000000..5dddf3e7 --- /dev/null +++ b/Server/plugin/manifest_relpath_fuzz_test.go @@ -0,0 +1,73 @@ +package plugin + +import ( + "strings" + "testing" +) + +// FuzzValidateRelativePath asserts that whenever validateRelativePath +// accepts a path (returns nil), that path is genuinely contained under the +// plugin base directory: not absolute, not empty, containing no ".." +// traversal component (checked as a real path segment, not just a +// substring, so a segment like "..foo" or "foo.." can't false-positive), +// no NUL byte, no backslash, and no leading separator. +func FuzzValidateRelativePath(f *testing.F) { + seeds := []string{ + "", + ".", + "..", + "/", + "/etc/passwd", + "a/b/c.js", + "../secret", + "a/../../secret", + "a/../b", + "./a", + "a//b", + "a/", + "a/b/", + "\\windows\\evil", + "a\\b", + "a\x00b", + "..a", + "a..", + "a..b", + "foo/..bar", + "C:\\evil.dll", + strings.Repeat("a/", 100) + "x.js", + "a/./b", + "a/../b/../c", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, p string) { + err := validateRelativePath(p) + if err != nil { + return + } + + // Accepted: verify genuine containment under the plugin base dir. + if p == "" { + t.Fatalf("validateRelativePath(%q) = nil but path is empty", p) + } + if strings.ContainsRune(p, 0) { + t.Fatalf("validateRelativePath(%q) = nil but path contains a NUL byte", p) + } + if strings.ContainsRune(p, '\\') { + t.Fatalf("validateRelativePath(%q) = nil but path contains a backslash", p) + } + if strings.HasPrefix(p, "/") { + t.Fatalf("validateRelativePath(%q) = nil but path is absolute", p) + } + if p == "." { + t.Fatalf("validateRelativePath(%q) = nil but path is the current directory", p) + } + for seg := range strings.SplitSeq(p, "/") { + if seg == ".." { + t.Fatalf("validateRelativePath(%q) = nil but path contains a %q traversal segment", p, "..") + } + } + }) +} diff --git a/Server/plugin/registry_test.go b/Server/plugin/registry_test.go index 336e088c..6af43cb1 100644 --- a/Server/plugin/registry_test.go +++ b/Server/plugin/registry_test.go @@ -1,5 +1,13 @@ +//go:build !wazero + // Registry lifecycle tests for the default (non-wazero) build. // +// The build constraint above is load-bearing, not decorative: these tests +// assert activation fails with ErrRuntimeUnavailable, which is only true +// when no runtime is linked in. Under -tags wazero a real runtime exists +// and two of them failed. The file always intended to be default-only (see +// the paragraph below); it just never carried the tag. +// // registry.go is the largest source file in the plugin package and its // lifecycle half — Sink, activate, EnablePlugin, DisablePlugin, // UninstallPlugin, List, UITabBindings — had no coverage. The wazero-tagged diff --git a/Server/service/channel.go b/Server/service/channel.go index b1a963c4..c0b97b67 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "time" + "unicode/utf8" "github.com/owncord/server/auth" "github.com/owncord/server/db" @@ -54,12 +55,13 @@ func (s *ChannelService) ListVisibleChannels(ctx context.Context, userID int64) } // Admins skip the override fetch (they bypass all channel checks anyway). + // Non-admins get both layers — role and per-user — in one batched fetch. var overrides map[int64]db.ChannelOverride if !permissions.HasAdmin(role.Permissions) { - overrides, err = s.st.GetAllChannelPermissionsForRole(ctx, role.ID) + overrides, err = s.st.GetChannelOverridesFor(ctx, role.ID, userID) if err != nil { // Fail closed — an empty map would return every denied channel. - slog.Error("ChannelService.ListVisibleChannels GetAllChannelPermissionsForRole", "err", err, "user_id", userID, "role_id", role.ID) + slog.Error("ChannelService.ListVisibleChannels GetChannelOverridesFor", "err", err, "user_id", userID, "role_id", role.ID) return nil, fmt.Errorf("%w: failed to fetch channel overrides", ErrInternal) } } @@ -80,16 +82,23 @@ func (s *ChannelService) ListVisibleChannels(ctx context.Context, userID int64) func channelRefs(channels []db.Channel) []permissions.ChannelRef { refs := make([]permissions.ChannelRef, len(channels)) for i := range channels { - refs[i] = permissions.ChannelRef{ID: channels[i].ID, Type: channels[i].Type} + refs[i] = permissions.ChannelRef{ID: channels[i].ID, Type: channels[i].Type, Archived: channels[i].Archived} } return refs } -// permOverrides maps a db override map to the checker's override map. +// permOverrides maps a db override map to the checker's override map, carrying +// BOTH layers — the role override and the per-user override — so the checker +// resolves the full order (base -> role -> user) rather than half of it. func permOverrides(overrides map[int64]db.ChannelOverride) map[int64]permissions.ChannelOverride { out := make(map[int64]permissions.ChannelOverride, len(overrides)) for id, o := range overrides { - out[id] = permissions.ChannelOverride{Allow: o.Allow, Deny: o.Deny} + out[id] = permissions.ChannelOverride{ + Allow: o.Allow, + Deny: o.Deny, + UserAllow: o.UserAllow, + UserDeny: o.UserDeny, + } } return out } @@ -140,30 +149,64 @@ func (s *ChannelService) GetDMParticipantIDs(ctx context.Context, channelID int6 return s.st.GetDMParticipantIDs(ctx, channelID) } -// HandlePresenceUpdate validates and persists a presence status change. -func (s *ChannelService) HandlePresenceUpdate(ctx context.Context, userID int64, status string, limiter interface { +// HandlePresenceUpdate validates and persists a presence status change, and +// (when customStatus is non-nil) the custom status line that came with it. +// +// The status is stored as chosen, invisible included; collapsing invisible to +// offline is a broadcast-time concern (db.BroadcastStatus), not a storage one — +// the server has to be able to tell "chose to look offline" from "is gone" on +// the next connect. Returns the sanitized custom status the caller should put +// on the wire, so the broadcast and the row can never disagree. +func (s *ChannelService) HandlePresenceUpdate(ctx context.Context, userID int64, status string, customStatus *string, limiter interface { Allow(key string, limit int, window time.Duration) bool }, -) error { +) (*string, error) { // Rate limit. ratKey := auth.Key("presence", userID) if limiter != nil && !limiter.Allow(ratKey, 1, 10*time.Second) { - return ErrRateLimited + return nil, ErrRateLimited } - validStatuses := map[string]bool{ - "online": true, "idle": true, "dnd": true, "offline": true, + if !db.ValidStatuses[status] { + return nil, fmt.Errorf("%w: invalid status", ErrBadRequest) } - if !validStatuses[status] { - return fmt.Errorf("%w: invalid status", ErrBadRequest) + + var cleaned *string + if customStatus != nil { + text := cleanText(*customStatus) + if utf8.RuneCountInString(text) > MaxCustomStatusLen { + return nil, fmt.Errorf("%w: custom_status must be at most %d characters", ErrBadRequest, MaxCustomStatusLen) + } + cleaned = nullable(text) } if err := s.st.UpdateUserStatus(ctx, userID, status); err != nil { slog.Error("ChannelService.HandlePresenceUpdate", "err", err, "user_id", userID) - return fmt.Errorf("%w: failed to update status", ErrInternal) + return nil, fmt.Errorf("%w: failed to update status", ErrInternal) + } + if customStatus != nil { + if err := s.st.UpdateUserCustomStatus(ctx, userID, cleaned); err != nil { + slog.Error("ChannelService.HandlePresenceUpdate custom status", "err", err, "user_id", userID) + return nil, fmt.Errorf("%w: failed to update custom status", ErrInternal) + } + return cleaned, nil } - return nil + // The command carried no custom_status field, so the stored one stands and + // still has to ride along on the broadcast — otherwise a plain + // online -> idle flip would blank everyone else's copy of the text. + // + // A read failure here is deliberately swallowed rather than returned: the + // status is already committed and is about to be broadcast, so reporting + // an error would tell the caller a presence update failed that in fact + // succeeded. The only cost is that this one broadcast omits the text. + user, readErr := s.st.GetUserByID(ctx, userID) + if readErr != nil || user == nil { + slog.Warn("HandlePresenceUpdate: could not read stored custom status", + "err", readErr, "user_id", userID) + return nil, nil //nolint:nilerr,nilnil // status committed; see comment above + } + return user.CustomStatus, nil } // HandleChannelFocus processes a channel focus event and updates read state. diff --git a/Server/service/channel_user_override_test.go b/Server/service/channel_user_override_test.go new file mode 100644 index 00000000..951c3182 --- /dev/null +++ b/Server/service/channel_user_override_test.go @@ -0,0 +1,112 @@ +package service + +import ( + "context" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// seedChannelUserOverride sets a per-user permission override on a channel. +func seedChannelUserOverride(t *testing.T, database *db.DB, userID, channelID, allow, deny int64) { + t.Helper() + if err := database.UpsertChannelUserOverride(context.Background(), channelID, userID, allow, deny); err != nil { + t.Fatalf("seedChannelUserOverride(user=%d,chan=%d): %v", userID, channelID, err) + } +} + +func newOverrideFixture(t *testing.T) (*ChannelService, *PermissionService, *db.DB) { + t.Helper() + database := newTestDB(t) + seedRole(t, database, &db.Role{ + ID: permissions.MemberRoleID, + Name: "member", + Permissions: permissions.SendMessages | permissions.ReadMessages, + Position: 1, + }) + seedUser(t, database, &db.User{ID: 1, Username: "alice", Status: "online"}) + seedUser(t, database, &db.User{ID: 2, Username: "bob", Status: "online"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedUserRole(t, database, 2, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "open", Type: "text"}) + seedChannel(t, database, &db.Channel{ID: 11, Name: "locked", Type: "text"}) + // The role cannot read #locked at all. + seedChannelOverride(t, database, permissions.MemberRoleID, 11, 0, permissions.ReadMessages) + + permSvc := NewPermissionService(database, permissions.NewChecker(database)) + return NewChannelService(database, permSvc), permSvc, database +} + +func visibleIDs(t *testing.T, svc *ChannelService, userID int64) map[int64]bool { + t.Helper() + chans, err := svc.ListVisibleChannels(context.Background(), userID) + if err != nil { + t.Fatalf("ListVisibleChannels(%d): %v", userID, err) + } + got := make(map[int64]bool, len(chans)) + for i := range chans { + got[chans[i].ID] = true + } + return got +} + +// Two members of the same role must be able to disagree about a channel: the +// per-user layer is the last one in the resolution order. +func TestListVisibleChannels_PerUserOverrideSplitsRoleMates(t *testing.T) { + svc, _, database := newOverrideFixture(t) + + // alice is individually granted READ on the role-denied channel. + seedChannelUserOverride(t, database, 1, 11, permissions.ReadMessages, 0) + // bob is individually denied READ on the otherwise open channel. + seedChannelUserOverride(t, database, 2, 10, 0, permissions.ReadMessages) + + alice := visibleIDs(t, svc, 1) + if !alice[10] || !alice[11] { + t.Errorf("alice sees %v, want both 10 and 11", alice) + } + bob := visibleIDs(t, svc, 2) + if bob[10] || bob[11] { + t.Errorf("bob sees %v, want neither", bob) + } +} + +// The cached PermissionService must answer the full order too — it is the path +// every REST/WS permission check actually takes. +func TestPermissionService_AppliesUserOverrideLayer(t *testing.T) { + _, permSvc, database := newOverrideFixture(t) + ctx := context.Background() + + // alice: role denies READ on 11, user allow restores it. + seedChannelUserOverride(t, database, 1, 11, permissions.ReadMessages, 0) + // bob: role allows SEND on 10, user deny removes it (READ survives). + seedChannelUserOverride(t, database, 2, 10, 0, permissions.SendMessages) + + if !permSvc.HasChannelPerm(ctx, 1, 11, permissions.ReadMessages) { + t.Error("alice: user allow must beat the role deny") + } + if permSvc.HasChannelPerm(ctx, 2, 10, permissions.SendMessages) { + t.Error("bob: user deny must beat the role grant") + } + if !permSvc.HasChannelPerm(ctx, 2, 10, permissions.ReadMessages) { + t.Error("bob: an unrelated bit must be untouched") + } +} + +// A cache populated before the override was written must not answer from the +// stale snapshot once the owning user is invalidated — this is the contract the +// admin handler relies on when it calls InvalidateUser before the hub fan-out. +func TestPermissionService_InvalidateUserPicksUpNewOverride(t *testing.T) { + _, permSvc, database := newOverrideFixture(t) + ctx := context.Background() + + if !permSvc.HasChannelPerm(ctx, 1, 10, permissions.ReadMessages) { + t.Fatal("alice must start with READ on the open channel") + } + seedChannelUserOverride(t, database, 1, 10, 0, permissions.ReadMessages) + permSvc.InvalidateUser(1) + + if permSvc.HasChannelPerm(ctx, 1, 10, permissions.ReadMessages) { + t.Error("after InvalidateUser the new per-user deny must apply") + } +} diff --git a/Server/service/datastore.go b/Server/service/datastore.go index 747b9374..d6604c3c 100644 --- a/Server/service/datastore.go +++ b/Server/service/datastore.go @@ -20,11 +20,14 @@ type Store interface { // ── Messages / reactions / read-state ── CreateMessage(ctx context.Context, channelID, userID int64, content string, replyTo *int64) (int64, error) CreateMessageReturning(ctx context.Context, channelID, userID int64, content string, replyTo *int64) (*db.Message, error) + CreateMessageWithMentions(ctx context.Context, channelID, userID int64, content string, replyTo *int64, mentionedUserIDs []int64, mentionsEveryone bool) (*db.Message, error) GetMessage(ctx context.Context, id int64) (*db.Message, error) GetMessages(ctx context.Context, channelID, before int64, limit int) ([]db.MessageWithUser, error) GetMessagesForAPI(ctx context.Context, channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) + GetMessagesAroundForAPI(ctx context.Context, channelID, centerID int64, beforeCount, afterCount int, requestingUserID int64) ([]db.MessageAPIResponse, error) EditMessage(ctx context.Context, id, userID int64, content string) (*db.Message, error) DeleteMessage(ctx context.Context, id, userID int64, isMod bool) error + PurgeChannelMessages(ctx context.Context, channelID, before int64, limit int) ([]int64, error) SearchMessages(ctx context.Context, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) SearchMessagesInChannels(ctx context.Context, query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) GetPinnedMessages(ctx context.Context, channelID int64, requestingUserID int64) ([]db.MessageAPIResponse, error) @@ -32,8 +35,20 @@ type Store interface { AddReaction(ctx context.Context, messageID, userID int64, emoji string) error RemoveReaction(ctx context.Context, messageID, userID int64, emoji string) error GetReactions(ctx context.Context, messageID int64) ([]db.ReactionCount, error) + GetReactionUsers(ctx context.Context, messageID int64, emoji string, limit int) ([]db.ReactionUser, error) UpdateReadState(ctx context.Context, userID, channelID, lastReadMessageID int64) error GetChannelUnreadCounts(ctx context.Context, userID int64) (map[int64]db.ChannelUnread, error) + + // ── Mentions ── + ReplaceMessageMentions(ctx context.Context, messageID int64, mentionedUserIDs []int64, mentionsEveryone bool) error + GetMentionsByMessageIDs(ctx context.Context, msgIDs []int64) (map[int64][]int64, error) + IncrementMentionCounts(ctx context.Context, channelID int64, userIDs []int64) error + GetUserIDsByUsernames(ctx context.Context, usernames []string) (map[string]int64, error) + ListMentionTargetsByRoles(ctx context.Context, roleIDs []int64) ([]db.MentionTarget, error) + ListBlockersOf(ctx context.Context, blockedID int64) ([]int64, error) + GetChannelOverrides(ctx context.Context, channelID int64) (map[int64]db.ChannelOverride, error) + GetChannelUserOverrides(ctx context.Context, channelID int64) (map[int64]db.ChannelOverride, error) + ListMentionTargetsByUserIDs(ctx context.Context, userIDs []int64) ([]db.MentionTarget, error) GetLatestMessageID(ctx context.Context, channelID int64) (int64, error) LinkAttachmentsToMessage(ctx context.Context, messageID, uploaderID int64, attachmentIDs []string) (int64, error) GetAttachmentsByMessageIDs(ctx context.Context, msgIDs []int64) (map[int64][]db.AttachmentInfo, error) @@ -46,8 +61,17 @@ type Store interface { DeleteChannel(ctx context.Context, id int64) error SetChannelSlowMode(ctx context.Context, id int64, slowMode int) error SetChannelVoiceMaxUsers(ctx context.Context, id int64, maxUsers int) error + // GetChannelPermissions / GetUserChannelPermissions are the two single-row + // override lookups permissions.DB requires (Store is passed straight to + // permissions.NewChecker). GetChannelPermissions(ctx context.Context, channelID, roleID int64) (allow, deny int64, err error) + GetUserChannelPermissions(ctx context.Context, channelID, userID int64) (allow, deny int64, err error) GetAllChannelPermissionsForRole(ctx context.Context, roleID int64) (map[int64]db.ChannelOverride, error) + // GetChannelOverridesFor merges the role and per-user override layers for + // one member in two batch queries — the single fetch behind every + // "what can this member do here" site, and the reason no site pays an N+1 + // for the second layer. + GetChannelOverridesFor(ctx context.Context, roleID, userID int64) (map[int64]db.ChannelOverride, error) GetChannelTypes(ctx context.Context, ids []int64) (map[int64]string, error) // ── Users ── @@ -56,7 +80,8 @@ type Store interface { CreateUser(ctx context.Context, username, passwordHash string, roleID int) (int64, error) CreateOwnerIfEmpty(ctx context.Context, username, passwordHash string, roleID int) (int64, error) CreateUserWithInvite(ctx context.Context, username, passwordHash string, roleID int, inviteCode string) (int64, error) - UpdateUserProfile(ctx context.Context, userID int64, username string, avatar *string) error + UpdateUserProfile(ctx context.Context, userID int64, username string, avatar, displayName, about *string) error + UpdateUserCustomStatus(ctx context.Context, userID int64, customStatus *string) error UpdateUserPassword(ctx context.Context, userID int64, newPasswordHash string) error UpdateUserStatus(ctx context.Context, id int64, status string) error UpdateUserTOTPSecret(ctx context.Context, id int64, secret *string) error @@ -84,6 +109,21 @@ type Store interface { GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) GetUserWithRole(ctx context.Context, userID int64) (*db.User, *db.Role, error) ListRoles(ctx context.Context) ([]*db.Role, error) + GetRoleByName(ctx context.Context, name string) (*db.Role, error) + GetDefaultRole(ctx context.Context) (*db.Role, error) + CreateRole(ctx context.Context, name string, color *string, perms int64, position int) (*db.Role, error) + UpdateRole(ctx context.Context, id int64, name string, color *string, perms int64, position int) error + SetRolePositions(ctx context.Context, positions map[int64]int) error + DeleteRoleReassigning(ctx context.Context, roleID, fallbackRoleID int64) ([]int64, error) + ListUserIDsByRole(ctx context.Context, roleID int64) ([]int64, error) + CountRoleMembers(ctx context.Context) (map[int64]int, error) + + // ── Emoji ── + ListEmoji(ctx context.Context) ([]*db.Emoji, error) + GetEmoji(ctx context.Context, id int64) (*db.Emoji, error) + GetEmojiByShortcode(ctx context.Context, shortcode string) (*db.Emoji, error) + CreateEmoji(ctx context.Context, shortcode, storedAs, mimeType string, uploadedBy int64) (*db.Emoji, error) + DeleteEmoji(ctx context.Context, id int64) (bool, error) // ── Invites ── CreateInvite(ctx context.Context, createdBy int64, maxUses int, expiresAt *time.Time) (string, error) @@ -119,6 +159,12 @@ type Store interface { IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) GetDMRecipient(ctx context.Context, channelID, requestingUserID int64) (*db.User, error) + CreateGroupDMChannel(ctx context.Context, name string, participantIDs []int64) (*db.Channel, error) + LeaveGroupDM(ctx context.Context, userID, channelID int64) (bool, error) + CountDMParticipants(ctx context.Context, channelID int64) (int, error) + IsGroupDM(ctx context.Context, channelID int64) (bool, error) + SetDMChannelName(ctx context.Context, channelID int64, name string) error + GetDMParticipants(ctx context.Context, channelID, viewerID int64) ([]db.DMUser, error) // ── Blocks ── BlockUser(ctx context.Context, blockerID, blockedID int64) error @@ -142,7 +188,7 @@ type Store interface { LogAudit(ctx context.Context, actorID int64, action, targetType string, targetID int64, detail string) error GetAuditLog(ctx context.Context, limit, offset int) ([]db.AuditEntry, error) AdminCreateChannel(ctx context.Context, name, chanType, category, topic string, position int) (int64, error) - AdminUpdateChannel(ctx context.Context, id int64, name, topic string, slowMode, position int, archived bool) error + AdminUpdateChannel(ctx context.Context, id int64, u db.ChannelUpdate) error AdminDeleteChannel(ctx context.Context, id int64) error BackupTo(ctx context.Context, path string) error BackupToSafe(ctx context.Context, path, safeRoot string) error diff --git a/Server/service/dm.go b/Server/service/dm.go index 006298f2..f7577405 100644 --- a/Server/service/dm.go +++ b/Server/service/dm.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "time" + "unicode/utf8" "github.com/owncord/server/db" "github.com/owncord/server/telemetry" @@ -83,21 +84,276 @@ func (s *DMService) ListDMs(ctx context.Context, userID int64) ([]db.DMChannelIn return dms, nil } +// CloseDMResult describes what closing a DM actually did, so the caller knows +// which events to send. +type CloseDMResult struct { + // Left is true when the caller left a *group* DM: they are no longer a + // participant, and the remaining members need to be told. + Left bool + // ChannelDeleted is true when the caller was the last participant of a + // group and the channel row went with them. + ChannelDeleted bool + // RemainingParticipantIDs is who is still in the group after a leave. Empty + // for a 1:1 close, which changes nothing for the other party. + RemainingParticipantIDs []int64 +} + // CloseDM closes a DM channel for a user. -func (s *DMService) CloseDM(ctx context.Context, userID, channelID int64) error { +// +// For a 1:1 DM this hides the conversation from the caller's sidebar and +// nothing more — the other party keeps their copy, and the next message from +// either side re-opens it. For a group DM it is a *leave*: the caller comes +// out of dm_participants, stops receiving the group's messages, and cannot +// re-open it without being re-added. The two meanings share a route because +// they share a gesture ("get this out of my list"), but they are different +// operations and the result says which one ran. +func (s *DMService) CloseDM(ctx context.Context, userID, channelID int64) (*CloseDMResult, error) { if channelID <= 0 { - return fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) + return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { - return fmt.Errorf("%w: not a participant in this DM", ErrNotFound) + return nil, fmt.Errorf("%w: not a participant in this DM", ErrNotFound) } - if err := s.st.CloseDM(ctx, userID, channelID); err != nil { - return fmt.Errorf("%w: failed to close DM: %v", ErrInternal, err) + isGroup, err := s.st.IsGroupDM(ctx, channelID) + if err != nil { + return nil, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err) } - slog.Debug("DM closed", "user_id", userID, "channel_id", channelID) - return nil + if !isGroup { + if err := s.st.CloseDM(ctx, userID, channelID); err != nil { + return nil, fmt.Errorf("%w: failed to close DM: %v", ErrInternal, err) + } + slog.Debug("DM closed", "user_id", userID, "channel_id", channelID) + return &CloseDMResult{}, nil + } + + // Read the survivors *before* the leave: after it, the caller is gone from + // dm_participants and a post-hoc read could not tell "left" from "was + // never in it" if the delete half-succeeded. + remaining, err := s.st.GetDMParticipantIDs(ctx, channelID) + if err != nil { + return nil, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err) + } + survivors := make([]int64, 0, len(remaining)) + for _, pid := range remaining { + if pid != userID { + survivors = append(survivors, pid) + } + } + + deleted, err := s.st.LeaveGroupDM(ctx, userID, channelID) + if err != nil { + return nil, fmt.Errorf("%w: failed to leave group DM: %v", ErrInternal, err) + } + + slog.Debug("group DM left", "user_id", userID, "channel_id", channelID, "deleted", deleted) + return &CloseDMResult{ + Left: true, + ChannelDeleted: deleted, + RemainingParticipantIDs: survivors, + }, nil +} + +// ─── Group DMs ────────────────────────────────────────────────────────────── + +// MaxGroupDMNameLen bounds the optional group name. It matches the channel +// name cap: a group DM name renders in the same sidebar row a channel name +// does, so a longer one would only ever be shown clipped. +const MaxGroupDMNameLen = 100 + +// CreateGroupDMResult holds the result of creating a group DM. +type CreateGroupDMResult struct { + Channel *db.Channel + Participants []db.DMUser + // ParticipantIDs is every member including the creator, which is the set + // the caller fans dm_channel_open out to. + ParticipantIDs []int64 +} + +// CreateGroupDM creates a group DM between the caller and 2..8 other users +// (3..10 total, matching db.MaxGroupDMParticipants). +// +// Blocks are enforced in both directions, per recipient: a user cannot pull +// someone they have blocked into a room with them, and cannot use a group to +// reach someone who blocked them. The check is deliberately *creation-time +// only* — once the group exists, sending into it is not block-checked, because +// a group DM is a shared room and silently dropping one member's messages for +// one other member would make the conversation lie to everybody in it. That is +// also why the composer gate on the client applies to 1:1 DMs alone. +// +// Unlike CreateDM this always creates a new channel: the same set of people may +// want more than one group, so there is no "the group for these users" to find. +func (s *DMService) CreateGroupDM(ctx context.Context, userID int64, recipientIDs []int64, name string) (*CreateGroupDMResult, error) { + ctx, span := telemetry.GlobalTracer("service/dm").Start(ctx, "DMService.CreateGroupDM", + telemetry.Int64("user_id", userID), + ) + start := time.Now() + defer func() { + telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start, + telemetry.String("method", "CreateGroupDM")) + span.End() + }() + + // De-duplicate and drop the caller: a payload naming the same person twice + // is a client bug, not a reason to refuse, but it must not inflate the + // participant count or double-insert. + seen := map[int64]bool{userID: true} + unique := make([]int64, 0, len(recipientIDs)) + for _, rid := range recipientIDs { + if rid <= 0 { + return nil, fmt.Errorf("%w: recipient_ids must be positive", ErrBadRequest) + } + if seen[rid] { + continue + } + seen[rid] = true + unique = append(unique, rid) + } + + if len(unique) < 2 { + return nil, fmt.Errorf("%w: a group DM needs at least 2 other users", ErrBadRequest) + } + if len(unique)+1 > db.MaxGroupDMParticipants { + return nil, fmt.Errorf("%w: a group DM holds at most %d users", ErrBadRequest, db.MaxGroupDMParticipants) + } + + cleanName := cleanText(name) + if utf8.RuneCountInString(cleanName) > MaxGroupDMNameLen { + return nil, fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, MaxGroupDMNameLen) + } + + for _, rid := range unique { + user, err := s.st.GetUserByID(ctx, rid) + if err != nil || user == nil { + return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) + } + blocked, err := s.st.IsEitherBlocked(ctx, userID, rid) + if err != nil { + return nil, fmt.Errorf("%w: failed to check block status: %v", ErrInternal, err) + } + if blocked { + return nil, fmt.Errorf("%w: cannot add a blocked user to a group DM", ErrForbidden) + } + } + + participantIDs := append([]int64{userID}, unique...) + ch, err := s.st.CreateGroupDMChannel(ctx, cleanName, participantIDs) + if err != nil { + slog.Error("DMService.CreateGroupDM", "err", err) + return nil, fmt.Errorf("%w: failed to create group DM", ErrInternal) + } + + participants, err := s.st.GetDMParticipants(ctx, ch.ID, userID) + if err != nil { + return nil, fmt.Errorf("%w: failed to read group DM participants: %v", ErrInternal, err) + } + + return &CreateGroupDMResult{ + Channel: ch, + Participants: participants, + ParticipantIDs: participantIDs, + }, nil +} + +// RenameGroupDM sets (or, with an empty name, clears) a group DM's name. +// +// Any participant may rename it, which is Discord's rule and the only one that +// works here: a group DM has no owner column and no roles, so "who may rename" +// has exactly one answer that does not require inventing an ownership model. +// A 1:1 DM refuses — its name is who is in it. +func (s *DMService) RenameGroupDM(ctx context.Context, userID, channelID int64, name string) (*db.Channel, error) { + if channelID <= 0 { + return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) + } + + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) + if err != nil || !ok { + return nil, fmt.Errorf("%w: not a participant in this DM", ErrNotFound) + } + + isGroup, err := s.st.IsGroupDM(ctx, channelID) + if err != nil { + return nil, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err) + } + if !isGroup { + return nil, fmt.Errorf("%w: only group DMs can be named", ErrBadRequest) + } + + cleanName := cleanText(name) + if utf8.RuneCountInString(cleanName) > MaxGroupDMNameLen { + return nil, fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, MaxGroupDMNameLen) + } + + if err := s.st.SetDMChannelName(ctx, channelID, cleanName); err != nil { + return nil, fmt.Errorf("%w: failed to rename group DM: %v", ErrInternal, err) + } + + ch, err := s.st.GetChannel(ctx, channelID) + if err != nil || ch == nil { + return nil, fmt.Errorf("%w: channel not found", ErrNotFound) + } + return ch, nil +} + +// DMSummaryFor returns one DM's payload shape as viewerID sees it: the group +// name, the participants other than them, and whether it is a group. +// +// Every push of a DM's membership — group create, rename, leave — goes through +// it, so the shape a client receives from an event is the same one it receives +// from GET /dms and from `ready`. Membership is checked here rather than by +// each caller. +func (s *DMService) DMSummaryFor(ctx context.Context, viewerID, channelID int64) (db.DMChannelInfo, error) { + ok, err := s.st.IsDMParticipant(ctx, viewerID, channelID) + if err != nil || !ok { + return db.DMChannelInfo{}, fmt.Errorf("%w: not a participant in this DM", ErrNotFound) + } + participants, err := s.st.GetDMParticipants(ctx, channelID, viewerID) + if err != nil { + return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err) + } + ch, err := s.st.GetChannel(ctx, channelID) + if err != nil || ch == nil { + return db.DMChannelInfo{}, fmt.Errorf("%w: channel not found", ErrNotFound) + } + isGroup, err := s.st.IsGroupDM(ctx, channelID) + if err != nil { + return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err) + } + return db.NewDMChannelInfo(channelID, ch.Name, isGroup, participants, viewerID), nil +} + +// RingTargets returns the other participants of a DM the caller is in — the +// people a call_ring or call_decline is addressed to. +// +// Ringing carries no state: a "call" in a DM *is* somebody being present in +// that DM's voice channel, and the ring is a nudge to come look. That is why +// this is a permission check and a fan-out list rather than a call record — +// there is nothing to persist that presence does not already say, and a +// persisted call would be one more thing that can be left dangling by a crash. +func (s *DMService) RingTargets(ctx context.Context, userID, channelID int64) ([]int64, error) { + if channelID <= 0 { + return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) + } + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) + if err != nil { + return nil, fmt.Errorf("%w: failed to check DM participation: %v", ErrInternal, err) + } + if !ok { + return nil, fmt.Errorf("%w: not a participant in this DM", ErrForbidden) + } + + ids, err := s.st.GetDMParticipantIDs(ctx, channelID) + if err != nil { + return nil, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err) + } + targets := make([]int64, 0, len(ids)) + for _, pid := range ids { + if pid != userID { + targets = append(targets, pid) + } + } + return targets, nil } diff --git a/Server/service/emoji.go b/Server/service/emoji.go new file mode 100644 index 00000000..16a53751 --- /dev/null +++ b/Server/service/emoji.go @@ -0,0 +1,192 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "strings" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// EmojiService owns the server-wide custom emoji set: who may change it, what +// a shortcode is allowed to look like, and the uniqueness rule. +// +// Emoji are deliberately gated on MANAGE_SERVER rather than on a new bit. +// Adding a permission bit is a schema-visible, forever decision, and "who may +// change server-wide branding" is exactly what MANAGE_SERVER already answers +// for the server name, icon and settings. Reading is not gated at all: an +// emoji nobody can render is not an emoji, and the set is server-wide with no +// per-channel scope to leak. +type EmojiService struct { + st Store + perms *PermissionService +} + +// NewEmojiService creates an EmojiService. +func NewEmojiService(st Store, perms *PermissionService) *EmojiService { + return &EmojiService{st: st, perms: perms} +} + +const ( + // MinShortcodeLen / MaxShortcodeLen bound `:name:`. Two characters is the + // shortest thing worth typing; 32 keeps a shortcode from dominating the + // composer, the picker row and the reaction pill it ends up in. + MinShortcodeLen = 2 + // MaxShortcodeLen is the upper bound on a shortcode's length. + MaxShortcodeLen = 32 + // MaxEmojiCount bounds the set. Every connected client holds the whole list + // in memory and re-receives it on every emoji_update, so this is the cap on + // what one MANAGE_SERVER holder can push to every session at once. + MaxEmojiCount = 200 +) + +// EmojiImageURL is the server-relative path the image bytes of an emoji are +// served from. Defined once here because three surfaces have to agree on it: +// the REST list response, the emoji_update broadcast, and the docs. +func EmojiImageURL(id int64) string { + return fmt.Sprintf("/api/v1/emoji/%d/image", id) +} + +// shortcodeRe is the exact spelling a shortcode may take. Lowercase only, so +// the table's plain UNIQUE index enforces case-insensitive uniqueness without +// a COLLATE change; no leading/trailing colon, because the colons are the +// delimiter and not part of the name. +var shortcodeRe = regexp.MustCompile(`^[a-z0-9_]{2,32}$`) + +// NormalizeShortcode strips the optional surrounding colons and lowercases the +// result, so `:WAVE:`, `WAVE` and `wave` are all the same shortcode. It does +// not validate -- ValidateShortcode does that on the normalized form. +func NormalizeShortcode(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + s = strings.TrimPrefix(s, ":") + s = strings.TrimSuffix(s, ":") + return s +} + +// ValidateShortcode normalizes and checks a shortcode, returning the canonical +// (lowercase, colon-free) form. +func ValidateShortcode(raw string) (string, error) { + s := NormalizeShortcode(raw) + if s == "" { + return "", fmt.Errorf("%w: shortcode is required", ErrBadRequest) + } + if !shortcodeRe.MatchString(s) { + return "", fmt.Errorf( + "%w: shortcode must be %d-%d characters of a-z, 0-9 or underscore", + ErrBadRequest, MinShortcodeLen, MaxShortcodeLen) + } + return s, nil +} + +// RequireManage reports whether actorID may add or remove emoji. Handlers call +// it BEFORE reading an upload body, so a user without the bit is refused +// without first being allowed to spend the server's disk on a multipart parse. +func (s *EmojiService) RequireManage(ctx context.Context, actorID int64) error { + if s.perms == nil { + return fmt.Errorf("%w: permission service unavailable", ErrForbidden) + } + role, err := s.perms.GetRoleForUser(ctx, actorID) + if err != nil || role == nil { + return fmt.Errorf("%w: failed to load actor role", ErrForbidden) + } + if !permissions.HasServerPerm(role.Permissions, permissions.ManageServer) { + return fmt.Errorf("%w: missing %s permission", ErrForbidden, permissions.Name(permissions.ManageServer)) + } + return nil +} + +// List returns every custom emoji, ordered by shortcode. Ungated. +func (s *EmojiService) List(ctx context.Context) ([]*db.Emoji, error) { + list, err := s.st.ListEmoji(ctx) + if err != nil { + return nil, fmt.Errorf("%w: failed to list emoji: %v", ErrInternal, err) + } + return list, nil +} + +// Create records an already-stored image as a custom emoji. The caller is +// responsible for having validated and stored the bytes; this owns the +// permission gate, the shortcode rules, the count cap and the audit entry. +// +// A shortcode collision is ErrConflict, not ErrBadRequest: the request was +// well-formed, the name is simply taken. Callers delete the orphaned file when +// this returns an error. +func (s *EmojiService) Create(ctx context.Context, actorID int64, rawShortcode, storedAs, mimeType string) (*db.Emoji, error) { + if err := s.RequireManage(ctx, actorID); err != nil { + return nil, err + } + shortcode, err := ValidateShortcode(rawShortcode) + if err != nil { + return nil, err + } + + existing, err := s.st.GetEmojiByShortcode(ctx, shortcode) + if err != nil { + return nil, fmt.Errorf("%w: failed to check shortcode: %v", ErrInternal, err) + } + if existing != nil { + return nil, fmt.Errorf("%w: an emoji named :%s: already exists", ErrConflict, shortcode) + } + + current, err := s.st.ListEmoji(ctx) + if err != nil { + return nil, fmt.Errorf("%w: failed to count emoji: %v", ErrInternal, err) + } + if len(current) >= MaxEmojiCount { + return nil, fmt.Errorf("%w: this server already has the maximum of %d emoji", ErrBadRequest, MaxEmojiCount) + } + + created, err := s.st.CreateEmoji(ctx, shortcode, storedAs, mimeType, actorID) + if err != nil { + return nil, fmt.Errorf("%w: failed to create emoji: %v", ErrInternal, err) + } + + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "emoji_create", "emoji", created.ID, + fmt.Sprintf("uploaded emoji :%s: (%s)", shortcode, mimeType)) + slog.Info("custom emoji created", "actor_id", actorID, "emoji_id", created.ID, "shortcode", shortcode) + return created, nil +} + +// Delete removes an emoji and returns the row that was removed, so the caller +// can unlink the stored file. The row is returned even though it no longer +// exists: the storage id is only knowable from it. +func (s *EmojiService) Delete(ctx context.Context, actorID, emojiID int64) (*db.Emoji, error) { + if err := s.RequireManage(ctx, actorID); err != nil { + return nil, err + } + existing, err := s.st.GetEmoji(ctx, emojiID) + if err != nil { + return nil, fmt.Errorf("%w: failed to load emoji: %v", ErrInternal, err) + } + if existing == nil { + return nil, fmt.Errorf("%w: emoji not found", ErrNotFound) + } + deleted, err := s.st.DeleteEmoji(ctx, emojiID) + if err != nil { + return nil, fmt.Errorf("%w: failed to delete emoji: %v", ErrInternal, err) + } + if !deleted { + // Lost a race with another delete -- report it as the 404 it now is. + return nil, fmt.Errorf("%w: emoji not found", ErrNotFound) + } + + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "emoji_delete", "emoji", emojiID, + fmt.Sprintf("deleted emoji :%s:", existing.Shortcode)) + slog.Info("custom emoji deleted", "actor_id", actorID, "emoji_id", emojiID, "shortcode", existing.Shortcode) + return existing, nil +} + +// Get returns one emoji by id, or ErrNotFound. Used by the image route. +func (s *EmojiService) Get(ctx context.Context, emojiID int64) (*db.Emoji, error) { + e, err := s.st.GetEmoji(ctx, emojiID) + if err != nil { + return nil, fmt.Errorf("%w: failed to load emoji: %v", ErrInternal, err) + } + if e == nil { + return nil, fmt.Errorf("%w: emoji not found", ErrNotFound) + } + return e, nil +} diff --git a/Server/service/emoji_fuzz_test.go b/Server/service/emoji_fuzz_test.go new file mode 100644 index 00000000..8808d616 --- /dev/null +++ b/Server/service/emoji_fuzz_test.go @@ -0,0 +1,62 @@ +package service + +import ( + "strings" + "testing" +) + +// FuzzValidateShortcode checks that ValidateShortcode never panics and that +// any shortcode it accepts is idempotent under re-validation and matches its +// own documented shape (^[a-z0-9_]{2,32}$). +func FuzzValidateShortcode(f *testing.F) { + seeds := []string{ + "", + "wave", + ":wave:", + " :WAVE: ", + "a", + "::", + strings.Repeat("x", MaxShortcodeLen), + strings.Repeat("x", MaxShortcodeLen+1), + strings.Repeat("x", 10000), + "has space", + "dash-not-allowed", + "dot.not.allowed", + "emojié", + "semi;colon", + "wave:extra:", + "", + "\x00\x00", + ":::::::", + "UPPER_lower_0123", + "\U0001F600\U0001F600", + "\u202ewave\u202e", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, raw string) { + got, err := ValidateShortcode(raw) + if err != nil { + if got != "" { + t.Fatalf("ValidateShortcode(%q) returned non-empty shortcode %q alongside error %v", raw, got, err) + } + return + } + + if !shortcodeRe.MatchString(got) { + t.Fatalf("ValidateShortcode(%q) accepted %q, which does not match %s", raw, got, shortcodeRe.String()) + } + + // Idempotence: re-validating an already-canonical shortcode must + // return it unchanged and must not suddenly start erroring. + again, err2 := ValidateShortcode(got) + if err2 != nil { + t.Fatalf("ValidateShortcode(%q) succeeded but re-validating its own output %q failed: %v", raw, got, err2) + } + if again != got { + t.Fatalf("ValidateShortcode is not idempotent: ValidateShortcode(%q) = %q, but ValidateShortcode(%q) = %q", raw, got, got, again) + } + }) +} diff --git a/Server/service/emoji_test.go b/Server/service/emoji_test.go new file mode 100644 index 00000000..b86e20e8 --- /dev/null +++ b/Server/service/emoji_test.go @@ -0,0 +1,313 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// newEmojiService builds an EmojiService over a hierarchy where exactly one +// non-admin role holds MANAGE_SERVER, so the gate can be shown to be that bit +// and not "any moderator": +// +// user 1 -> Owner (ADMINISTRATOR) +// user 2 -> Admin (MANAGE_SERVER, no ADMINISTRATOR) +// user 3 -> Moderator (MANAGE_ROLES + KICK_MEMBERS, NO MANAGE_SERVER) +// user 4 -> Member (default role) +func newEmojiService(t *testing.T) (*EmojiService, *db.DB) { + t.Helper() + database := newTestDB(t) + seedRole(t, database, &db.Role{ID: permissions.OwnerRoleID, Name: "Owner", + Permissions: permissions.Administrator, Position: permissions.OwnerRolePosition}) + seedRole(t, database, &db.Role{ID: permissions.AdminRoleID, Name: "Admin", + Permissions: permissions.ManageServer | permissions.ReadMessages, Position: 80}) + seedRole(t, database, &db.Role{ID: permissions.ModeratorRoleID, Name: "Moderator", + Permissions: permissions.ManageRoles | permissions.KickMembers | permissions.ReadMessages, Position: 60}) + for userID, roleID := range map[int64]int64{ + 1: permissions.OwnerRoleID, + 2: permissions.AdminRoleID, + 3: permissions.ModeratorRoleID, + 4: permissions.MemberRoleID, + } { + seedUser(t, database, &db.User{ID: userID}) + seedUserRole(t, database, userID, roleID) + } + checker := permissions.NewChecker(database) + return NewEmojiService(database, NewPermissionService(database, checker)), database +} + +// ─── Shortcode rules ───────────────────────────────────────────────────────── + +func TestValidateShortcode_Accepts(t *testing.T) { + cases := map[string]string{ + "wave": "wave", + ":wave:": "wave", + " :WAVE: ": "wave", + "PartyBlob": "partyblob", + "a1": "a1", + "under_score": "under_score", + "0123456789": "0123456789", + strings.Repeat("x", MaxShortcodeLen): strings.Repeat("x", MaxShortcodeLen), + } + for in, want := range cases { + got, err := ValidateShortcode(in) + if err != nil { + t.Errorf("ValidateShortcode(%q) = error %v, want %q", in, err, want) + continue + } + if got != want { + t.Errorf("ValidateShortcode(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValidateShortcode_Rejects(t *testing.T) { + bad := []string{ + "", // empty + "::", // colons only + "a", // one character + strings.Repeat("x", MaxShortcodeLen+1), // too long + "has space", + "dash-not-allowed", + "dot.not.allowed", + "emojié", + "semi;colon", + "wave:extra:", + "", + } + for _, in := range bad { + if got, err := ValidateShortcode(in); err == nil { + t.Errorf("ValidateShortcode(%q) = %q, want ErrBadRequest", in, got) + } else if !errors.Is(err, ErrBadRequest) { + t.Errorf("ValidateShortcode(%q) error = %v, want ErrBadRequest", in, err) + } + } +} + +func TestEmojiImageURL(t *testing.T) { + if got, want := EmojiImageURL(42), "/api/v1/emoji/42/image"; got != want { + t.Errorf("EmojiImageURL(42) = %q, want %q", got, want) + } +} + +// ─── Permission gate ───────────────────────────────────────────────────────── + +func TestEmojiCreate_RequiresManageServer(t *testing.T) { + svc, _ := newEmojiService(t) + + // Moderator holds MANAGE_ROLES and KICK_MEMBERS but not MANAGE_SERVER. + if _, err := svc.Create(context.Background(), 3, "wave", "stored-1", "image/png"); !errors.Is(err, ErrForbidden) { + t.Fatalf("moderator Create error = %v, want ErrForbidden", err) + } + if _, err := svc.Create(context.Background(), 4, "wave", "stored-1", "image/png"); !errors.Is(err, ErrForbidden) { + t.Fatalf("member Create error = %v, want ErrForbidden", err) + } + // MANAGE_SERVER without ADMINISTRATOR is enough. + if _, err := svc.Create(context.Background(), 2, "wave", "stored-1", "image/png"); err != nil { + t.Fatalf("admin Create: %v", err) + } + // ADMINISTRATOR bypasses the bit check. + if _, err := svc.Create(context.Background(), 1, "party", "stored-2", "image/gif"); err != nil { + t.Fatalf("owner Create: %v", err) + } +} + +func TestEmojiDelete_RequiresManageServer(t *testing.T) { + svc, _ := newEmojiService(t) + created, err := svc.Create(context.Background(), 1, "wave", "stored-1", "image/png") + if err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := svc.Delete(context.Background(), 4, created.ID); !errors.Is(err, ErrForbidden) { + t.Fatalf("member Delete error = %v, want ErrForbidden", err) + } + // The refusal must not have removed anything. + list, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 { + t.Fatalf("after refused delete len(list) = %d, want 1", len(list)) + } +} + +func TestEmojiRequireManage_UnknownUserIsForbidden(t *testing.T) { + svc, _ := newEmojiService(t) + if err := svc.RequireManage(context.Background(), 9999); !errors.Is(err, ErrForbidden) { + t.Fatalf("RequireManage(unknown) = %v, want ErrForbidden", err) + } +} + +func TestEmojiRequireManage_NilPermServiceIsForbidden(t *testing.T) { + database := newTestDB(t) + svc := NewEmojiService(database, nil) + if err := svc.RequireManage(context.Background(), 1); !errors.Is(err, ErrForbidden) { + t.Fatalf("RequireManage(nil perms) = %v, want ErrForbidden", err) + } +} + +// ─── Create / List / Delete ────────────────────────────────────────────────── + +func TestEmojiCreate_NormalizesAndPersists(t *testing.T) { + svc, database := newEmojiService(t) + + created, err := svc.Create(context.Background(), 1, ":WAVE:", "stored-1", "image/png") + if err != nil { + t.Fatalf("Create: %v", err) + } + if created.Shortcode != "wave" { + t.Errorf("Shortcode = %q, want %q", created.Shortcode, "wave") + } + if created.StoredAs != "stored-1" || created.MimeType != "image/png" { + t.Errorf("StoredAs/MimeType = %q/%q, want stored-1/image/png", created.StoredAs, created.MimeType) + } + if created.UploadedBy != 1 { + t.Errorf("UploadedBy = %d, want 1", created.UploadedBy) + } + + got, err := database.GetEmojiByShortcode(context.Background(), "wave") + if err != nil || got == nil { + t.Fatalf("GetEmojiByShortcode = %v, %v", got, err) + } + if got.ID != created.ID { + t.Errorf("round-tripped id = %d, want %d", got.ID, created.ID) + } +} + +func TestEmojiCreate_DuplicateShortcodeIsConflict(t *testing.T) { + svc, _ := newEmojiService(t) + if _, err := svc.Create(context.Background(), 1, "wave", "stored-1", "image/png"); err != nil { + t.Fatalf("first Create: %v", err) + } + // Different case, different file — same shortcode. + _, err := svc.Create(context.Background(), 1, ":WaVe:", "stored-2", "image/gif") + if !errors.Is(err, ErrConflict) { + t.Fatalf("duplicate Create error = %v, want ErrConflict", err) + } +} + +func TestEmojiCreate_RejectsBadShortcodeBeforeInsert(t *testing.T) { + svc, _ := newEmojiService(t) + if _, err := svc.Create(context.Background(), 1, "no spaces", "stored-1", "image/png"); !errors.Is(err, ErrBadRequest) { + t.Fatalf("Create error = %v, want ErrBadRequest", err) + } + list, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 0 { + t.Fatalf("len(list) = %d, want 0", len(list)) + } +} + +func TestEmojiCreate_EnforcesCountCap(t *testing.T) { + svc, database := newEmojiService(t) + // Insert the cap directly — going through Create MaxEmojiCount times is a + // slower way of arriving at the same state. + for i := range MaxEmojiCount { + if _, err := database.CreateEmoji(context.Background(), + fmt.Sprintf("e%d", i), fmt.Sprintf("stored-%d", i), "image/png", 1); err != nil { + t.Fatalf("seed emoji %d: %v", i, err) + } + } + _, err := svc.Create(context.Background(), 1, "onemore", "stored-x", "image/png") + if !errors.Is(err, ErrBadRequest) { + t.Fatalf("over-cap Create error = %v, want ErrBadRequest", err) + } +} + +func TestEmojiList_OrderedByShortcode(t *testing.T) { + svc, _ := newEmojiService(t) + for _, sc := range []string{"zebra", "apple", "mango"} { + if _, err := svc.Create(context.Background(), 1, sc, "stored-"+sc, "image/png"); err != nil { + t.Fatalf("Create(%s): %v", sc, err) + } + } + list, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + got := make([]string, 0, len(list)) + for _, e := range list { + got = append(got, e.Shortcode) + } + want := []string{"apple", "mango", "zebra"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("List order = %v, want %v", got, want) + } +} + +func TestEmojiDelete_RemovesAndReturnsStorageID(t *testing.T) { + svc, _ := newEmojiService(t) + created, err := svc.Create(context.Background(), 1, "wave", "stored-1", "image/png") + if err != nil { + t.Fatalf("Create: %v", err) + } + + removed, err := svc.Delete(context.Background(), 1, created.ID) + if err != nil { + t.Fatalf("Delete: %v", err) + } + if removed.StoredAs != "stored-1" { + t.Errorf("removed.StoredAs = %q, want stored-1", removed.StoredAs) + } + + list, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 0 { + t.Fatalf("len(list) after delete = %d, want 0", len(list)) + } + // The shortcode is free again. + if _, err := svc.Create(context.Background(), 1, "wave", "stored-2", "image/png"); err != nil { + t.Fatalf("re-Create after delete: %v", err) + } +} + +func TestEmojiDelete_UnknownIDIsNotFound(t *testing.T) { + svc, _ := newEmojiService(t) + if _, err := svc.Delete(context.Background(), 1, 4242); !errors.Is(err, ErrNotFound) { + t.Fatalf("Delete(unknown) = %v, want ErrNotFound", err) + } +} + +func TestEmojiGet_UnknownIDIsNotFound(t *testing.T) { + svc, _ := newEmojiService(t) + if _, err := svc.Get(context.Background(), 4242); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get(unknown) = %v, want ErrNotFound", err) + } +} + +func TestEmojiCreate_WritesAudit(t *testing.T) { + svc, database := newEmojiService(t) + created, err := svc.Create(context.Background(), 1, "wave", "stored-1", "image/png") + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := svc.Delete(context.Background(), 1, created.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + + entries, err := database.GetAuditLog(context.Background(), 50, 0) + if err != nil { + t.Fatalf("GetAuditLog: %v", err) + } + seen := map[string]bool{} + for _, e := range entries { + if e.TargetType == "emoji" { + seen[e.Action] = true + } + } + for _, action := range []string{"emoji_create", "emoji_delete"} { + if !seen[action] { + t.Errorf("no %s audit entry (saw %v)", action, seen) + } + } +} diff --git a/Server/service/mentions.go b/Server/service/mentions.go new file mode 100644 index 00000000..0b232894 --- /dev/null +++ b/Server/service/mentions.go @@ -0,0 +1,322 @@ +package service + +import ( + "context" + "log/slog" + "regexp" + "slices" + "strings" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// maxMentionsPerMessage caps how many usernames one message can resolve. +// Tokens past the cap stay plain text, bounding both the stored rows and the +// read-state writes a single send can trigger. +const maxMentionsPerMessage = 20 + +// maxMentionCandidates caps how many distinct @tokens are looked up per +// message, so a wall of @words cannot turn one send into an unbounded query. +const maxMentionCandidates = 60 + +// mentionTokenRe matches an @token that stands alone as a word. Group 1 is the +// preceding character, which must be start-of-string or a non-word non-@ rune +// ("mail@example" and "@@name" never resolve); group 2 is the token; group 3 +// captures a trailing "@" so address-shaped text like "@bob@example.com" is +// rejected as a whole rather than half-matched. The token charset is letters, +// digits, underscore, dot and hyphen — usernames may hold any printable rune, +// but only this subset is addressable without an explicit delimiter. +var mentionTokenRe = regexp.MustCompile(`(^|[^\p{L}\p{N}_@])@([\p{L}\p{N}_.-]{1,64})(@?)`) + +// everyoneToken and hereToken are reserved: a user literally named "everyone" +// is not resolvable by @everyone. +const ( + everyoneToken = "everyone" + hereToken = "here" +) + +// mentionSet is the resolved mention state of one message. +type mentionSet struct { + // UserIDs is ordered by first appearance in the content, deduplicated and + // capped at maxMentionsPerMessage. Never nil. + UserIDs []int64 + // Everyone reports an @everyone or @here that cleared the MENTION_EVERYONE + // gate. Without the bit the token keeps no mention semantics at all. + Everyone bool + // HereOnly narrows an Everyone fan-out to users who are not offline. It is + // set when @here matched and @everyone did not. + HereOnly bool +} + +// mentionCandidate is one @token and the username spellings it may resolve to, +// in preference order. Trailing sentence punctuation is stripped in the second +// spelling so "@bob." resolves to bob when no user is named "bob.". +type mentionCandidate struct { + spellings []string +} + +// parseMentionTokens extracts the @tokens of a message. Returned tokens are +// lowercased, distinct and ordered by first appearance; the reserved +// @everyone / @here tokens are reported separately and never resolved as +// usernames. +func parseMentionTokens(content string) (tokens []mentionCandidate, everyone, here bool) { + seen := make(map[string]struct{}) + for _, m := range mentionTokenRe.FindAllStringSubmatch(content, -1) { + if m[3] == "@" { + continue // address-shaped, e.g. "@bob@example.com" + } + raw := strings.ToLower(m[2]) + switch raw { + case everyoneToken: + everyone = true + continue + case hereToken: + here = true + continue + } + if _, dup := seen[raw]; dup { + continue + } + seen[raw] = struct{}{} + spellings := []string{raw} + if trimmed := strings.TrimRight(raw, ".-"); trimmed != "" && trimmed != raw { + spellings = append(spellings, trimmed) + } + tokens = append(tokens, mentionCandidate{spellings: spellings}) + if len(tokens) >= maxMentionCandidates { + break + } + } + return tokens, everyone, here +} + +// resolveMentions turns sanitized content into a mentionSet. Unknown @words +// resolve to nothing and stay plain text, and @everyone/@here without the +// MENTION_EVERYONE bit on this channel does the same. DM channels have no +// @everyone semantics — there is no permission surface to answer to. +// +// Resolution failures are logged and treated as "no mentions": a message must +// not be rejected because its mention lookup failed. +func (s *MessageService) resolveMentions(ctx context.Context, content string, authorID, channelID int64, isDM bool) mentionSet { + set := mentionSet{UserIDs: []int64{}} + + tokens, everyone, here := parseMentionTokens(content) + if (everyone || here) && !isDM && + s.perms.HasChannelPerm(ctx, authorID, channelID, permissions.ReadMessages|permissions.MentionEveryone) { + set.Everyone = true + set.HereOnly = here && !everyone + } + if len(tokens) == 0 { + return set + } + + lookup := make([]string, 0, len(tokens)*2) + for _, t := range tokens { + lookup = append(lookup, t.spellings...) + } + byName, err := s.st.GetUserIDsByUsernames(ctx, lookup) + if err != nil { + slog.Error("MessageService.resolveMentions GetUserIDsByUsernames", "err", err, "channel_id", channelID) + return set + } + + seen := make(map[int64]struct{}, len(tokens)) + for _, t := range tokens { + for _, spelling := range t.spellings { + id, ok := byName[spelling] + if !ok { + continue + } + if _, dup := seen[id]; !dup { + seen[id] = struct{}{} + set.UserIDs = append(set.UserIDs, id) + } + break + } + if len(set.UserIDs) >= maxMentionsPerMessage { + break + } + } + return set +} + +// applyMentionCounts increments read_states.mention_count for every user the +// message mentions who can see the channel, except the author and except users +// who have blocked the author. @everyone reaches every reader; @here reaches +// only readers who are not offline. +// +// Edits deliberately never call this: a mention that was already counted must +// not be counted twice, and the simplest rule that guarantees it is that only +// the original insert can raise a badge. +// +// The message is already committed by the time this runs, so failures are +// logged rather than surfaced — a lost badge must not fail a delivered send. +func (s *MessageService) applyMentionCounts(ctx context.Context, channelID, authorID int64, set mentionSet, isDM bool, participantIDs []int64) { + if len(set.UserIDs) == 0 && !set.Everyone { + return + } + + readers, err := s.mentionReaders(ctx, channelID, isDM, participantIDs) + if err != nil { + slog.Error("MessageService.applyMentionCounts readers", "err", err, "channel_id", channelID) + return + } + + recipients := make(map[int64]struct{}, len(readers)) + if set.Everyone { + for _, r := range readers { + // db.BroadcastStatus, not a bare == "offline": the column stores the + // status the user *chose*, so an invisible reader holds "invisible" + // here and a literal comparison would ping them with @here — the one + // thing "appear offline" is meant to stop. Collapsing first makes + // @here agree with what everyone else can see of that reader. + if set.HereOnly && db.BroadcastStatus(r.Status) == db.StatusOffline { + continue + } + recipients[r.UserID] = struct{}{} + } + } + if len(set.UserIDs) > 0 { + // Build the reader set once instead of scanning readers per mentioned + // uid: that nested loop was O(mentions x readers), which gets + // expensive on a channel with many readers. + readerIDs := make(map[int64]struct{}, len(readers)) + for _, r := range readers { + readerIDs[r.UserID] = struct{}{} + } + for _, uid := range set.UserIDs { + if _, ok := readerIDs[uid]; ok { + recipients[uid] = struct{}{} + } + } + } + delete(recipients, authorID) + if len(recipients) == 0 { + return + } + + blockers, err := s.st.ListBlockersOf(ctx, authorID) + if err != nil { + slog.Error("MessageService.applyMentionCounts ListBlockersOf", "err", err, "user_id", authorID) + return // Fail closed: a badge from a blocked user is worse than no badge. + } + for _, b := range blockers { + delete(recipients, b) + } + if len(recipients) == 0 { + return + } + + ids := make([]int64, 0, len(recipients)) + for id := range recipients { + ids = append(ids, id) + } + if err := s.st.IncrementMentionCounts(ctx, channelID, ids); err != nil { + slog.Error("MessageService.applyMentionCounts IncrementMentionCounts", "err", err, "channel_id", channelID) + } +} + +// mentionReaders lists the users who can read the channel, with the presence +// status @here filters on. DM participation is membership, not permissions, so +// DMs skip the role walk entirely. +func (s *MessageService) mentionReaders(ctx context.Context, channelID int64, isDM bool, participantIDs []int64) ([]db.MentionTarget, error) { + if isDM { + targets := make([]db.MentionTarget, 0, len(participantIDs)) + for _, pid := range participantIDs { + targets = append(targets, db.MentionTarget{UserID: pid}) + } + return targets, nil + } + + roles, err := s.st.ListRoles(ctx) + if err != nil { + return nil, err + } + overrides, err := s.st.GetChannelOverrides(ctx, channelID) + if err != nil { + return nil, err + } + + readRoles := make([]int64, 0, len(roles)) + adminRoles := make(map[int64]bool, len(roles)) + for _, r := range roles { + if r == nil { + continue + } + o := overrides[r.ID] + eff := permissions.EffectivePerms(r.Permissions, o.Allow, o.Deny) + if permissions.HasAdmin(r.Permissions) { + adminRoles[r.ID] = true + } + if permissions.HasAdmin(r.Permissions) || eff&permissions.ReadMessages != 0 { + readRoles = append(readRoles, r.ID) + } + } + targets, err := s.st.ListMentionTargetsByRoles(ctx, readRoles) + if err != nil { + return nil, err + } + return s.applyUserOverridesToReaders(ctx, channelID, targets, adminRoles) +} + +// applyUserOverridesToReaders folds the per-user override layer into a reader +// set the role walk produced. The layer is last in the resolution order, so it +// moves members in both directions: +// +// - a user DENY of READ_MESSAGES drops a member their role admitted (unless +// they hold ADMINISTRATOR, which bypasses every override), and +// - a user ALLOW of READ_MESSAGES adds a member their role excluded. +// +// A user allow also beats a user deny on the same bit, matching +// permissions.EffectiveChannelPerms. +func (s *MessageService) applyUserOverridesToReaders( + ctx context.Context, channelID int64, targets []db.MentionTarget, adminRoles map[int64]bool, +) ([]db.MentionTarget, error) { + userOv, err := s.st.GetChannelUserOverrides(ctx, channelID) + if err != nil { + return nil, err + } + if len(userOv) == 0 { + return targets, nil + } + + granted := make([]int64, 0, len(userOv)) + revoked := make(map[int64]bool, len(userOv)) + for uid, o := range userOv { + switch { + case o.UserAllow&permissions.ReadMessages != 0: + granted = append(granted, uid) + case o.UserDeny&permissions.ReadMessages != 0: + revoked[uid] = true + } + } + + kept := make([]db.MentionTarget, 0, len(targets)) + present := make(map[int64]bool, len(targets)) + for _, t := range targets { + if revoked[t.UserID] && !adminRoles[t.RoleID] { + continue + } + present[t.UserID] = true + kept = append(kept, t) + } + + missing := make([]int64, 0, len(granted)) + for _, uid := range granted { + if !present[uid] { + missing = append(missing, uid) + } + } + if len(missing) == 0 { + return kept, nil + } + // Deterministic order so the fan-out (and its tests) do not depend on map + // iteration order. + slices.Sort(missing) + added, err := s.st.ListMentionTargetsByUserIDs(ctx, missing) + if err != nil { + return nil, err + } + return append(kept, added...), nil +} diff --git a/Server/service/mentions_fuzz_test.go b/Server/service/mentions_fuzz_test.go new file mode 100644 index 00000000..f379e81a --- /dev/null +++ b/Server/service/mentions_fuzz_test.go @@ -0,0 +1,102 @@ +package service + +import ( + "regexp" + "strings" + "testing" +) + +// fuzzSpellingRe mirrors the token charset mentionTokenRe captures: letters, +// digits, underscore, dot and hyphen, 1-64 runes. Every candidate spelling +// parseMentionTokens returns must match it -- if it doesn't, the parser +// leaked something outside its own documented token shape. +var fuzzSpellingRe = regexp.MustCompile(`^[\p{L}\p{N}_.-]{1,64}$`) + +// FuzzParseMentionTokens shakes out panics and contract violations in +// parseMentionTokens: unicode, RTL overrides, combining marks, null bytes, +// pathological runs of "@", and address-shaped text that must never yield a +// bare mention. +func FuzzParseMentionTokens(f *testing.F) { + seeds := []string{ + "", + "@bob", + "@@bob", + "@@@", + "mail@example.com", + "a@b", + "@a@b@c", + "@bob@example.com", + "@everyone", + "@here", + "@EVERYONE", + "@Here", + strings.Repeat("@", 10000), + strings.Repeat("@a", 5000), + "@bob.", + "@bob-", + "@bob_baz.qux", + "\U0001F642@bob\U0001F642", // emoji either side of the token + " @bob ", + "cafe\u0301@bob", // combining acute accent (e + U+0301) + "\u202e@bob\u202e", // RTL override (U+202E) + "\u200f@bob", // RTL mark (U+200F) + "@\u0301", // bare combining mark as the token itself + "@bob\x00carol", // embedded NUL + strings.Repeat("a", 2000) + "@" + strings.Repeat("b", 2000), + "@" + strings.Repeat("x", 100), + "@" + strings.Repeat("x", 63) + "y" + strings.Repeat("z", 63), + "(@bob), @carol!", + "@" + strings.Repeat("\U0001F600", 100), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, content string) { + tokens, everyone, here := parseMentionTokens(content) + + if len(tokens) > maxMentionCandidates { + t.Fatalf("token count %d exceeds cap %d", len(tokens), maxMentionCandidates) + } + + seen := make(map[string]bool, len(tokens)) + for _, tok := range tokens { + if len(tok.spellings) == 0 { + t.Fatalf("candidate with zero spellings for content %q", content) + } + for _, sp := range tok.spellings { + if sp == "" { + t.Fatalf("empty spelling in candidate for content %q", content) + } + if strings.Contains(sp, "@") { + t.Fatalf("spelling %q retained an '@' -- address-shaped text leaked a mention (content %q)", sp, content) + } + if sp != strings.ToLower(sp) { + t.Fatalf("spelling %q is not lowercased", sp) + } + if !fuzzSpellingRe.MatchString(sp) { + t.Fatalf("spelling %q outside the token charset (content %q)", sp, content) + } + if sp == everyoneToken || sp == hereToken { + t.Fatalf("reserved token %q leaked into resolvable candidates", sp) + } + } + primary := tok.spellings[0] + if seen[primary] { + t.Fatalf("duplicate candidate %q returned (content %q)", primary, content) + } + seen[primary] = true + } + + // @everyone/@here flags must only ever be set for the literal reserved + // words; the substring check is a necessary (not sufficient) condition + // that still catches a parser gone wrong on unrelated input. + lower := strings.ToLower(content) + if everyone && !strings.Contains(lower, everyoneToken) { + t.Fatalf("everyone=true but content has no %q substring: %q", everyoneToken, content) + } + if here && !strings.Contains(lower, hereToken) { + t.Fatalf("here=true but content has no %q substring: %q", hereToken, content) + } + }) +} diff --git a/Server/service/mentions_test.go b/Server/service/mentions_test.go new file mode 100644 index 00000000..ef8da559 --- /dev/null +++ b/Server/service/mentions_test.go @@ -0,0 +1,543 @@ +package service + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// newMentionFixture builds a channel 10 with three members (alice=1 author, +// bob=2 online, carol=3 offline) plus a moderator (mod=4) holding +// MENTION_EVERYONE. Every role can read channel 10. +func newMentionFixture(t *testing.T) (*MessageService, *ChannelService, *db.DB) { + t.Helper() + database := newTestDB(t) + seedRole(t, database, &db.Role{ + ID: permissions.MemberRoleID, + Name: "member", + Permissions: permissions.SendMessages | permissions.ReadMessages, + Position: 1, + }) + seedRole(t, database, &db.Role{ + ID: permissions.ModeratorRoleID, + Name: "moderator", + Permissions: permissions.SendMessages | permissions.ReadMessages | + permissions.MentionEveryone, + Position: 60, + }) + seedUser(t, database, &db.User{ID: 1, Username: "alice", Status: "online"}) + seedUser(t, database, &db.User{ID: 2, Username: "Bob", Status: "online"}) + seedUser(t, database, &db.User{ID: 3, Username: "carol", Status: "offline"}) + seedUser(t, database, &db.User{ID: 4, Username: "mod", Status: "online"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedUserRole(t, database, 2, permissions.MemberRoleID) + seedUserRole(t, database, 3, permissions.MemberRoleID) + seedUserRole(t, database, 4, permissions.ModeratorRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) + + checker := permissions.NewChecker(database) + permSvc := NewPermissionService(database, checker) + msgSvc := NewMessageService(database, permSvc, nil) + // Mention counts are written on a background goroutine in production; run + // them inline here so the tests can read the counts right after a send. + msgSvc.RunBackgroundInlineForTest() + return msgSvc, NewChannelService(database, permSvc), database +} + +func sendAs(t *testing.T, svc *MessageService, userID int64, content string) *SendMessageResult { + t.Helper() + res, err := svc.SendMessage(context.Background(), SendMessageParams{ + ChannelID: 10, + UserID: userID, + Username: "user", + RoleName: "member", + Content: content, + }) + if err != nil { + t.Fatalf("SendMessage(%q): %v", content, err) + } + return res +} + +func mentionCount(t *testing.T, database *db.DB, userID int64) int { + t.Helper() + n, err := database.GetMentionCount(context.Background(), userID, 10) + if err != nil { + t.Fatalf("GetMentionCount(%d): %v", userID, err) + } + return n +} + +// ─── parsing ───────────────────────────────────────────────────────────────── + +func TestParseMentionTokens(t *testing.T) { + tests := []struct { + name string + content string + wantTokens []string + wantEveryone bool + wantHere bool + }{ + {name: "plain token", content: "hi @bob", wantTokens: []string{"bob"}}, + {name: "lowercased", content: "hi @BoB", wantTokens: []string{"bob"}}, + {name: "deduplicated", content: "@bob @bob @carol", wantTokens: []string{"bob", "carol"}}, + {name: "no token", content: "no mentions here", wantTokens: nil}, + {name: "email is not a mention", content: "write to bob@example.com", wantTokens: nil}, + {name: "double at is not a mention", content: "@@bob", wantTokens: nil}, + {name: "punctuation delimits", content: "(@bob), @carol!", wantTokens: []string{"bob", "carol"}}, + {name: "everyone reserved", content: "@everyone hi", wantEveryone: true}, + {name: "here reserved", content: "@here hi", wantHere: true}, + {name: "case-insensitive reserved", content: "@EVERYONE", wantEveryone: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokens, everyone, here := parseMentionTokens(tt.content) + var got []string + for _, tok := range tokens { + got = append(got, tok.spellings[0]) + } + if strings.Join(got, ",") != strings.Join(tt.wantTokens, ",") { + t.Errorf("tokens = %v, want %v", got, tt.wantTokens) + } + if everyone != tt.wantEveryone { + t.Errorf("everyone = %v, want %v", everyone, tt.wantEveryone) + } + if here != tt.wantHere { + t.Errorf("here = %v, want %v", here, tt.wantHere) + } + }) + } +} + +// TestParseMentionTokens_TrailingPunctuationSpelling locks the fallback that +// makes "@bob." resolve to bob when no user is literally named "bob.". +func TestParseMentionTokens_TrailingPunctuationSpelling(t *testing.T) { + tokens, _, _ := parseMentionTokens("thanks @bob.") + if len(tokens) != 1 { + t.Fatalf("tokens = %d, want 1", len(tokens)) + } + if got := tokens[0].spellings; len(got) != 2 || got[0] != "bob." || got[1] != "bob" { + t.Errorf("spellings = %v, want [bob. bob]", got) + } +} + +func TestParseMentionTokens_CandidateCap(t *testing.T) { + var sb strings.Builder + for i := range maxMentionCandidates + 20 { + fmt.Fprintf(&sb, "@user%d ", i) + } + tokens, _, _ := parseMentionTokens(sb.String()) + if len(tokens) != maxMentionCandidates { + t.Errorf("tokens = %d, want %d", len(tokens), maxMentionCandidates) + } +} + +// ─── resolution on the send path ───────────────────────────────────────────── + +func TestSendMessage_ResolvesKnownUsername(t *testing.T) { + svc, _, database := newMentionFixture(t) + + res := sendAs(t, svc, 1, "hey @Bob, look at this") + if len(res.Mentions) != 1 || res.Mentions[0] != 2 { + t.Fatalf("mentions = %v, want [2]", res.Mentions) + } + if res.MentionsEveryone { + t.Error("mentions_everyone should be false") + } + + stored, err := database.GetMentionsByMessageIDs(context.Background(), []int64{res.MessageID}) + if err != nil { + t.Fatalf("GetMentionsByMessageIDs: %v", err) + } + if len(stored[res.MessageID]) != 1 || stored[res.MessageID][0] != 2 { + t.Errorf("stored mentions = %v, want [2]", stored[res.MessageID]) + } +} + +func TestSendMessage_CaseInsensitiveUsername(t *testing.T) { + svc, _, _ := newMentionFixture(t) + + res := sendAs(t, svc, 1, "hi @bOB") + if len(res.Mentions) != 1 || res.Mentions[0] != 2 { + t.Fatalf("mentions = %v, want [2]", res.Mentions) + } +} + +func TestSendMessage_UnknownWordStaysText(t *testing.T) { + svc, _, database := newMentionFixture(t) + + res := sendAs(t, svc, 1, "@nobody @bob@example.com hello") + if len(res.Mentions) != 0 { + t.Fatalf("mentions = %v, want none", res.Mentions) + } + if res.Content != "@nobody @bob@example.com hello" { + t.Errorf("content was rewritten: %q", res.Content) + } + stored, err := database.GetMentionsByMessageIDs(context.Background(), []int64{res.MessageID}) + if err != nil { + t.Fatalf("GetMentionsByMessageIDs: %v", err) + } + if len(stored) != 0 { + t.Errorf("stored mentions = %v, want none", stored) + } +} + +func TestSendMessage_MentionCapped(t *testing.T) { + svc, _, database := newMentionFixture(t) + + // 25 distinct mentionable users, all readers of channel 10. + var sb strings.Builder + for i := range 25 { + id := int64(100 + i) + name := fmt.Sprintf("capuser%d", i) + seedUser(t, database, &db.User{ID: id, Username: name, Status: "online"}) + seedUserRole(t, database, id, permissions.MemberRoleID) + sb.WriteString("@" + name + " ") + } + + res := sendAs(t, svc, 1, sb.String()) + if len(res.Mentions) != maxMentionsPerMessage { + t.Fatalf("mentions = %d, want %d", len(res.Mentions), maxMentionsPerMessage) + } + stored, err := database.GetMentionsByMessageIDs(context.Background(), []int64{res.MessageID}) + if err != nil { + t.Fatalf("GetMentionsByMessageIDs: %v", err) + } + if len(stored[res.MessageID]) != maxMentionsPerMessage { + t.Errorf("stored = %d, want %d", len(stored[res.MessageID]), maxMentionsPerMessage) + } +} + +// ─── @everyone / @here permission gate ─────────────────────────────────────── + +func TestSendMessage_EveryoneRequiresPermission(t *testing.T) { + svc, _, database := newMentionFixture(t) + + res := sendAs(t, svc, 1, "@everyone stand up") // alice is a plain member + if res.MentionsEveryone { + t.Fatal("@everyone without MENTION_EVERYONE must not gain mention semantics") + } + if got := mentionCount(t, database, 2); got != 0 { + t.Errorf("bob mention_count = %d, want 0", got) + } + + res = sendAs(t, svc, 4, "@everyone stand up") // mod holds the bit + if !res.MentionsEveryone { + t.Fatal("@everyone with MENTION_EVERYONE must be honored") + } +} + +func TestSendMessage_EveryoneCountsEveryReaderButAuthor(t *testing.T) { + svc, _, database := newMentionFixture(t) + + sendAs(t, svc, 4, "@everyone meeting now") + for _, uid := range []int64{1, 2, 3} { + if got := mentionCount(t, database, uid); got != 1 { + t.Errorf("user %d mention_count = %d, want 1", uid, got) + } + } + if got := mentionCount(t, database, 4); got != 0 { + t.Errorf("author mention_count = %d, want 0", got) + } +} + +func TestSendMessage_HereSkipsOfflineUsers(t *testing.T) { + svc, _, database := newMentionFixture(t) + + sendAs(t, svc, 4, "@here quick question") + if got := mentionCount(t, database, 2); got != 1 { + t.Errorf("online bob mention_count = %d, want 1", got) + } + if got := mentionCount(t, database, 3); got != 0 { + t.Errorf("offline carol mention_count = %d, want 0", got) + } +} + +// TestSendMessage_HereSkipsInvisibleUsers locks the phase-6 half of the @here +// rule. users.status stores the status the user *chose*, so an invisible reader +// holds the literal "invisible" here — a bare == "offline" test would ping them, +// which is the one thing "appear offline" exists to prevent. The fan-out has to +// collapse through db.BroadcastStatus first, so @here agrees with what everyone +// else can see of that reader. +func TestSendMessage_HereSkipsInvisibleUsers(t *testing.T) { + svc, _, database := newMentionFixture(t) + + if err := database.UpdateUserStatus(context.Background(), 2, db.StatusInvisible); err != nil { + t.Fatalf("UpdateUserStatus(invisible): %v", err) + } + + sendAs(t, svc, 4, "@here quick question") + if got := mentionCount(t, database, 2); got != 0 { + t.Errorf("invisible bob mention_count = %d, want 0", got) + } + // A plain @everyone still reaches them: only @here narrows on presence. + sendAs(t, svc, 4, "@everyone meeting now") + if got := mentionCount(t, database, 2); got != 1 { + t.Errorf("invisible bob @everyone mention_count = %d, want 1", got) + } +} + +// TestSendMessage_EveryoneSkipsUsersWithoutRead locks that the @everyone +// fan-out honors per-channel denies, not just the base role mask. +func TestSendMessage_EveryoneSkipsUsersWithoutRead(t *testing.T) { + svc, _, database := newMentionFixture(t) + seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.ReadMessages) + + sendAs(t, svc, 4, "@everyone private notice") + for _, uid := range []int64{1, 2, 3} { + if got := mentionCount(t, database, uid); got != 0 { + t.Errorf("denied user %d mention_count = %d, want 0", uid, got) + } + } +} + +// TestSendMessage_EveryoneHonorsUserOverrides locks the per-user layer in the +// @everyone fan-out: it is the last layer of the resolution order, so it must +// both DROP a reader the role admitted and ADD one the role excluded. +func TestSendMessage_EveryoneHonorsUserOverrides(t *testing.T) { + svc, _, database := newMentionFixture(t) + // The role cannot read the channel at all... + seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.ReadMessages) + // ...but bob is individually granted READ back. + if err := database.UpsertChannelUserOverride(context.Background(), 10, 2, permissions.ReadMessages, 0); err != nil { + t.Fatalf("UpsertChannelUserOverride bob: %v", err) + } + + sendAs(t, svc, 4, "@everyone notice") + if got := mentionCount(t, database, 2); got != 1 { + t.Errorf("bob (user allow) mention_count = %d, want 1", got) + } + if got := mentionCount(t, database, 3); got != 0 { + t.Errorf("carol (no override) mention_count = %d, want 0", got) + } +} + +func TestSendMessage_EveryoneSkipsUserDenied(t *testing.T) { + svc, _, database := newMentionFixture(t) + // carol alone is denied READ on a channel her role can read. + if err := database.UpsertChannelUserOverride(context.Background(), 10, 3, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelUserOverride carol: %v", err) + } + + sendAs(t, svc, 4, "@everyone notice") + if got := mentionCount(t, database, 2); got != 1 { + t.Errorf("bob mention_count = %d, want 1", got) + } + if got := mentionCount(t, database, 3); got != 0 { + t.Errorf("carol (user deny) mention_count = %d, want 0", got) + } +} + +// A direct @mention of a user the channel's per-user deny excludes must not +// raise their badge either — mentionReaders is the single gate behind both. +func TestSendMessage_DirectMentionSkipsUserDenied(t *testing.T) { + svc, _, database := newMentionFixture(t) + if err := database.UpsertChannelUserOverride(context.Background(), 10, 2, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelUserOverride bob: %v", err) + } + + sendAs(t, svc, 1, "@bob ping") + if got := mentionCount(t, database, 2); got != 0 { + t.Errorf("user-denied bob mention_count = %d, want 0", got) + } +} + +// A single message mentioning several users at once must resolve each one +// independently against the reader set: readers get counted, a non-reader +// (denied READ_MESSAGES via a per-user override) does not — the case the +// mentioned-uid x reader lookup has to get right regardless of how it is +// implemented internally (loop or map). +func TestSendMessage_MultipleDirectMentionsResolveIndependently(t *testing.T) { + svc, _, database := newMentionFixture(t) + if err := database.UpsertChannelUserOverride(context.Background(), 10, 3, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelUserOverride carol: %v", err) + } + + sendAs(t, svc, 1, "@bob @carol @mod hi") + + if got := mentionCount(t, database, 2); got != 1 { + t.Errorf("bob (reader) mention_count = %d, want 1", got) + } + if got := mentionCount(t, database, 3); got != 0 { + t.Errorf("carol (denied read) mention_count = %d, want 0", got) + } + if got := mentionCount(t, database, 4); got != 1 { + t.Errorf("mod (reader) mention_count = %d, want 1", got) + } +} + +// ─── mention counts ────────────────────────────────────────────────────────── + +func TestSendMessage_DirectMentionIncrementsCount(t *testing.T) { + svc, _, database := newMentionFixture(t) + + sendAs(t, svc, 1, "@bob ping") + sendAs(t, svc, 1, "@bob again") + if got := mentionCount(t, database, 2); got != 2 { + t.Errorf("bob mention_count = %d, want 2", got) + } + if got := mentionCount(t, database, 3); got != 0 { + t.Errorf("uninvolved carol mention_count = %d, want 0", got) + } +} + +// TestSendMessage_MentionCountsWrittenInBackground exercises the default async +// path: the fixture normally forces the inline seam, so here we restore the +// real `go fn()` dispatcher and confirm the count still lands shortly after +// SendMessage returns. +func TestSendMessage_MentionCountsWrittenInBackground(t *testing.T) { + svc, _, database := newMentionFixture(t) + // Undo the fixture's inline seam so bg is the production `go fn()` again. + svc.bg = func(fn func()) { go fn() } + + sendAs(t, svc, 1, "@Bob ping") + + // The write is on a goroutine; poll briefly rather than assuming timing. + const deadline = 2 * time.Second + var got int + for waited := time.Duration(0); waited < deadline; waited += 10 * time.Millisecond { + if got = mentionCount(t, database, 2); got == 1 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("bob mention_count = %d after %s, want 1 (background write never landed)", got, deadline) +} + +func TestSendMessage_SelfMentionDoesNotCount(t *testing.T) { + svc, _, database := newMentionFixture(t) + + sendAs(t, svc, 2, "note to self @bob") + if got := mentionCount(t, database, 2); got != 0 { + t.Errorf("self-mention mention_count = %d, want 0", got) + } +} + +func TestSendMessage_BlockedAuthorDoesNotRaiseBadge(t *testing.T) { + svc, _, database := newMentionFixture(t) + seedBlock(t, database, 2, 1) // bob blocked alice + + sendAs(t, svc, 1, "@bob @carol hello") + if got := mentionCount(t, database, 2); got != 0 { + t.Errorf("blocker mention_count = %d, want 0", got) + } + if got := mentionCount(t, database, 3); got != 1 { + t.Errorf("carol mention_count = %d, want 1", got) + } +} + +func TestChannelFocus_ClearsMentionCount(t *testing.T) { + msgSvc, chanSvc, database := newMentionFixture(t) + + sendAs(t, msgSvc, 1, "@bob look") + if got := mentionCount(t, database, 2); got != 1 { + t.Fatalf("setup: bob mention_count = %d, want 1", got) + } + + if _, err := chanSvc.HandleChannelFocus(context.Background(), 2, 10); err != nil { + t.Fatalf("HandleChannelFocus: %v", err) + } + if got := mentionCount(t, database, 2); got != 0 { + t.Errorf("after focus mention_count = %d, want 0", got) + } +} + +// ─── edits ─────────────────────────────────────────────────────────────────── + +func TestEditMessage_ReplacesMentionsWithoutRecounting(t *testing.T) { + svc, _, database := newMentionFixture(t) + + res := sendAs(t, svc, 1, "@bob first") + if got := mentionCount(t, database, 2); got != 1 { + t.Fatalf("setup: bob mention_count = %d, want 1", got) + } + + edited, err := svc.EditMessage(context.Background(), 1, res.MessageID, "@carol instead") + if err != nil { + t.Fatalf("EditMessage: %v", err) + } + if len(edited.Mentions) != 1 || edited.Mentions[0] != 3 { + t.Fatalf("edited mentions = %v, want [3]", edited.Mentions) + } + + stored, err := database.GetMentionsByMessageIDs(context.Background(), []int64{res.MessageID}) + if err != nil { + t.Fatalf("GetMentionsByMessageIDs: %v", err) + } + if len(stored[res.MessageID]) != 1 || stored[res.MessageID][0] != 3 { + t.Errorf("stored mentions = %v, want [3]", stored[res.MessageID]) + } + + // Edits never advance badges — bob keeps his one, carol gains none. + if got := mentionCount(t, database, 2); got != 1 { + t.Errorf("bob mention_count = %d, want 1", got) + } + if got := mentionCount(t, database, 3); got != 0 { + t.Errorf("carol mention_count = %d, want 0 (edits never increment)", got) + } +} + +func TestEditMessage_EveryoneGateApplies(t *testing.T) { + svc, _, database := newMentionFixture(t) + + res := sendAs(t, svc, 1, "plain text") + if _, err := svc.EditMessage(context.Background(), 1, res.MessageID, "@everyone actually"); err != nil { + t.Fatalf("EditMessage: %v", err) + } + msg, err := database.GetMessage(context.Background(), res.MessageID) + if err != nil || msg == nil { + t.Fatalf("GetMessage: %v", err) + } + if msg.MentionsEveryone { + t.Error("edit by a member must not set mentions_everyone") + } +} + +// ─── read-state fan-out ────────────────────────────────────────────────────── + +func TestGetChannelUnreadCounts_CarriesMentionCount(t *testing.T) { + svc, _, database := newMentionFixture(t) + + sendAs(t, svc, 1, "@bob check the ready payload") + counts, err := database.GetChannelUnreadCounts(context.Background(), 2) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + got, ok := counts[10] + if !ok { + t.Fatal("channel 10 missing from unread counts") + } + if got.MentionCount != 1 { + t.Errorf("mention_count = %d, want 1", got.MentionCount) + } + if got.UnreadCount != 1 { + t.Errorf("unread_count = %d, want 1", got.UnreadCount) + } +} + +// TestSendMessage_DMMentionsResolve locks that DMs resolve usernames but never +// gain @everyone semantics — there is no permission surface behind a DM. +func TestSendMessage_DMMentionsResolve(t *testing.T) { + svc, _, database := newMentionFixture(t) + seedChannel(t, database, &db.Channel{ID: 50, Name: "dm-1-2", Type: "dm"}) + seedDMParticipant(t, database, 50, 1) + seedDMParticipant(t, database, 50, 2) + + res, err := svc.SendMessage(context.Background(), SendMessageParams{ + ChannelID: 50, UserID: 1, Username: "alice", Content: "@bob @everyone hi", + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if len(res.Mentions) != 1 || res.Mentions[0] != 2 { + t.Errorf("mentions = %v, want [2]", res.Mentions) + } + if res.MentionsEveryone { + t.Error("DM must not honor @everyone") + } +} diff --git a/Server/service/message.go b/Server/service/message.go index 999a7b7b..3cf55c2e 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -53,9 +53,27 @@ type SendMessageResult struct { ParticipantIDs []int64 SenderUser *db.User // for dm_channel_open events OpenedDMFor []int64 // participant IDs that had their DM opened + // DMParticipants is the full participant list of the DM, viewer-neutral: + // it is read with viewerID 0, which matches nobody, so every status is + // already broadcast-collapsed (an invisible participant reads as offline). + // That is what makes it safe to reuse for every addressee — the ws layer + // turns it into a per-recipient dm_channel_open payload without re-deriving + // visibility. Nil when the participant read failed, in which case the + // caller falls back to the sender-only 1:1 shape. + DMParticipants []db.DMUser + // DMIsGroup mirrors channels.is_group for this DM. Carried alongside the + // participants because a group that people have left can have two members + // and must still render as a group. + DMIsGroup bool // Attachment data for broadcast. Attachments []db.AttachmentInfo + + // Mentions is the resolved mentioned user ids (never nil) and + // MentionsEveryone an authorized @everyone/@here. Both are broadcast so + // clients highlight from server-resolved data instead of re-guessing. + Mentions []int64 + MentionsEveryone bool } // EditMessageResult contains the output of a successful message edit. @@ -67,6 +85,10 @@ type EditMessageResult struct { IsDM bool // DM-specific. ParticipantIDs []int64 + + // Mentions/MentionsEveryone are re-resolved from the edited content. + Mentions []int64 + MentionsEveryone bool } // DeleteMessageResult contains the output of a successful message delete. @@ -79,6 +101,13 @@ type DeleteMessageResult struct { ParticipantIDs []int64 } +// PurgeMessagesResult contains the output of a successful bulk delete. +// MessageIDs is newest-first and is empty (never nil) when nothing matched. +type PurgeMessagesResult struct { + ChannelID int64 + MessageIDs []int64 +} + // ReactionResult contains the output of a reaction add/remove. type ReactionResult struct { MessageID int64 @@ -97,6 +126,12 @@ type MessageService struct { st Store perms *PermissionService limiter *auth.RateLimiter + // bg runs mention-badge bookkeeping off the send path so a mention or + // @everyone message does not wait on the full reader-resolution chain + // before it is delivered to the rest of the channel. Defaults to `go fn()`; + // tests swap it for an inline runner via RunBackgroundInlineForTest so they + // can read the counts deterministically right after a send. + bg func(fn func()) } // NewMessageService creates a MessageService. @@ -105,9 +140,18 @@ func NewMessageService(st Store, perms *PermissionService, limiter *auth.RateLim st: st, perms: perms, limiter: limiter, + bg: func(fn func()) { go fn() }, } } +// RunBackgroundInlineForTest makes deferred bookkeeping (mention counts) run +// synchronously on the calling goroutine instead of in a background goroutine, +// so tests can assert on the results immediately after SendMessage returns. +// Test-only. +func (s *MessageService) RunBackgroundInlineForTest() { + s.bg = func(fn func()) { fn() } +} + // sanitizeContent validates and sanitizes message content. func sanitizeContent(raw string, allowEmpty bool) (string, error) { if len(raw) > maxMessageLen*4 { diff --git a/Server/service/message_around_test.go b/Server/service/message_around_test.go new file mode 100644 index 00000000..fde268d6 --- /dev/null +++ b/Server/service/message_around_test.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/owncord/server/db" +) + +// seedAroundHistory fills channelID with n messages authored by user 1 and +// returns their ids oldest-first. +func seedAroundHistory(t *testing.T, database *db.DB, channelID int64, n int) []int64 { + t.Helper() + ids := make([]int64, 0, n) + for range n { + id, err := database.CreateMessage(context.Background(), channelID, 1, "history", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + ids = append(ids, id) + } + return ids +} + +func TestGetMessagesAround_SplitsTheLimitAroundTheCentre(t *testing.T) { + svc, database := newTestMessageService(t) + ids := seedAroundHistory(t, database, 10, 40) + + window, err := svc.GetMessagesAround(context.Background(), 1, 10, ids[20], 11) + if err != nil { + t.Fatalf("GetMessagesAround: %v", err) + } + if len(window.Messages) != 11 { + t.Fatalf("window size = %d, want 11", len(window.Messages)) + } + // limit 11 → 5 older, centre, 5 newer. + if window.Messages[5].ID != ids[20] { + t.Errorf("centre at index 5 = %d, want %d", window.Messages[5].ID, ids[20]) + } + if !window.HasMoreBefore || !window.HasMoreAfter { + t.Errorf("HasMoreBefore = %v, HasMoreAfter = %v; want both true", + window.HasMoreBefore, window.HasMoreAfter) + } +} + +func TestGetMessagesAround_EdgesReportNoMore(t *testing.T) { + svc, database := newTestMessageService(t) + ids := seedAroundHistory(t, database, 10, 30) + + first, err := svc.GetMessagesAround(context.Background(), 1, 10, ids[0], 10) + if err != nil { + t.Fatalf("GetMessagesAround(first): %v", err) + } + if first.HasMoreBefore { + t.Error("HasMoreBefore = true at the oldest message") + } + if !first.HasMoreAfter { + t.Error("HasMoreAfter = false with 29 newer messages") + } + + last, err := svc.GetMessagesAround(context.Background(), 1, 10, ids[len(ids)-1], 10) + if err != nil { + t.Fatalf("GetMessagesAround(last): %v", err) + } + if last.HasMoreAfter { + t.Error("HasMoreAfter = true at the newest message") + } + if !last.HasMoreBefore { + t.Error("HasMoreBefore = false with 29 older messages") + } +} + +func TestGetMessagesAround_ExactFitReportsNoMore(t *testing.T) { + svc, database := newTestMessageService(t) + // limit 5 → 2 older + centre + 2 newer, and the channel holds exactly that. + ids := seedAroundHistory(t, database, 10, 5) + + window, err := svc.GetMessagesAround(context.Background(), 1, 10, ids[2], 5) + if err != nil { + t.Fatalf("GetMessagesAround: %v", err) + } + if len(window.Messages) != 5 { + t.Fatalf("window size = %d, want 5", len(window.Messages)) + } + if window.HasMoreBefore || window.HasMoreAfter { + t.Errorf("HasMoreBefore = %v, HasMoreAfter = %v; a window that exactly covers the channel has no more", + window.HasMoreBefore, window.HasMoreAfter) + } +} + +func TestGetMessagesAround_ClampsLimit(t *testing.T) { + svc, database := newTestMessageService(t) + ids := seedAroundHistory(t, database, 10, 150) + + window, err := svc.GetMessagesAround(context.Background(), 1, 10, ids[75], 5000) + if err != nil { + t.Fatalf("GetMessagesAround: %v", err) + } + if len(window.Messages) != 100 { + t.Errorf("window size = %d, want the 100 cap", len(window.Messages)) + } + + // A zero/negative limit falls back to the 50 default rather than returning + // an empty window. + window, err = svc.GetMessagesAround(context.Background(), 1, 10, ids[75], 0) + if err != nil { + t.Fatalf("GetMessagesAround(limit=0): %v", err) + } + if len(window.Messages) != 50 { + t.Errorf("default window size = %d, want 50", len(window.Messages)) + } +} + +func TestGetMessagesAround_RejectsBadIDs(t *testing.T) { + svc, database := newTestMessageService(t) + ids := seedAroundHistory(t, database, 10, 3) + + if _, err := svc.GetMessagesAround(context.Background(), 1, 10, 0, 50); !errors.Is(err, ErrBadRequest) { + t.Errorf("message_id 0 error = %v, want ErrBadRequest", err) + } + if _, err := svc.GetMessagesAround(context.Background(), 1, 0, ids[0], 50); !errors.Is(err, ErrBadRequest) { + t.Errorf("channel_id 0 error = %v, want ErrBadRequest", err) + } + if _, err := svc.GetMessagesAround(context.Background(), 1, 999, ids[0], 50); !errors.Is(err, ErrNotFound) { + t.Errorf("unknown channel error = %v, want ErrNotFound", err) + } +} + +func TestGetMessagesAround_MessageFromAnotherChannelIsNotFound(t *testing.T) { + svc, database := newTestMessageService(t) + seedChannel(t, database, &db.Channel{ID: 12, Name: "other", Type: "text"}) + otherIDs := seedAroundHistory(t, database, 12, 2) + + _, err := svc.GetMessagesAround(context.Background(), 1, 10, otherIDs[0], 50) + if !errors.Is(err, ErrNotFound) { + t.Errorf("cross-channel centre error = %v, want ErrNotFound", err) + } +} + +func TestGetMessagesAround_DeletedCentreIsNotFound(t *testing.T) { + svc, database := newTestMessageService(t) + ids := seedAroundHistory(t, database, 10, 3) + if err := database.DeleteMessage(context.Background(), ids[1], 1, false); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + + // History omits soft-deleted rows, so there is no row to centre on — the + // jump must fail loudly rather than land on an arbitrary neighbour. + _, err := svc.GetMessagesAround(context.Background(), 1, 10, ids[1], 50) + if !errors.Is(err, ErrNotFound) { + t.Errorf("deleted centre error = %v, want ErrNotFound", err) + } +} + +func TestGetMessagesAround_DMNonParticipantIsNotFound(t *testing.T) { + svc, database := newTestMessageService(t) + seedUser(t, database, &db.User{ID: 2, Username: "bob", Status: "online"}) + seedUser(t, database, &db.User{ID: 3, Username: "outsider", Status: "offline"}) + seedChannel(t, database, &db.Channel{ID: 13, Name: "dm", Type: "dm"}) + seedDMParticipant(t, database, 13, 1) + seedDMParticipant(t, database, 13, 2) + msgID, err := database.CreateMessage(context.Background(), 13, 1, "private", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + if _, err := svc.GetMessagesAround(context.Background(), 3, 13, msgID, 50); !errors.Is(err, ErrNotFound) { + t.Errorf("outsider error = %v, want ErrNotFound", err) + } + window, err := svc.GetMessagesAround(context.Background(), 1, 13, msgID, 50) + if err != nil { + t.Fatalf("participant GetMessagesAround: %v", err) + } + if len(window.Messages) != 1 { + t.Errorf("participant window size = %d, want 1", len(window.Messages)) + } +} diff --git a/Server/service/message_crud.go b/Server/service/message_crud.go index 11d3efff..67281fb0 100644 --- a/Server/service/message_crud.go +++ b/Server/service/message_crud.go @@ -71,9 +71,15 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } } + // Resolve mentions against the sanitized content, before the insert, so the + // row and its mention set are written together. Unknown @words and an + // unauthorized @everyone resolve to nothing and stay plain text. + mentions := s.resolveMentions(ctx, content, p.UserID, p.ChannelID, isDM) + // Persist message. RETURNING hands back the inserted row, so the DB-assigned // timestamp the fan-out needs arrives with the insert instead of a re-read. - msg, err := s.st.CreateMessageReturning(ctx, p.ChannelID, p.UserID, content, p.ReplyTo) + msg, err := s.st.CreateMessageWithMentions(ctx, p.ChannelID, p.UserID, content, p.ReplyTo, + mentions.UserIDs, mentions.Everyone) if err != nil { slog.Error("MessageService.SendMessage CreateMessage", "err", err) return nil, fmt.Errorf("%w: failed to save message", ErrInternal) @@ -111,12 +117,14 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } result := &SendMessageResult{ - MessageID: msgID, - Timestamp: msg.Timestamp, - Content: content, - IsDM: isDM, - Channel: ch, - Attachments: attachments, + MessageID: msgID, + Timestamp: msg.Timestamp, + Content: content, + IsDM: isDM, + Channel: ch, + Attachments: attachments, + Mentions: mentions.UserIDs, + MentionsEveryone: mentions.Everyone, } // DM path: open DM for recipients. @@ -131,6 +139,19 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( sender, _ := s.st.GetUserByID(ctx, p.UserID) result.SenderUser = sender + // Viewer-neutral (viewerID 0 matches nobody, so every status is + // broadcast-collapsed); the ws layer re-derives "who is the recipient" + // per addressee. A read failure is non-fatal — the message is already + // committed, and the caller falls back to the 1:1 shape. + if participants, partErr := s.st.GetDMParticipants(ctx, p.ChannelID, 0); partErr == nil { + result.DMParticipants = participants + } else { + slog.Warn("MessageService.SendMessage GetDMParticipants", "err", partErr, "channel_id", p.ChannelID) + } + if isGroup, gErr := s.st.IsGroupDM(ctx, p.ChannelID); gErr == nil { + result.DMIsGroup = isGroup + } + for _, pid := range participantIDs { if pid == p.UserID { continue @@ -143,6 +164,19 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } } + // Mention badges run off the send path: the message is already committed, so + // the recipients' badge bookkeeping (the full reader-resolution chain plus + // the batched increment) must not delay delivering the message to the rest + // of the channel. The ctx is detached from cancellation — for the same + // reason audit writes are — so a client hanging up mid-request cannot drop + // the badges. The count is advisory: if a reader's channel_focus clears it + // in the tiny window before the increment lands, the badge simply does not + // reappear, which matches Discord's eventual-consistency behaviour. + channelID, authorID, participantIDs := p.ChannelID, p.UserID, result.ParticipantIDs + s.bg(func() { + s.applyMentionCounts(context.WithoutCancel(ctx), channelID, authorID, mentions, isDM, participantIDs) + }) + slog.Debug("message sent", "user", p.Username, "channel_id", p.ChannelID, "msg_id", msgID) return result, nil } @@ -215,12 +249,25 @@ func (s *MessageService) EditMessage(ctx context.Context, userID, msgID int64, r editedAt = *msg.EditedAt } + // Re-resolve mentions from the new content and replace the stored set, so a + // mention added by an edit is highlighted and one removed by an edit stops + // being. Mention counts are deliberately NOT advanced here: an edit that + // re-adds an already-counted mention would otherwise raise the badge twice, + // and "only the original insert can raise a badge" is the simplest rule that + // is always correct. + mentions := s.resolveMentions(ctx, content, userID, msg.ChannelID, isDM) + if mErr := s.st.ReplaceMessageMentions(context.WithoutCancel(ctx), msgID, mentions.UserIDs, mentions.Everyone); mErr != nil { + slog.Error("MessageService.EditMessage ReplaceMessageMentions", "err", mErr, "msg_id", msgID) + } + result := &EditMessageResult{ - MessageID: msgID, - ChannelID: msg.ChannelID, - Content: content, - EditedAt: editedAt, - IsDM: isDM, + MessageID: msgID, + ChannelID: msg.ChannelID, + Content: content, + EditedAt: editedAt, + IsDM: isDM, + Mentions: mentions.UserIDs, + MentionsEveryone: mentions.Everyone, } if isDM { diff --git a/Server/service/message_perms.go b/Server/service/message_perms.go index 0ab830bb..9e3a7efc 100644 --- a/Server/service/message_perms.go +++ b/Server/service/message_perms.go @@ -23,7 +23,7 @@ func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int var overrides map[int64]db.ChannelOverride if !permissions.HasAdmin(role.Permissions) { var overrideErr error - overrides, overrideErr = s.st.GetAllChannelPermissionsForRole(ctx, role.ID) + overrides, overrideErr = s.st.GetChannelOverridesFor(ctx, role.ID, userID) if overrideErr != nil { return nil, fmt.Errorf("%w: failed to fetch channel overrides: %v", ErrInternal, overrideErr) } @@ -107,7 +107,19 @@ func (s *MessageService) checkSendPermission(ctx context.Context, userID, channe // A GetDMRecipient lookup failure or a DM with no other participant is treated // as "not blocked", carrying over the posture the send path has always had // rather than newly failing closed on all five sinks at once. +// +// Group DMs are exempt, which is Discord's rule and the only coherent one for +// a shared room: there is no single "the other party" to be blocked by, and +// dropping one member's messages for one other member would leave the two of +// them reading different conversations under the same name. Blocks are instead +// enforced when the group is *created* (DMService.CreateGroupDM), where the +// question "may these two be in a room together" still has one answer. func requireDMNotBlocked(ctx context.Context, st Store, userID, channelID int64) error { + isGroup, gErr := st.IsGroupDM(ctx, channelID) + if gErr == nil && isGroup { + return nil + } + recipient, err := st.GetDMRecipient(ctx, channelID, userID) if err != nil || recipient == nil { return nil //nolint:nilerr // carries over checkSendPermission's posture: a lookup failure or a DM with no other participant is not a block diff --git a/Server/service/message_purge.go b/Server/service/message_purge.go new file mode 100644 index 00000000..998f4057 --- /dev/null +++ b/Server/service/message_purge.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// maxPurgeLimit bounds one purge request. Matches the message page size, so a +// moderator can clear exactly what a client shows in one screenful and no +// single call can fan out an unbounded id list to every channel subscriber. +const maxPurgeLimit = 100 + +// PurgeMessages soft-deletes the newest limit non-deleted messages in a +// channel, optionally restricted to messages older than before. +// +// The actor needs READ_MESSAGES|MANAGE_MESSAGES on the channel (per-channel +// overrides apply), the same pair the single-message moderator delete and the +// pin toggle require — MANAGE_MESSAGES alone would let a role the admin panel's +// "Can access" toggle locked out of a private channel wipe it. +// +// DMs are rejected outright: a DM has no MANAGE_MESSAGES gate to check, so +// there is no participant-scoped authority a bulk delete could answer to. +func (s *MessageService) PurgeMessages(ctx context.Context, userID, channelID int64, limit int, before int64) (*PurgeMessagesResult, error) { + ratKey := auth.Key("chat_purge", userID) + if s.limiter != nil && !s.limiter.Allow(ratKey, 5, time.Second) { + return nil, ErrRateLimited + } + + if channelID <= 0 { + return nil, fmt.Errorf("%w: channel_id must be a positive integer", ErrBadRequest) + } + if limit < 1 { + return nil, fmt.Errorf("%w: limit must be between 1 and %d", ErrBadRequest, maxPurgeLimit) + } + if before < 0 { + return nil, fmt.Errorf("%w: before must be a non-negative integer", ErrBadRequest) + } + if limit > maxPurgeLimit { + limit = maxPurgeLimit + } + + ch, err := s.st.GetChannel(ctx, channelID) + if err != nil || ch == nil { + return nil, fmt.Errorf("%w: channel not found", ErrNotFound) + } + if ch.Type == "dm" { + return nil, fmt.Errorf("%w: bulk delete is not available in direct messages", ErrForbidden) + } + if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages|permissions.ManageMessages) { + return nil, fmt.Errorf("%w: missing MANAGE_MESSAGES permission", ErrForbidden) + } + + ids, err := s.st.PurgeChannelMessages(ctx, channelID, before, limit) + if err != nil { + slog.Error("MessageService.PurgeMessages", "err", err, "channel_id", channelID) + return nil, fmt.Errorf("%w: failed to purge messages", ErrInternal) + } + if ids == nil { + ids = []int64{} + } + + slog.Info("messages purged", "user_id", userID, "channel_id", channelID, "count", len(ids)) + // One audit row per purge, not per message. Audit rows must survive a + // request canceled after the delete committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "message_purge", "channel", channelID, + fmt.Sprintf("purged %d messages, limit=%d, before=%d", len(ids), limit, before)) + + return &PurgeMessagesResult{ChannelID: channelID, MessageIDs: ids}, nil +} diff --git a/Server/service/message_purge_test.go b/Server/service/message_purge_test.go new file mode 100644 index 00000000..a9f2e765 --- /dev/null +++ b/Server/service/message_purge_test.go @@ -0,0 +1,247 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// newPurgeService builds a MessageService with a moderator (user 2, role 20, +// READ_MESSAGES|MANAGE_MESSAGES) alongside the plain member (user 1) that +// newTestMessageService seeds, plus a DM channel both users participate in. +func newPurgeService(t *testing.T) (*MessageService, *db.DB) { + t.Helper() + svc, database := newTestMessageService(t) + seedRole(t, database, &db.Role{ + ID: 20, + Name: "purge-mod", + Permissions: permissions.ReadMessages | permissions.SendMessages | permissions.ManageMessages, + Position: 5, + }) + seedUser(t, database, &db.User{ID: 2, Username: "mod", Status: "online"}) + seedUserRole(t, database, 2, 20) + seedChannel(t, database, &db.Channel{ID: 11, Name: "dm", Type: "dm"}) + seedDMParticipant(t, database, 11, 1) + seedDMParticipant(t, database, 11, 2) + return svc, database +} + +// seedPurgeMessages inserts n messages into channelID and returns their ids in +// insertion (oldest-first) order. +func seedPurgeMessages(t *testing.T, database *db.DB, channelID int64, n int) []int64 { + t.Helper() + ids := make([]int64, 0, n) + for range n { + id, err := database.CreateMessage(context.Background(), channelID, 1, "spam", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + ids = append(ids, id) + } + return ids +} + +func TestPurgeMessages_ModeratorPurgesNewest(t *testing.T) { + svc, database := newPurgeService(t) + ids := seedPurgeMessages(t, database, 10, 5) + + result, err := svc.PurgeMessages(context.Background(), 2, 10, 2, 0) + if err != nil { + t.Fatalf("PurgeMessages: %v", err) + } + if len(result.MessageIDs) != 2 { + t.Fatalf("purged %d messages, want 2", len(result.MessageIDs)) + } + if result.ChannelID != 10 { + t.Errorf("ChannelID = %d, want 10", result.ChannelID) + } + if result.MessageIDs[0] != ids[4] || result.MessageIDs[1] != ids[3] { + t.Errorf("purged ids = %v, want newest-first %v", result.MessageIDs, []int64{ids[4], ids[3]}) + } +} + +func TestPurgeMessages_TombstonesPreserved(t *testing.T) { + svc, database := newPurgeService(t) + ids := seedPurgeMessages(t, database, 10, 3) + + if _, err := svc.PurgeMessages(context.Background(), 2, 10, 3, 0); err != nil { + t.Fatalf("PurgeMessages: %v", err) + } + + // Soft delete only: the rows and their content must survive so clients + // render tombstones and reply targets still resolve. + for _, id := range ids { + msg, err := database.GetMessage(context.Background(), id) + if err != nil { + t.Fatalf("GetMessage(%d): %v", id, err) + } + if msg == nil { + t.Fatalf("message %d was hard-deleted", id) + } + if !msg.Deleted { + t.Errorf("message %d not marked deleted", id) + } + if msg.Content == "" { + t.Errorf("message %d lost its content", id) + } + } +} + +func TestPurgeMessages_MissingManageMessagesDenied(t *testing.T) { + svc, database := newPurgeService(t) + seedPurgeMessages(t, database, 10, 3) + + // User 1 holds SEND_MESSAGES|READ_MESSAGES but not MANAGE_MESSAGES. + _, err := svc.PurgeMessages(context.Background(), 1, 10, 3, 0) + if !errors.Is(err, ErrForbidden) { + t.Fatalf("err = %v, want ErrForbidden", err) + } + + msgs, _, listErr := svc.GetMessages(context.Background(), 1, 10, 0, 50) + if listErr != nil { + t.Fatalf("GetMessages: %v", listErr) + } + if len(msgs) != 3 { + t.Errorf("denied purge still deleted messages: %d remain, want 3", len(msgs)) + } +} + +// A role with MANAGE_MESSAGES but READ_MESSAGES denied on the channel (what the +// admin panel's "Can access" toggle writes) must not be able to wipe it. +func TestPurgeMessages_DeniedReadCannotPurge(t *testing.T) { + svc, database := newPurgeService(t) + seedPurgeMessages(t, database, 10, 3) + seedChannelOverride(t, database, 20, 10, 0, permissions.ReadMessages) + + _, err := svc.PurgeMessages(context.Background(), 2, 10, 3, 0) + if !errors.Is(err, ErrForbidden) { + t.Fatalf("err = %v, want ErrForbidden", err) + } +} + +func TestPurgeMessages_DMRejected(t *testing.T) { + svc, database := newPurgeService(t) + seedPurgeMessages(t, database, 11, 3) + + _, err := svc.PurgeMessages(context.Background(), 2, 11, 3, 0) + if !errors.Is(err, ErrForbidden) { + t.Fatalf("err = %v, want ErrForbidden for a DM channel", err) + } + + msgs, _, listErr := svc.GetMessages(context.Background(), 2, 11, 0, 50) + if listErr != nil { + t.Fatalf("GetMessages: %v", listErr) + } + if len(msgs) != 3 { + t.Errorf("DM purge deleted messages: %d remain, want 3", len(msgs)) + } +} + +func TestPurgeMessages_LimitClampedToMax(t *testing.T) { + svc, database := newPurgeService(t) + seedPurgeMessages(t, database, 10, maxPurgeLimit+10) + + result, err := svc.PurgeMessages(context.Background(), 2, 10, 5000, 0) + if err != nil { + t.Fatalf("PurgeMessages: %v", err) + } + if len(result.MessageIDs) != maxPurgeLimit { + t.Fatalf("purged %d messages, want the clamp of %d", len(result.MessageIDs), maxPurgeLimit) + } + + msgs, _, listErr := svc.GetMessages(context.Background(), 2, 10, 0, maxPurgeLimit) + if listErr != nil { + t.Fatalf("GetMessages: %v", listErr) + } + if len(msgs) != 10 { + t.Errorf("%d messages remain, want 10", len(msgs)) + } +} + +func TestPurgeMessages_NonPositiveLimitRejected(t *testing.T) { + svc, database := newPurgeService(t) + seedPurgeMessages(t, database, 10, 2) + + for _, limit := range []int{0, -1} { + if _, err := svc.PurgeMessages(context.Background(), 2, 10, limit, 0); !errors.Is(err, ErrBadRequest) { + t.Errorf("limit %d: err = %v, want ErrBadRequest", limit, err) + } + } +} + +func TestPurgeMessages_BeforeCursorHonored(t *testing.T) { + svc, database := newPurgeService(t) + ids := seedPurgeMessages(t, database, 10, 4) + + result, err := svc.PurgeMessages(context.Background(), 2, 10, 100, ids[2]) + if err != nil { + t.Fatalf("PurgeMessages: %v", err) + } + if len(result.MessageIDs) != 2 { + t.Fatalf("purged %v, want the two messages below the cursor", result.MessageIDs) + } + for _, id := range ids[2:] { + msg, _ := database.GetMessage(context.Background(), id) + if msg.Deleted { + t.Errorf("message %d at/after the cursor was purged", id) + } + } +} + +func TestPurgeMessages_ChannelNotFound(t *testing.T) { + svc, _ := newPurgeService(t) + + if _, err := svc.PurgeMessages(context.Background(), 2, 9999, 10, 0); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + if _, err := svc.PurgeMessages(context.Background(), 2, 0, 10, 0); !errors.Is(err, ErrBadRequest) { + t.Fatalf("channel 0: err = %v, want ErrBadRequest", err) + } +} + +func TestPurgeMessages_EmptyChannelSucceedsWithNoIDs(t *testing.T) { + svc, _ := newPurgeService(t) + + result, err := svc.PurgeMessages(context.Background(), 2, 10, 50, 0) + if err != nil { + t.Fatalf("PurgeMessages: %v", err) + } + if result.MessageIDs == nil { + t.Fatal("MessageIDs is nil, want an empty slice") + } + if len(result.MessageIDs) != 0 { + t.Fatalf("purged %v, want none", result.MessageIDs) + } +} + +func TestPurgeMessages_WritesOneAuditEntry(t *testing.T) { + svc, database := newPurgeService(t) + seedPurgeMessages(t, database, 10, 4) + + if _, err := svc.PurgeMessages(context.Background(), 2, 10, 4, 0); err != nil { + t.Fatalf("PurgeMessages: %v", err) + } + + entries, err := database.GetAuditLog(context.Background(), 50, 0) + if err != nil { + t.Fatalf("GetAuditLog: %v", err) + } + var purges int + for _, e := range entries { + if e.Action == "message_purge" { + purges++ + if e.TargetID != 10 { + t.Errorf("audit target_id = %d, want the channel id 10", e.TargetID) + } + if e.Detail == "" { + t.Error("audit detail is empty, want the purged count") + } + } + } + if purges != 1 { + t.Fatalf("wrote %d message_purge audit entries, want exactly 1", purges) + } +} diff --git a/Server/service/message_query.go b/Server/service/message_query.go index ef28a5e2..ea9c8d7f 100644 --- a/Server/service/message_query.go +++ b/Server/service/message_query.go @@ -4,30 +4,41 @@ import ( "context" "fmt" "log/slog" + "slices" "github.com/owncord/server/db" "github.com/owncord/server/permissions" ) -// GetMessages retrieves paginated messages for a channel with permission checks. -func (s *MessageService) GetMessages(ctx context.Context, userID, channelID, before int64, limit int) ([]db.MessageAPIResponse, bool, error) { +// requireChannelRead resolves a channel and asserts the user may read it: DM +// membership for a DM, READ_MESSAGES otherwise. A DM the user is not in is +// reported as ErrNotFound rather than ErrForbidden — its existence is not +// something an outsider gets to learn. +func (s *MessageService) requireChannelRead(ctx context.Context, userID, channelID int64) error { if channelID <= 0 { - return nil, false, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) + return fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } - ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { - return nil, false, fmt.Errorf("%w: channel not found", ErrNotFound) + return fmt.Errorf("%w: channel not found", ErrNotFound) } - - // Permission check. if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(ctx, userID, channelID) - if err != nil || !ok { - return nil, false, fmt.Errorf("%w: access denied", ErrNotFound) + ok, dmErr := s.st.IsDMParticipant(ctx, userID, channelID) + if dmErr != nil || !ok { + return fmt.Errorf("%w: access denied", ErrNotFound) } - } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { - return nil, false, fmt.Errorf("%w: access denied", ErrForbidden) + return nil + } + if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { + return fmt.Errorf("%w: access denied", ErrForbidden) + } + return nil +} + +// GetMessages retrieves paginated messages for a channel with permission checks. +func (s *MessageService) GetMessages(ctx context.Context, userID, channelID, before int64, limit int) ([]db.MessageAPIResponse, bool, error) { + if err := s.requireChannelRead(ctx, userID, channelID); err != nil { + return nil, false, err } if limit <= 0 { @@ -101,22 +112,81 @@ func (s *MessageService) SearchMessages(ctx context.Context, userID int64, query return results, nil } +// MessageWindow is a slice of channel history centred on one message, as +// returned by GetMessagesAround. Messages are ordered oldest-first; the +// HasMore flags report whether the channel holds further history on each side +// of the window. +type MessageWindow struct { + Messages []db.MessageAPIResponse `json:"messages"` + HasMoreBefore bool `json:"has_more_before"` + HasMoreAfter bool `json:"has_more_after"` +} + +// GetMessagesAround retrieves the window of `limit` messages centred on +// messageID, ordered oldest-first, with the same read gate as GetMessages. +// +// The centre message must be a live message in this channel: a message from +// another channel, one that never existed, or a soft-deleted one (which +// history omits, so there is no row to centre on) is ErrNotFound. +func (s *MessageService) GetMessagesAround(ctx context.Context, userID, channelID, messageID int64, limit int) (*MessageWindow, error) { + if messageID <= 0 { + return nil, fmt.Errorf("%w: message_id must be positive", ErrBadRequest) + } + if err := s.requireChannelRead(ctx, userID, channelID); err != nil { + return nil, err + } + + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + + msg, err := s.st.GetMessage(ctx, messageID) + if err != nil { + slog.Error("MessageService.GetMessagesAround", "err", err, "message_id", messageID) + return nil, fmt.Errorf("%w: failed to fetch message", ErrInternal) + } + if msg == nil || msg.ChannelID != channelID || msg.Deleted { + return nil, fmt.Errorf("%w: message not found in this channel", ErrNotFound) + } + + // Half the window sits before the centre, the rest after it; the centre + // occupies one slot. Ask for one extra on each side so the has-more flags + // come out of the same query instead of two follow-up counts. + beforeCount := limit / 2 + afterCount := limit - beforeCount - 1 + + msgs, err := s.st.GetMessagesAroundForAPI(ctx, channelID, messageID, beforeCount+1, afterCount+1, userID) + if err != nil { + slog.Error("MessageService.GetMessagesAround", "err", err, "channel_id", channelID) + return nil, fmt.Errorf("%w: failed to fetch messages", ErrInternal) + } + + centreIdx := slices.IndexFunc(msgs, func(m db.MessageAPIResponse) bool { return m.ID == messageID }) + if centreIdx < 0 { + // The centre vanished between the lookup and the window query. + return nil, fmt.Errorf("%w: message not found in this channel", ErrNotFound) + } + + window := &MessageWindow{Messages: msgs} + if centreIdx > beforeCount { + window.HasMoreBefore = true + window.Messages = window.Messages[centreIdx-beforeCount:] + centreIdx = beforeCount + } + if len(window.Messages)-centreIdx-1 > afterCount { + window.HasMoreAfter = true + window.Messages = window.Messages[:centreIdx+afterCount+1] + } + return window, nil +} + // GetPinnedMessages retrieves pinned messages for a channel. func (s *MessageService) GetPinnedMessages(ctx context.Context, userID, channelID int64) ([]db.MessageAPIResponse, error) { - if channelID <= 0 { - return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) - } - ch, err := s.st.GetChannel(ctx, channelID) - if err != nil || ch == nil { - return nil, fmt.Errorf("%w: channel not found", ErrNotFound) - } - if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(ctx, userID, channelID) - if err != nil || !ok { - return nil, fmt.Errorf("%w: access denied", ErrNotFound) - } - } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { - return nil, fmt.Errorf("%w: access denied", ErrForbidden) + if err := s.requireChannelRead(ctx, userID, channelID); err != nil { + return nil, err } msgs, err := s.st.GetPinnedMessages(ctx, channelID, userID) if err != nil { diff --git a/Server/service/message_reaction_users_test.go b/Server/service/message_reaction_users_test.go new file mode 100644 index 00000000..9a2a514b --- /dev/null +++ b/Server/service/message_reaction_users_test.go @@ -0,0 +1,183 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// seedReactedMessage posts a message in channel 10 and has each of userIDs +// react to it with emoji, returning the message id. +func seedReactedMessage(t *testing.T, svc *MessageService, database *db.DB, emoji string, userIDs ...int64) int64 { + t.Helper() + msgID, err := database.CreateMessage(context.Background(), 10, 1, "react to me", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + for _, uid := range userIDs { + if _, err := svc.AddReaction(context.Background(), uid, msgID, emoji); err != nil { + t.Fatalf("AddReaction(%d): %v", uid, err) + } + } + return msgID +} + +func TestGetReactionUsers_ReturnsReactors(t *testing.T) { + svc, database := newTestMessageService(t) + seedUser(t, database, &db.User{ID: 2, Username: "bob"}) + seedUserRole(t, database, 2, permissions.MemberRoleID) + msgID := seedReactedMessage(t, svc, database, "👍", 1, 2) + + users, err := svc.GetReactionUsers(context.Background(), 1, 10, msgID, "👍") + if err != nil { + t.Fatalf("GetReactionUsers: %v", err) + } + if len(users) != 2 { + t.Fatalf("len(users) = %d, want 2 (%+v)", len(users), users) + } + if users[0].Username != "alice" || users[1].Username != "bob" { + t.Errorf("usernames = [%s %s], want [alice bob]", users[0].Username, users[1].Username) + } +} + +// An emoji nobody used is an empty list, never nil — the handler serialises it +// straight to JSON and `null` is not a list the client can iterate. +func TestGetReactionUsers_EmptyIsNonNil(t *testing.T) { + svc, database := newTestMessageService(t) + msgID := seedReactedMessage(t, svc, database, "👍", 1) + + users, err := svc.GetReactionUsers(context.Background(), 1, 10, msgID, "🎉") + if err != nil { + t.Fatalf("GetReactionUsers: %v", err) + } + if users == nil { + t.Fatal("users = nil, want an empty slice") + } + if len(users) != 0 { + t.Errorf("len(users) = %d, want 0", len(users)) + } +} + +// Reading the reactor list is gated by the same READ_MESSAGES check as reading +// the channel's history: a reaction pill must not leak who is in a channel the +// caller cannot see. +func TestGetReactionUsers_ForbiddenWithoutReadPermission(t *testing.T) { + svc, database := newTestMessageService(t) + msgID := seedReactedMessage(t, svc, database, "👍", 1) + + seedRole(t, database, &db.Role{ID: 90, Name: "outsider", Permissions: 0, Position: 1}) + seedUser(t, database, &db.User{ID: 3, Username: "outsider"}) + seedUserRole(t, database, 3, 90) + + _, err := svc.GetReactionUsers(context.Background(), 3, 10, msgID, "👍") + if !errors.Is(err, ErrForbidden) { + t.Fatalf("err = %v, want ErrForbidden", err) + } +} + +// The channel in the URL is what the permission check ran against, so a message +// that lives elsewhere must not be answered from that check. +func TestGetReactionUsers_MessageInAnotherChannelIsNotFound(t *testing.T) { + svc, database := newTestMessageService(t) + seedChannel(t, database, &db.Channel{ID: 11, Name: "other", Type: "text"}) + msgID := seedReactedMessage(t, svc, database, "👍", 1) + + _, err := svc.GetReactionUsers(context.Background(), 1, 11, msgID, "👍") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestGetReactionUsers_RejectsBadInput(t *testing.T) { + svc, database := newTestMessageService(t) + msgID := seedReactedMessage(t, svc, database, "👍", 1) + + tests := []struct { + name string + msgID int64 + emoji string + wantErr error + }{ + {"zero message id", 0, "👍", ErrBadRequest}, + {"negative message id", -5, "👍", ErrBadRequest}, + {"empty emoji", msgID, "", ErrBadRequest}, + // The cap is derived from MaxShortcodeLen + 2, so a bare ":wave:"-shaped + // string at that exact length must pass and one rune more must not. + {"overlong emoji", msgID, strings.Repeat("a", MaxShortcodeLen+3), ErrBadRequest}, + {"control character", msgID, "a\x01b", ErrBadRequest}, + {"unknown message", 999999, "👍", ErrNotFound}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := svc.GetReactionUsers(context.Background(), 1, 10, tt.msgID, tt.emoji) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("err = %v, want %v", err, tt.wantErr) + } + }) + } +} + +// A DM the caller is not a participant of is reported as not-found (matching +// requireChannelRead), so its existence stays hidden. +func TestGetReactionUsers_ForeignDMIsNotFound(t *testing.T) { + svc, database := newTestMessageService(t) + seedUser(t, database, &db.User{ID: 2, Username: "bob"}) + seedUser(t, database, &db.User{ID: 3, Username: "carol"}) + seedUserRole(t, database, 2, permissions.MemberRoleID) + seedUserRole(t, database, 3, permissions.MemberRoleID) + + ch, _, err := database.GetOrCreateDMChannel(context.Background(), 2, 3) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + msgID, err := database.CreateMessage(context.Background(), ch.ID, 2, "psst", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if _, err := svc.AddReaction(context.Background(), 3, msgID, "👍"); err != nil { + t.Fatalf("AddReaction: %v", err) + } + + if _, err := svc.GetReactionUsers(context.Background(), 1, ch.ID, msgID, "👍"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + // A participant does get the list. + users, err := svc.GetReactionUsers(context.Background(), 2, ch.ID, msgID, "👍") + if err != nil { + t.Fatalf("GetReactionUsers(participant): %v", err) + } + if len(users) != 1 || users[0].Username != "carol" { + t.Errorf("users = %+v, want [carol]", users) + } +} + +// A custom emoji is reacted with as its ":shortcode:" literal, so the longest +// shortcode the emoji service will accept has to fit inside the reaction length +// cap. Before the cap was derived from MaxShortcodeLen, a 31- or 32-character +// shortcode produced an emoji that rendered in messages but was silently +// refused as a reaction. +func TestReaction_AcceptsLongestCustomShortcode(t *testing.T) { + svc, database := newTestMessageService(t) + msgID, err := database.CreateMessage(context.Background(), 10, 1, "react to me", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + longest := ":" + strings.Repeat("a", MaxShortcodeLen) + ":" + if _, vErr := ValidateShortcode(longest); vErr != nil { + t.Fatalf("the emoji service would refuse %q: %v", longest, vErr) + } + if _, rErr := svc.AddReaction(context.Background(), 1, msgID, longest); rErr != nil { + t.Fatalf("AddReaction(longest shortcode): %v", rErr) + } + + // One rune past it is still refused. + tooLong := ":" + strings.Repeat("a", MaxShortcodeLen+1) + ":" + if _, rErr := svc.AddReaction(context.Background(), 1, msgID, tooLong); !errors.Is(rErr, ErrBadRequest) { + t.Fatalf("AddReaction(one past the cap) = %v, want ErrBadRequest", rErr) + } +} diff --git a/Server/service/message_reactions.go b/Server/service/message_reactions.go index 915c2bd8..16234fa8 100644 --- a/Server/service/message_reactions.go +++ b/Server/service/message_reactions.go @@ -7,6 +7,7 @@ import ( "time" "github.com/owncord/server/auth" + "github.com/owncord/server/db" "github.com/owncord/server/permissions" ) @@ -20,6 +21,65 @@ func (s *MessageService) RemoveReaction(ctx context.Context, userID, msgID int64 return s.handleReaction(ctx, userID, msgID, emoji, false) } +// GetReactionUsers returns the users who reacted to msgID with emoji, capped at +// db.MaxReactionUsers. Gated by the same read check as fetching the channel's +// history, so a reaction pill never leaks membership of a channel the caller +// cannot read. The message must live in channelID — the URL's channel is what +// the permission check ran against, so a mismatch is a not-found, not a +// silently-broader lookup. +func (s *MessageService) GetReactionUsers(ctx context.Context, userID, channelID, msgID int64, emoji string) ([]db.ReactionUser, error) { + if msgID <= 0 { + return nil, fmt.Errorf("%w: message_id must be positive", ErrBadRequest) + } + if err := validateEmoji(emoji); err != nil { + return nil, err + } + if err := s.requireChannelRead(ctx, userID, channelID); err != nil { + return nil, err + } + + msg, err := s.st.GetMessage(ctx, msgID) + if err != nil || msg == nil || msg.ChannelID != channelID { + return nil, fmt.Errorf("%w: message not found", ErrNotFound) + } + + users, err := s.st.GetReactionUsers(ctx, msgID, emoji, db.MaxReactionUsers) + if err != nil { + slog.Error("MessageService.GetReactionUsers", "err", err, "msg_id", msgID) + return nil, fmt.Errorf("%w: failed to fetch reaction users", ErrInternal) + } + if users == nil { + users = []db.ReactionUser{} + } + return users, nil +} + +// maxReactionRunes bounds a reaction string. Reactions are free-form text, so +// the ceiling has to clear the longest thing a client can legitimately react +// with: a custom emoji is stored as its ":shortcode:" literal, which is +// MaxShortcodeLen plus the two colons. Deriving it keeps the two from drifting +// into an emoji that renders in a message but is silently refused as a +// reaction. Unicode emoji, even long ZWJ sequences, sit far below this. +const maxReactionRunes = MaxShortcodeLen + 2 + +// validateEmoji applies the shared shape rules for a reaction emoji: non-empty, +// at most maxReactionRunes runes, no control characters, and unchanged by the +// sanitizer. +func validateEmoji(emoji string) error { + if emoji == "" || len([]rune(emoji)) > maxReactionRunes { + return fmt.Errorf("%w: invalid emoji", ErrBadRequest) + } + for _, r := range emoji { + if r <= 0x1F || r == 0x7F { + return fmt.Errorf("%w: emoji contains control characters", ErrBadRequest) + } + } + if sanitizer.Sanitize(emoji) != emoji { + return fmt.Errorf("%w: emoji contains unsafe content", ErrBadRequest) + } + return nil +} + func (s *MessageService) handleReaction(ctx context.Context, userID, msgID int64, emoji string, add bool) (*ReactionResult, error) { // Rate limit. ratKey := auth.Key("reaction", userID) @@ -30,18 +90,8 @@ func (s *MessageService) handleReaction(ctx context.Context, userID, msgID int64 if msgID <= 0 { return nil, fmt.Errorf("%w: message_id must be positive", ErrBadRequest) } - if emoji == "" || len([]rune(emoji)) > 32 { - return nil, fmt.Errorf("%w: invalid emoji", ErrBadRequest) - } - // Reject control characters. - for _, r := range emoji { - if r <= 0x1F || r == 0x7F { - return nil, fmt.Errorf("%w: emoji contains control characters", ErrBadRequest) - } - } - // Sanitize check. - if sanitizer.Sanitize(emoji) != emoji { - return nil, fmt.Errorf("%w: emoji contains unsafe content", ErrBadRequest) + if err := validateEmoji(emoji); err != nil { + return nil, err } msg, err := s.st.GetMessage(ctx, msgID) diff --git a/Server/service/moderation.go b/Server/service/moderation.go index b4545f52..93400fa6 100644 --- a/Server/service/moderation.go +++ b/Server/service/moderation.go @@ -22,38 +22,62 @@ func NewModerationService(st Store, perms *PermissionService) *ModerationService return &ModerationService{st: st, perms: perms} } -// requireBanPermission verifies the actor holds BAN_MEMBERS (or the -// Administrator bypass). It deliberately takes no target: it runs before any -// target lookup so an actor without ban authority always sees Forbidden and -// never NotFound — the ban path cannot be used to enumerate user ids. -func (s *ModerationService) requireBanPermission(ctx context.Context, actorID int64) error { +// roleFor loads a principal's role through the permission cache. Every failure +// is Forbidden: an unresolvable role must never authorize a moderation action. +// The which argument names the principal in the error message ("actor" or +// "target"). +func (s *ModerationService) roleFor(ctx context.Context, userID int64, which string) (*db.Role, error) { if s.perms == nil { - // No permission service wired — fail closed rather than allow unchecked bans. - return fmt.Errorf("%w: permission service unavailable", ErrForbidden) + // No permission service wired — fail closed rather than allow unchecked actions. + return nil, fmt.Errorf("%w: permission service unavailable", ErrForbidden) } - actorRole, err := s.perms.GetRoleForUser(ctx, actorID) - if err != nil || actorRole == nil { - return fmt.Errorf("%w: failed to load actor role", ErrForbidden) + role, err := s.perms.GetRoleForUser(ctx, userID) + if err != nil || role == nil { + return nil, fmt.Errorf("%w: failed to load %s role", ErrForbidden, which) } - if !permissions.HasServerPerm(actorRole.Permissions, permissions.BanMembers) { - return fmt.Errorf("%w: missing BAN_MEMBERS permission", ErrForbidden) + return role, nil +} + +// requirePerm verifies the actor holds perm (or the Administrator bypass) and +// returns the actor's role for follow-up hierarchy checks. It deliberately +// takes no target: it runs before any target lookup so an actor without +// authority always sees Forbidden and never NotFound — these paths cannot be +// used to enumerate user ids. +func (s *ModerationService) requirePerm(ctx context.Context, actorID, perm int64) (*db.Role, error) { + actorRole, err := s.roleFor(ctx, actorID, "actor") + if err != nil { + return nil, err } - return nil + if !permissions.HasServerPerm(actorRole.Permissions, perm) { + return nil, fmt.Errorf("%w: missing %s permission", ErrForbidden, permissions.Name(perm)) + } + return actorRole, nil +} + +// requireBanPermission verifies the actor holds BAN_MEMBERS. See requirePerm. +func (s *ModerationService) requireBanPermission(ctx context.Context, actorID int64) error { + _, err := s.requirePerm(ctx, actorID, permissions.BanMembers) + return err } // requireOutranks enforces the role hierarchy: the actor must strictly -// outrank the target so a user cannot ban a peer or a higher-ranked user +// outrank the target so a user cannot moderate a peer or a higher-ranked user // (e.g. the owner) — mirroring the position-based hierarchy used elsewhere. -// Runs after requireBanPermission and the existence check, so only callers -// that already hold ban authority reach it. +// Runs after the permission and existence checks, so only callers that already +// hold authority reach it. func (s *ModerationService) requireOutranks(ctx context.Context, actorID, targetID int64) error { - actorRole, err := s.perms.GetRoleForUser(ctx, actorID) - if err != nil || actorRole == nil { - return fmt.Errorf("%w: failed to load actor role", ErrForbidden) + actorRole, err := s.roleFor(ctx, actorID, "actor") + if err != nil { + return err } - targetRole, err := s.perms.GetRoleForUser(ctx, targetID) - if err != nil || targetRole == nil { - return fmt.Errorf("%w: failed to load target role", ErrForbidden) + return s.requireOutranksRole(ctx, actorRole, targetID) +} + +// requireOutranksRole is requireOutranks with the actor's role already loaded. +func (s *ModerationService) requireOutranksRole(ctx context.Context, actorRole *db.Role, targetID int64) error { + targetRole, err := s.roleFor(ctx, targetID, "target") + if err != nil { + return err } if actorRole.Position <= targetRole.Position { return fmt.Errorf("%w: cannot moderate a user of equal or higher rank", ErrForbidden) @@ -106,6 +130,93 @@ func (s *ModerationService) BanUser(ctx context.Context, actorID, targetID int64 return nil } +// ChangeUserRole assigns newRoleID to the target user. It enforces +// MANAGE_ROLES plus two hierarchy rules the admin panel previously had none +// of: the actor must strictly outrank the target, and may not hand out a role +// positioned at or above their own — otherwise any admin could promote anyone +// (including themselves via a second account) to Owner. +func (s *ModerationService) ChangeUserRole(ctx context.Context, actorID, targetID, newRoleID int64) error { + if targetID <= 0 { + return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) + } + if actorID == targetID { + return fmt.Errorf("%w: cannot change your own role", ErrBadRequest) + } + + // Authorization before existence — see BanUser. + actorRole, err := s.requirePerm(ctx, actorID, permissions.ManageRoles) + if err != nil { + return err + } + target, err := s.st.GetUserByID(ctx, targetID) + if err != nil || target == nil { + return fmt.Errorf("%w: user not found", ErrNotFound) + } + if err := s.requireOutranksRole(ctx, actorRole, targetID); err != nil { + return err + } + + newRole, err := s.st.GetRoleByID(ctx, newRoleID) + if err != nil || newRole == nil { + return fmt.Errorf("%w: role not found", ErrBadRequest) + } + // Administrator bypasses permission bits, never the hierarchy: the owner + // role is above every admin, so only the owner can grant it. + if newRole.Position >= actorRole.Position { + return fmt.Errorf("%w: cannot assign a role at or above your own rank", ErrForbidden) + } + + if err := s.st.UpdateUserRole(ctx, targetID, newRoleID); err != nil { + return fmt.Errorf("%w: failed to update role: %v", ErrInternal, err) + } + // Drop the target's cached role immediately: without this a demotion keeps + // granting the old bits (and the old rank) for up to permCacheTTL. + s.perms.InvalidateUser(targetID) + + // Audit rows must survive a request canceled after the update committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_change", "user", targetID, + fmt.Sprintf("changed %s role to %s", target.Username, newRole.Name)) + + slog.Info("role changed", "actor_id", actorID, "target_id", targetID, "new_role_id", newRoleID) + return nil +} + +// ForceLogout revokes every session of the target user (the client's "Kick"). +// Gated on KICK_MEMBERS plus the same hierarchy rule as ban, so a moderator +// cannot log out an admin or the owner. +func (s *ModerationService) ForceLogout(ctx context.Context, actorID, targetID int64) error { + if targetID <= 0 { + return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) + } + if actorID == targetID { + return fmt.Errorf("%w: cannot force-logout yourself", ErrBadRequest) + } + + // Authorization before existence — see BanUser. + actorRole, err := s.requirePerm(ctx, actorID, permissions.KickMembers) + if err != nil { + return err + } + target, err := s.st.GetUserByID(ctx, targetID) + if err != nil || target == nil { + return fmt.Errorf("%w: user not found", ErrNotFound) + } + if err := s.requireOutranksRole(ctx, actorRole, targetID); err != nil { + return err + } + + if err := s.st.ForceLogoutUser(ctx, targetID); err != nil { + return fmt.Errorf("%w: failed to log out user: %v", ErrInternal, err) + } + + // Audit rows must survive a request canceled after the sessions were cut. + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "force_logout", "user", targetID, + "all sessions terminated") + + slog.Info("force logout", "actor_id", actorID, "target_id", targetID) + return nil +} + // UnbanUser removes a ban on a target user. func (s *ModerationService) UnbanUser(ctx context.Context, actorID, targetID int64) error { if targetID <= 0 { diff --git a/Server/service/moderation_test.go b/Server/service/moderation_test.go index 2f0cf36b..f7e086ea 100644 --- a/Server/service/moderation_test.go +++ b/Server/service/moderation_test.go @@ -82,6 +82,166 @@ func TestBanUser_AuthorizedSucceeds(t *testing.T) { } } +// newTestRoleService seeds a four-rank hierarchy for the role-assignment and +// force-logout paths: owner (pos 100, Administrator) > admin (pos 80, +// Administrator) > mod (pos 60, MANAGE_ROLES+KICK_MEMBERS) > member (pos 40). +// Users: 1=owner, 2=admin, 3=mod, 4=member, 5=member. +func newTestRoleService(t *testing.T) (*ModerationService, *db.DB) { + t.Helper() + database := newTestDB(t) + seedRole(t, database, &db.Role{ID: 1, Name: "owner", Permissions: permissions.Administrator, Position: 100}) + seedRole(t, database, &db.Role{ID: 2, Name: "admin", Permissions: permissions.Administrator, Position: 80}) + seedRole(t, database, &db.Role{ID: 3, Name: "mod", + Permissions: permissions.ManageRoles | permissions.KickMembers, Position: 60}) + seedRole(t, database, &db.Role{ID: 4, Name: "member", Permissions: permissions.SendMessages, Position: 40}) + for userID, roleID := range map[int64]int64{1: 1, 2: 2, 3: 3, 4: 4, 5: 4} { + seedUser(t, database, &db.User{ID: userID, Username: fmt.Sprintf("u%d", userID), Status: "offline"}) + seedUserRole(t, database, userID, roleID) + } + checker := permissions.NewChecker(database) + return NewModerationService(database, NewPermissionService(database, checker)), database +} + +func roleIDOf(t *testing.T, database *db.DB, userID int64) int64 { + t.Helper() + user, err := database.GetUserByID(context.Background(), userID) + if err != nil || user == nil { + t.Fatalf("GetUserByID(%d): %v", userID, err) + } + return user.RoleID +} + +func TestChangeUserRole_RequiresManageRoles(t *testing.T) { + svc, database := newTestRoleService(t) + + // A member without MANAGE_ROLES is refused... + if err := svc.ChangeUserRole(context.Background(), 4, 5, 3); !errors.Is(err, ErrForbidden) { + t.Fatalf("member role change: want ErrForbidden, got %v", err) + } + // ...and gets Forbidden, not NotFound, for a missing target. + if err := svc.ChangeUserRole(context.Background(), 4, 999, 3); !errors.Is(err, ErrForbidden) { + t.Fatalf("unauthorized probe of missing id: want ErrForbidden, got %v", err) + } + if got := roleIDOf(t, database, 5); got != 4 { + t.Fatalf("target role changed to %d despite refusal", got) + } +} + +func TestChangeUserRole_CannotAssignAtOrAboveOwnRank(t *testing.T) { + svc, database := newTestRoleService(t) + + // The hole this closes: an Administrator could promote anyone to Owner. + if err := svc.ChangeUserRole(context.Background(), 2, 4, 1); !errors.Is(err, ErrForbidden) { + t.Fatalf("admin promoting to owner: want ErrForbidden, got %v", err) + } + // Equal rank is refused too — an admin cannot mint another admin. + if err := svc.ChangeUserRole(context.Background(), 2, 4, 2); !errors.Is(err, ErrForbidden) { + t.Fatalf("admin assigning own rank: want ErrForbidden, got %v", err) + } + if got := roleIDOf(t, database, 4); got != 4 { + t.Fatalf("member role changed to %d despite refusal", got) + } + // Strictly below own rank is allowed. + if err := svc.ChangeUserRole(context.Background(), 2, 4, 3); err != nil { + t.Fatalf("admin promoting to mod: %v", err) + } + if got := roleIDOf(t, database, 4); got != 3 { + t.Fatalf("role after promotion = %d, want 3", got) + } + // The owner outranks the admin role, so the owner may grant it. + if err := svc.ChangeUserRole(context.Background(), 1, 5, 2); err != nil { + t.Fatalf("owner promoting to admin: %v", err) + } +} + +func TestChangeUserRole_HierarchyAndValidation(t *testing.T) { + svc, database := newTestRoleService(t) + + // A moderator holding MANAGE_ROLES still cannot touch a higher-ranked user. + if err := svc.ChangeUserRole(context.Background(), 3, 2, 4); !errors.Is(err, ErrForbidden) { + t.Fatalf("mod demoting an admin: want ErrForbidden, got %v", err) + } + if got := roleIDOf(t, database, 2); got != 2 { + t.Fatalf("admin role changed to %d despite refusal", got) + } + // Nor the owner. + if err := svc.ChangeUserRole(context.Background(), 3, 1, 4); !errors.Is(err, ErrForbidden) { + t.Fatalf("mod demoting the owner: want ErrForbidden, got %v", err) + } + // Self-service promotion is a bad request regardless of authority. + if err := svc.ChangeUserRole(context.Background(), 2, 2, 1); !errors.Is(err, ErrBadRequest) { + t.Fatalf("self role change: want ErrBadRequest, got %v", err) + } + // A nonexistent role is a bad request, not a 500. + if err := svc.ChangeUserRole(context.Background(), 1, 4, 9999); !errors.Is(err, ErrBadRequest) { + t.Fatalf("unknown role id: want ErrBadRequest, got %v", err) + } + // Authorized actor gets a real NotFound for a missing target. + if err := svc.ChangeUserRole(context.Background(), 1, 999, 4); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing target: want ErrNotFound, got %v", err) + } +} + +func TestChangeUserRole_AuditWritten(t *testing.T) { + svc, database := newTestRoleService(t) + + if err := svc.ChangeUserRole(context.Background(), 1, 4, 3); err != nil { + t.Fatalf("owner role change: %v", err) + } + entries, err := database.GetAuditLog(context.Background(), 10, 0) + if err != nil { + t.Fatalf("GetAuditLog: %v", err) + } + found := false + for _, e := range entries { + if e.Action == "role_change" && e.TargetID == 4 && e.ActorID == 1 { + found = true + } + } + if !found { + t.Fatalf("no role_change audit entry, got %+v", entries) + } +} + +func TestForceLogout_AuthorizationMatrix(t *testing.T) { + svc, database := newTestRoleService(t) + + ctx := context.Background() + if _, err := database.CreateSession(ctx, 4, "victim-hash", "web", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + // No KICK_MEMBERS → Forbidden (member 5 targeting member 4). + if err := svc.ForceLogout(ctx, 5, 4); !errors.Is(err, ErrForbidden) { + t.Fatalf("member force-logout: want ErrForbidden, got %v", err) + } + // Holding KICK_MEMBERS is not enough against a higher rank. + if err := svc.ForceLogout(ctx, 3, 2); !errors.Is(err, ErrForbidden) { + t.Fatalf("mod force-logout of admin: want ErrForbidden, got %v", err) + } + // Self is a bad request. + if err := svc.ForceLogout(ctx, 3, 3); !errors.Is(err, ErrBadRequest) { + t.Fatalf("self force-logout: want ErrBadRequest, got %v", err) + } + sessions, _ := database.GetUserSessions(ctx, 4) + if len(sessions) != 1 { + t.Fatalf("sessions = %d before an authorized call, want 1", len(sessions)) + } + + // Authorized: mod outranks member and holds KICK_MEMBERS. + if err := svc.ForceLogout(ctx, 3, 4); err != nil { + t.Fatalf("authorized force-logout: %v", err) + } + sessions, _ = database.GetUserSessions(ctx, 4) + if len(sessions) != 0 { + t.Fatalf("sessions = %d after force logout, want 0", len(sessions)) + } + // Authorized actor gets a real NotFound for a missing target. + if err := svc.ForceLogout(ctx, 3, 999); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing target: want ErrNotFound, got %v", err) + } +} + func TestUnbanUser_AuthorizationMatrix(t *testing.T) { svc, database := newTestModerationService(t) diff --git a/Server/service/permission.go b/Server/service/permission.go index 97b05cd8..f4f26373 100644 --- a/Server/service/permission.go +++ b/Server/service/permission.go @@ -148,10 +148,13 @@ func (s *PermissionService) getOrPopulate(ctx context.Context, userID int64) *ca return nil } // Admins bypass every channel check, so skip the fetch entirely (mirrors - // ChannelService.ListVisibleChannels and ws.buildReady). + // ChannelService.ListVisibleChannels and ws.buildReady). The fetch pulls + // BOTH override layers (role + per-user) in two batch queries, so the + // cached snapshot can answer the full Discord resolution order without an + // extra query per channel. var overrides map[int64]permissions.ChannelOverride if !permissions.HasAdmin(role.Permissions) { - raw, oErr := s.st.GetAllChannelPermissionsForRole(ctx, role.ID) + raw, oErr := s.st.GetChannelOverridesFor(ctx, role.ID, userID) if oErr != nil { // Fail closed: an empty map would silently drop every deny bit, // and caching it would keep doing so for permCacheTTL. diff --git a/Server/service/permission_test.go b/Server/service/permission_test.go index 1c56ae7a..28e55692 100644 --- a/Server/service/permission_test.go +++ b/Server/service/permission_test.go @@ -22,6 +22,12 @@ func (errOverrideStore) GetAllChannelPermissionsForRole(context.Context, int64) return nil, errors.New("boom") } +// GetChannelOverridesFor is the merged role+user fetch every visibility site +// now uses. It must fail closed for exactly the same reason. +func (errOverrideStore) GetChannelOverridesFor(context.Context, int64, int64) (map[int64]db.ChannelOverride, error) { + return nil, errors.New("boom") +} + // TestHasChannelPerm_OverrideFetchErrorDenies locks the fail-closed rule: when // the override fetch errors we must NOT substitute an empty map, because that // restores every bit a channel-level deny had stripped — and PermissionService diff --git a/Server/service/profile_fields_test.go b/Server/service/profile_fields_test.go new file mode 100644 index 00000000..0e9f3304 --- /dev/null +++ b/Server/service/profile_fields_test.go @@ -0,0 +1,203 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// Phase 6 profile fields. The rules that matter here are the ones a handler +// could plausibly skip: sanitization, the length bounds, "omitted means +// unchanged", and "empty means cleared". They live in the service precisely so +// every transport gets them, so they are tested against the service. + +func newUserSvc(t *testing.T) (*UserService, *db.DB) { + t.Helper() + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"}) + return NewUserService(database), database +} + +func TestUpdateProfile_SetsAndClearsDisplayNameAndAbout(t *testing.T) { + svc, _ := newUserSvc(t) + ctx := context.Background() + + name, about := "Ada L.", "counts on it" + u, err := svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", DisplayName: &name, About: &about}) + if err != nil { + t.Fatalf("UpdateProfile: %v", err) + } + if u.DisplayName == nil || *u.DisplayName != name { + t.Fatalf("display_name = %v, want %q", u.DisplayName, name) + } + if u.About == nil || *u.About != about { + t.Fatalf("about = %v, want %q", u.About, about) + } + + // A patch that mentions neither field must leave both standing — the + // update writes every column, so this is the merge doing its job. + u, err = svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada"}) + if err != nil { + t.Fatalf("UpdateProfile (username only): %v", err) + } + if u.DisplayName == nil || u.About == nil { + t.Fatalf("a username-only patch cleared other fields: %v / %v", u.DisplayName, u.About) + } + + // An explicit empty string clears. + empty := "" + u, err = svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", DisplayName: &empty, About: &empty}) + if err != nil { + t.Fatalf("UpdateProfile (clear): %v", err) + } + if u.DisplayName != nil || u.About != nil { + t.Fatalf("expected cleared, got %v / %v", u.DisplayName, u.About) + } +} + +func TestUpdateProfile_SanitizesAndTrims(t *testing.T) { + svc, _ := newUserSvc(t) + name := " Ada " + about := "hello" + u, err := svc.UpdateProfile(context.Background(), 1, ProfilePatch{ + Username: "ada", DisplayName: &name, About: &about, + }) + if err != nil { + t.Fatalf("UpdateProfile: %v", err) + } + if u.DisplayName == nil || *u.DisplayName != "Ada" { + t.Errorf("display_name = %v, want %q", u.DisplayName, "Ada") + } + if u.About == nil || strings.Contains(*u.About, "<") { + t.Errorf("about = %v, want markup stripped", u.About) + } +} + +func TestUpdateProfile_RejectsOverlongFields(t *testing.T) { + svc, _ := newUserSvc(t) + ctx := context.Background() + + tooLongName := strings.Repeat("a", MaxDisplayNameLen+1) + if _, err := svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", DisplayName: &tooLongName}); !errors.Is(err, ErrBadRequest) { + t.Errorf("overlong display_name err = %v, want ErrBadRequest", err) + } + tooLongAbout := strings.Repeat("b", MaxAboutLen+1) + if _, err := svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", About: &tooLongAbout}); !errors.Is(err, ErrBadRequest) { + t.Errorf("overlong about err = %v, want ErrBadRequest", err) + } + + // Exactly at the bound is accepted, and the bound counts runes rather than + // bytes — otherwise a name in a non-Latin script would be a third as long. + atBound := strings.Repeat("é", MaxDisplayNameLen) + if _, err := svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", DisplayName: &atBound}); err != nil { + t.Errorf("display_name at bound rejected: %v", err) + } +} + +func TestSetCustomStatus_RoundTripClearAndBound(t *testing.T) { + svc, database := newUserSvc(t) + ctx := context.Background() + + if err := svc.SetCustomStatus(ctx, 1, " debugging "); err != nil { + t.Fatalf("SetCustomStatus: %v", err) + } + u, _ := database.GetUserByID(ctx, 1) + if u.CustomStatus == nil || *u.CustomStatus != "debugging" { + t.Fatalf("custom_status = %v, want sanitized+trimmed %q", u.CustomStatus, "debugging") + } + + if err := svc.SetCustomStatus(ctx, 1, " "); err != nil { + t.Fatalf("SetCustomStatus (blank): %v", err) + } + u, _ = database.GetUserByID(ctx, 1) + if u.CustomStatus != nil { + t.Fatalf("whitespace-only text should clear, got %q", *u.CustomStatus) + } + + if err := svc.SetCustomStatus(ctx, 1, strings.Repeat("x", MaxCustomStatusLen+1)); !errors.Is(err, ErrBadRequest) { + t.Errorf("overlong custom_status err = %v, want ErrBadRequest", err) + } +} + +func TestClearCustomStatus(t *testing.T) { + svc, database := newUserSvc(t) + ctx := context.Background() + if err := svc.SetCustomStatus(ctx, 1, "afk"); err != nil { + t.Fatalf("SetCustomStatus: %v", err) + } + if err := svc.ClearCustomStatus(ctx, 1); err != nil { + t.Fatalf("ClearCustomStatus: %v", err) + } + u, _ := database.GetUserByID(ctx, 1) + if u.CustomStatus != nil { + t.Fatalf("custom_status = %q, want nil", *u.CustomStatus) + } +} + +func TestAvatarFileURL(t *testing.T) { + if got := AvatarFileURL("abc"); got != "/api/v1/files/abc" { + t.Errorf("AvatarFileURL = %q", got) + } +} + +func TestHandlePresenceUpdate_AcceptsInvisibleAndCarriesCustomStatus(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"}) + svc := NewChannelService(database, NewPermissionService(database, permissions.NewChecker(database))) + ctx := context.Background() + + text := " away " + got, err := svc.HandlePresenceUpdate(ctx, 1, db.StatusInvisible, &text, nil) + if err != nil { + t.Fatalf("HandlePresenceUpdate: %v", err) + } + if got == nil || *got != "away" { + t.Fatalf("returned custom status = %v, want sanitized %q", got, "away") + } + u, _ := database.GetUserByID(ctx, 1) + // Stored as chosen — the invisible -> offline collapse is a broadcast-time + // concern, and storing it collapsed would make the next connect unable to + // tell "appear offline" from "not connected". + if u.Status != db.StatusInvisible { + t.Fatalf("stored status = %q, want invisible", u.Status) + } + + // A later status flip that carries no custom_status field must leave the + // text alone AND still put it on the wire, or the auto-idle timer would + // blank everyone else's copy several times an hour. + got, err = svc.HandlePresenceUpdate(ctx, 1, db.StatusIdle, nil, nil) + if err != nil { + t.Fatalf("HandlePresenceUpdate (bare): %v", err) + } + if got == nil || *got != "away" { + t.Fatalf("bare status flip returned %v, want the stored %q", got, "away") + } + u, _ = database.GetUserByID(ctx, 1) + if u.CustomStatus == nil || *u.CustomStatus != "away" { + t.Fatalf("bare status flip changed the stored text: %v", u.CustomStatus) + } +} + +func TestHandlePresenceUpdate_RejectsUnknownStatusAndOverlongText(t *testing.T) { + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"}) + svc := NewChannelService(database, NewPermissionService(database, permissions.NewChecker(database))) + ctx := context.Background() + + if _, err := svc.HandlePresenceUpdate(ctx, 1, "afk", nil, nil); !errors.Is(err, ErrBadRequest) { + t.Errorf("unknown status err = %v, want ErrBadRequest", err) + } + long := strings.Repeat("x", MaxCustomStatusLen+1) + if _, err := svc.HandlePresenceUpdate(ctx, 1, db.StatusOnline, &long, nil); !errors.Is(err, ErrBadRequest) { + t.Errorf("overlong custom_status err = %v, want ErrBadRequest", err) + } + // The rejected call must not have committed the status either. + u, _ := database.GetUserByID(ctx, 1) + if u.Status == db.StatusOnline { + t.Error("a rejected presence_update must not commit the status") + } +} diff --git a/Server/service/role.go b/Server/service/role.go new file mode 100644 index 00000000..83e4371e --- /dev/null +++ b/Server/service/role.go @@ -0,0 +1,459 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "strings" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// RoleService owns create/edit/delete/reorder of roles. It is the only writer +// of the roles table outside migrations. +// +// Every mutation is checked against the ACTOR's role position rather than +// against a bit alone: MANAGE_ROLES says "may manage roles", the hierarchy says +// "which ones". Without the position rule any holder of the bit could edit the +// role above them — or grant themselves ADMINISTRATOR through a role they +// create — which is the same privilege-escalation hole ChangeUserRole closed +// for role *assignment*. +type RoleService struct { + st Store + perms *PermissionService +} + +// NewRoleService creates a RoleService. +func NewRoleService(st Store, perms *PermissionService) *RoleService { + return &RoleService{st: st, perms: perms} +} + +const ( + // maxRoleNameLen bounds the name so a role label cannot be used to blow up + // every member list and permission modal that renders it. + maxRoleNameLen = 32 + // maxRoles bounds how many roles a server can hold. Reorder normalizes + // positions into 1..N strictly below the actor, so N must stay well under + // the owner position (100) for the hierarchy to remain expressible. + maxRoles = 64 +) + +// hexColorRe matches the two CSS hex forms the client renders (#rgb, #rrggbb). +// Anything else — named colors, rgb(), a bare hex without the hash — is +// rejected rather than normalized: the value is written straight into a style +// attribute by the desktop client and the admin panel. +var hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) + +// RoleInput carries the mutable fields of a role. Pointer fields are "leave +// unchanged" on update; on create a nil Position means "just below the actor". +type RoleInput struct { + Name *string + Color *string // points at "" to clear the color + Permissions *int64 + Position *int +} + +// RoleWithMembers is a role plus how many users currently hold it. The count +// is what makes the delete confirmation honest ("12 members move to Member"). +type RoleWithMembers struct { + db.Role + MemberCount int `json:"member_count"` +} + +// actorRole loads the actor's role through the permission cache and verifies +// MANAGE_ROLES (ADMINISTRATOR bypasses). Every failure is Forbidden: an +// unresolvable role must never authorize a role mutation. +func (s *RoleService) actorRole(ctx context.Context, actorID int64) (*db.Role, error) { + if s.perms == nil { + return nil, fmt.Errorf("%w: permission service unavailable", ErrForbidden) + } + role, err := s.perms.GetRoleForUser(ctx, actorID) + if err != nil || role == nil { + return nil, fmt.Errorf("%w: failed to load actor role", ErrForbidden) + } + if !permissions.HasServerPerm(role.Permissions, permissions.ManageRoles) { + return nil, fmt.Errorf("%w: missing %s permission", ErrForbidden, permissions.Name(permissions.ManageRoles)) + } + return role, nil +} + +// requireBelowActor enforces the hierarchy rule shared by edit, delete and +// reorder: the target role must sit strictly below the actor's own position. +// Equality is refused too, so a peer cannot rewrite the role they both hold. +func requireBelowActor(actor *db.Role, target *db.Role) error { + if target.Position >= actor.Position { + return fmt.Errorf("%w: cannot manage a role at or above your own rank", ErrForbidden) + } + return nil +} + +// requireGrantable enforces "you cannot hand out what you do not hold": every +// bit being ADDED must be present in the actor's own mask. Removing a bit the +// actor lacks is allowed — that is a de-escalation, and refusing it would make +// an over-permissioned role impossible to wind down by anyone but an admin. +// ADMINISTRATOR bypasses, which is what lets the owner grant anything. +func requireGrantable(actor *db.Role, oldPerms, newPerms int64) error { + if permissions.HasAdmin(actor.Permissions) { + return nil + } + added := newPerms &^ oldPerms + if missing := added &^ actor.Permissions; missing != 0 { + return fmt.Errorf("%w: cannot grant a permission your own role lacks (%s)", + ErrForbidden, permissions.Name(missing&-missing)) + } + return nil +} + +// validateName trims, bounds and uniqueness-checks a role name. excludeID is +// the role being renamed (0 on create) so a no-op rename is not a collision. +func (s *RoleService) validateName(ctx context.Context, raw string, excludeID int64) (string, error) { + name := strings.TrimSpace(raw) + if name == "" { + return "", fmt.Errorf("%w: name is required", ErrBadRequest) + } + if len([]rune(name)) > maxRoleNameLen { + return "", fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, maxRoleNameLen) + } + existing, err := s.st.GetRoleByName(ctx, name) + if err != nil { + return "", fmt.Errorf("%w: failed to check role name: %v", ErrInternal, err) + } + if existing != nil && existing.ID != excludeID { + return "", fmt.Errorf("%w: a role named %q already exists", ErrBadRequest, existing.Name) + } + return name, nil +} + +// validateColor normalizes an optional color. A nil pointer means "unset"; an +// empty string clears it. +func validateColor(raw *string) (*string, error) { + if raw == nil { + return nil, nil + } + c := strings.TrimSpace(*raw) + if c == "" { + return nil, nil + } + if !hexColorRe.MatchString(c) { + return nil, fmt.Errorf("%w: color must be a hex value like #5865F2", ErrBadRequest) + } + c = strings.ToUpper(c) + return &c, nil +} + +// validatePosition bounds a requested position: non-negative and strictly below +// the actor's own rank. +func validatePosition(actor *db.Role, pos int) error { + if pos < 0 { + return fmt.Errorf("%w: position must not be negative", ErrBadRequest) + } + if pos >= actor.Position { + return fmt.Errorf("%w: cannot place a role at or above your own rank", ErrForbidden) + } + return nil +} + +// ListRoles returns every role, highest position first, with member counts. +// Gated on MANAGE_ROLES like the mutations — the panel section that shows it is. +func (s *RoleService) ListRoles(ctx context.Context, actorID int64) ([]RoleWithMembers, error) { + if _, err := s.actorRole(ctx, actorID); err != nil { + return nil, err + } + roles, err := s.st.ListRoles(ctx) + if err != nil { + return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err) + } + counts, err := s.st.CountRoleMembers(ctx) + if err != nil { + return nil, fmt.Errorf("%w: failed to count role members: %v", ErrInternal, err) + } + out := make([]RoleWithMembers, 0, len(roles)) + for _, r := range roles { + out = append(out, RoleWithMembers{Role: *r, MemberCount: counts[r.ID]}) + } + return out, nil +} + +// CreateRole creates a role strictly below the actor's own rank. +func (s *RoleService) CreateRole(ctx context.Context, actorID int64, in RoleInput) (*db.Role, error) { + actor, err := s.actorRole(ctx, actorID) + if err != nil { + return nil, err + } + if in.Name == nil { + return nil, fmt.Errorf("%w: name is required", ErrBadRequest) + } + name, err := s.validateName(ctx, *in.Name, 0) + if err != nil { + return nil, err + } + color, err := validateColor(in.Color) + if err != nil { + return nil, err + } + + // Unknown bits are dropped rather than rejected, matching the channel + // override handlers — a client sending a wider mask than this build knows + // about must not be able to persist bits nothing enforces. + perms := int64(0) + if in.Permissions != nil { + perms = *in.Permissions & permissions.AllPerms + } + // Every bit is "added" on create, so the grantable check runs against 0. + if err := requireGrantable(actor, 0, perms); err != nil { + return nil, err + } + + existing, err := s.st.ListRoles(ctx) + if err != nil { + return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err) + } + if len(existing) >= maxRoles { + return nil, fmt.Errorf("%w: server already has the maximum of %d roles", ErrBadRequest, maxRoles) + } + + // Positions must stay unique: every hierarchy comparison uses >=/<=, so two + // roles sharing a position read as equal rank and can never manage each + // other's members. Track the slots already taken. + taken := make(map[int]bool, len(existing)) + for _, rl := range existing { + taken[rl.Position] = true + } + + // Default placement is the highest free slot below the actor — directly + // below when that is free, which is what a manager creating a deputy role + // expects, but stepping past any occupied position so a second create does + // not collide with the first. + position := actor.Position - 1 + if in.Position != nil { + position = *in.Position + // Rank first: an at/above-rank position is a hierarchy violation + // (ErrForbidden) regardless of whether it also happens to be occupied. + if err := validatePosition(actor, position); err != nil { + return nil, err + } + if taken[position] { + return nil, fmt.Errorf("%w: position %d is already used by another role", ErrBadRequest, position) + } + } else { + for position > 0 && taken[position] { + position-- + } + if position <= 0 { + return nil, fmt.Errorf("%w: no free position below your rank — reorder existing roles first", ErrBadRequest) + } + if err := validatePosition(actor, position); err != nil { + return nil, err + } + } + + role, err := s.st.CreateRole(ctx, name, color, perms, position) + if err != nil { + return nil, fmt.Errorf("%w: failed to create role: %v", ErrInternal, err) + } + + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_create", "role", role.ID, + fmt.Sprintf("created role %s (permissions=%#x position=%d)", role.Name, role.Permissions, role.Position)) + slog.Info("role created", "actor_id", actorID, "role_id", role.ID, "name", role.Name) + return role, nil +} + +// UpdateRole applies a partial change to a role below the actor's rank. +// Returns the updated role and whether the permission mask actually moved — +// the caller uses that to decide between a cheap roles_update broadcast and a +// full visibility re-sync. +func (s *RoleService) UpdateRole(ctx context.Context, actorID, roleID int64, in RoleInput) (updated *db.Role, permsChanged bool, err error) { + actor, err := s.actorRole(ctx, actorID) + if err != nil { + return nil, false, err + } + role, err := s.st.GetRoleByID(ctx, roleID) + if err != nil { + return nil, false, fmt.Errorf("%w: failed to fetch role: %v", ErrInternal, err) + } + if role == nil { + return nil, false, fmt.Errorf("%w: role not found", ErrNotFound) + } + if err := requireBelowActor(actor, role); err != nil { + return nil, false, err + } + + name := role.Name + if in.Name != nil { + if name, err = s.validateName(ctx, *in.Name, role.ID); err != nil { + return nil, false, err + } + } + color := role.Color + if in.Color != nil { + if color, err = validateColor(in.Color); err != nil { + return nil, false, err + } + } + perms := role.Permissions + if in.Permissions != nil { + perms = *in.Permissions & permissions.AllPerms + if err := requireGrantable(actor, role.Permissions, perms); err != nil { + return nil, false, err + } + } + position := role.Position + if in.Position != nil { + position = *in.Position + if err := validatePosition(actor, position); err != nil { + return nil, false, err + } + } + + if err := s.st.UpdateRole(ctx, role.ID, name, color, perms, position); err != nil { + return nil, false, fmt.Errorf("%w: failed to update role: %v", ErrInternal, err) + } + + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_update", "role", role.ID, + fmt.Sprintf("updated role %s (permissions=%#x position=%d)", name, perms, position)) + slog.Info("role updated", "actor_id", actorID, "role_id", role.ID, "name", name) + + return &db.Role{ + ID: role.ID, + Name: name, + Color: color, + Permissions: perms, + Position: position, + IsDefault: role.IsDefault, + }, perms != role.Permissions, nil +} + +// DeleteRole removes a role below the actor's rank, moving its members onto the +// default role. Returns the deleted role, the fallback its members landed on, +// and the ids of those members so the caller can invalidate and re-sync them. +func (s *RoleService) DeleteRole(ctx context.Context, actorID, roleID int64) (deleted, fallback *db.Role, movedUserIDs []int64, err error) { + actor, err := s.actorRole(ctx, actorID) + if err != nil { + return nil, nil, nil, err + } + role, err := s.st.GetRoleByID(ctx, roleID) + if err != nil { + return nil, nil, nil, fmt.Errorf("%w: failed to fetch role: %v", ErrInternal, err) + } + if role == nil { + return nil, nil, nil, fmt.Errorf("%w: role not found", ErrNotFound) + } + if err := requireBelowActor(actor, role); err != nil { + return nil, nil, nil, err + } + // Belt and braces: the position rule already puts the owner role out of + // reach (nothing is above position 100), but the seeded owner is named + // explicitly so a database whose positions were edited by hand cannot make + // the server ownerless. + if role.ID == permissions.OwnerRoleID || role.Position >= permissions.OwnerRolePosition { + return nil, nil, nil, fmt.Errorf("%w: the Owner role cannot be deleted", ErrBadRequest) + } + if role.IsDefault { + return nil, nil, nil, fmt.Errorf("%w: the default role cannot be deleted — every member falls back to it", ErrBadRequest) + } + + fallback, err = s.st.GetDefaultRole(ctx) + if err != nil { + return nil, nil, nil, fmt.Errorf("%w: failed to resolve the default role: %v", ErrInternal, err) + } + if fallback == nil { + // Fail closed: without a fallback the members would be orphaned on a + // role id that no longer exists. + return nil, nil, nil, fmt.Errorf("%w: no default role is configured", ErrInternal) + } + + movedUserIDs, err = s.st.DeleteRoleReassigning(ctx, role.ID, fallback.ID) + if err != nil { + return nil, nil, nil, fmt.Errorf("%w: failed to delete role: %v", ErrInternal, err) + } + // The members' cached masks are the deleted role's until this drops them. + if s.perms != nil { + for _, uid := range movedUserIDs { + s.perms.InvalidateUser(uid) + } + } + + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_delete", "role", role.ID, + fmt.Sprintf("deleted role %s (%d members reassigned to %s)", role.Name, len(movedUserIDs), fallback.Name)) + slog.Warn("role deleted", "actor_id", actorID, "role_id", role.ID, "name", role.Name, + "members_reassigned", len(movedUserIDs)) + return role, fallback, movedUserIDs, nil +} + +// ReorderRoles rewrites the positions of every role the actor may manage. +// orderedIDs is highest-rank-first and must name exactly the set of roles +// strictly below the actor — a partial list is refused rather than silently +// leaving the omitted roles wherever they were, which is how positions +// collided in the first place. +// +// Positions are normalized to N..1, so they stay unique, stay strictly below +// the actor (N < actor.Position, enforced by maxRoles), and never collide with +// the untouched roles above the actor. +func (s *RoleService) ReorderRoles(ctx context.Context, actorID int64, orderedIDs []int64) ([]*db.Role, error) { + actor, err := s.actorRole(ctx, actorID) + if err != nil { + return nil, err + } + roles, err := s.st.ListRoles(ctx) + if err != nil { + return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err) + } + + manageable := make(map[int64]*db.Role, len(roles)) + for _, r := range roles { + if r.Position < actor.Position { + manageable[r.ID] = r + } + } + if len(orderedIDs) != len(manageable) { + return nil, fmt.Errorf("%w: the order must list all %d roles below your own rank", ErrBadRequest, len(manageable)) + } + seen := make(map[int64]bool, len(orderedIDs)) + for _, id := range orderedIDs { + if seen[id] { + return nil, fmt.Errorf("%w: role %d listed twice", ErrBadRequest, id) + } + seen[id] = true + if _, ok := manageable[id]; !ok { + // Covers both "unknown role" and "role at or above your rank"; the + // two are deliberately indistinguishable to the caller. + return nil, fmt.Errorf("%w: role %d is not yours to reorder", ErrForbidden, id) + } + } + if len(orderedIDs) >= actor.Position { + return nil, fmt.Errorf("%w: too many roles to place below your own rank", ErrBadRequest) + } + + positions := make(map[int64]int, len(orderedIDs)) + for i, id := range orderedIDs { + positions[id] = len(orderedIDs) - i + } + if err := s.st.SetRolePositions(ctx, positions); err != nil { + return nil, fmt.Errorf("%w: failed to reorder roles: %v", ErrInternal, err) + } + + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "role_reorder", "role", 0, + fmt.Sprintf("reordered %d roles", len(orderedIDs))) + slog.Info("roles reordered", "actor_id", actorID, "count", len(orderedIDs)) + + updated, err := s.st.ListRoles(ctx) + if err != nil { + return nil, fmt.Errorf("%w: failed to list roles: %v", ErrInternal, err) + } + return updated, nil +} + +// AffectedUserIDs returns the ids of the users holding roleID. Handlers use it +// to invalidate exactly the permission-cache entries a role edit touches. +func (s *RoleService) AffectedUserIDs(ctx context.Context, roleID int64) []int64 { + ids, err := s.st.ListUserIDsByRole(ctx, roleID) + if err != nil { + // The caller falls back to a blanket invalidation; a partial list here + // would silently leave stale masks in place. + slog.Warn("role service: failed to list role members", "role_id", roleID, "err", err) + return nil + } + return ids +} diff --git a/Server/service/role_test.go b/Server/service/role_test.go new file mode 100644 index 00000000..363aad7d --- /dev/null +++ b/Server/service/role_test.go @@ -0,0 +1,607 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// newRoleCRUDService builds a RoleService over the hierarchy these tests use, +// layered on the four migration-seeded defaults (Owner 100, Admin 80, +// Moderator 60, Member 40 / is_default): +// +// user 1 → Owner (ADMINISTRATOR, position 100) +// user 2 → Admin (everything but ADMINISTRATOR, position 80) +// user 3 → Moderator(MANAGE_ROLES + a few bits, position 60) +// user 4 → Member (default role, position 40) +// user 5 → Member +// +// The Moderator role is redefined so it holds MANAGE_ROLES but NOT +// MANAGE_SERVER — that gap is what the "cannot grant a bit you lack" tests +// exercise. +func newRoleCRUDService(t *testing.T) (*RoleService, *db.DB) { + t.Helper() + database := newTestDB(t) + seedRole(t, database, &db.Role{ID: permissions.OwnerRoleID, Name: "Owner", + Permissions: permissions.Administrator, Position: permissions.OwnerRolePosition}) + seedRole(t, database, &db.Role{ID: permissions.AdminRoleID, Name: "Admin", + Permissions: permissions.AllPerms &^ permissions.Administrator, Position: 80}) + seedRole(t, database, &db.Role{ID: permissions.ModeratorRoleID, Name: "Moderator", + Permissions: permissions.ManageRoles | permissions.ReadMessages | permissions.SendMessages | + permissions.KickMembers, Position: 60}) + for userID, roleID := range map[int64]int64{ + 1: permissions.OwnerRoleID, + 2: permissions.AdminRoleID, + 3: permissions.ModeratorRoleID, + 4: permissions.MemberRoleID, + 5: permissions.MemberRoleID, + } { + seedUser(t, database, &db.User{ID: userID}) + seedUserRole(t, database, userID, roleID) + } + checker := permissions.NewChecker(database) + return NewRoleService(database, NewPermissionService(database, checker)), database +} + +// ─── Create ────────────────────────────────────────────────────────────────── + +func TestCreateRole_HappyPath(t *testing.T) { + svc, database := newRoleCRUDService(t) + + role, err := svc.CreateRole(context.Background(), 1, RoleInput{ + Name: new(" Helper "), + Color: new("#5865f2"), + Permissions: new(permissions.SendMessages | permissions.ReadMessages), + Position: new(50), + }) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + if role.Name != "Helper" { + t.Errorf("name = %q, want trimmed %q", role.Name, "Helper") + } + if role.Color == nil || *role.Color != "#5865F2" { + t.Errorf("color = %v, want normalized #5865F2", role.Color) + } + if role.Position != 50 { + t.Errorf("position = %d, want 50", role.Position) + } + if role.IsDefault { + t.Error("a created role must never be the default") + } + + stored, err := database.GetRoleByID(context.Background(), role.ID) + if err != nil || stored == nil { + t.Fatalf("GetRoleByID: %v", err) + } + if stored.Permissions != permissions.SendMessages|permissions.ReadMessages { + t.Errorf("stored permissions = %#x", stored.Permissions) + } + + assertAudit(t, database, "role_create") +} + +func TestCreateRole_DefaultsToJustBelowActor(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + // The Admin actor sits at 80, so an unpositioned role lands at 79. + role, err := svc.CreateRole(context.Background(), 2, RoleInput{Name: new("Deputy")}) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + if role.Position != 79 { + t.Errorf("position = %d, want 79 (one below the actor)", role.Position) + } + if role.Permissions != 0 { + t.Errorf("permissions = %#x, want 0 when the body omits them", role.Permissions) + } +} + +// Two roles created back-to-back without an explicit position must not collide: +// tied positions read as equal rank in every hierarchy check, so the second +// default placement steps past the first. +func TestCreateRole_DefaultPlacementAvoidsCollision(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + first, err := svc.CreateRole(context.Background(), 2, RoleInput{Name: new("Deputy")}) + if err != nil { + t.Fatalf("CreateRole first: %v", err) + } + second, err := svc.CreateRole(context.Background(), 2, RoleInput{Name: new("Deputy2")}) + if err != nil { + t.Fatalf("CreateRole second: %v", err) + } + if first.Position == second.Position { + t.Fatalf("two default-placed roles collided at position %d", first.Position) + } + if second.Position != first.Position-1 { + t.Errorf("second position = %d, want %d (the next free slot below the first)", second.Position, first.Position-1) + } +} + +// An explicit position already held by another role is refused rather than +// silently duplicated. +func TestCreateRole_RejectsExplicitPositionCollision(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + first, err := svc.CreateRole(context.Background(), 2, RoleInput{Name: new("Deputy")}) + if err != nil { + t.Fatalf("CreateRole first: %v", err) + } + _, err = svc.CreateRole(context.Background(), 2, RoleInput{Name: new("Clash"), Position: &first.Position}) + if !errors.Is(err, ErrBadRequest) { + t.Fatalf("explicit-collision err = %v, want ErrBadRequest", err) + } +} + +func TestCreateRole_NameUniquenessIsCaseInsensitive(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + if _, err := svc.CreateRole(context.Background(), 1, RoleInput{Name: new("mEmBeR")}); !errors.Is(err, ErrBadRequest) { + t.Fatalf("colliding name: want ErrBadRequest, got %v", err) + } + if _, err := svc.CreateRole(context.Background(), 1, RoleInput{Name: new(" ")}); !errors.Is(err, ErrBadRequest) { + t.Fatalf("blank name: want ErrBadRequest, got %v", err) + } + long := strings.Repeat("x", maxRoleNameLen+1) + if _, err := svc.CreateRole(context.Background(), 1, RoleInput{Name: &long}); !errors.Is(err, ErrBadRequest) { + t.Fatalf("over-long name: want ErrBadRequest, got %v", err) + } +} + +func TestCreateRole_RejectsBadColor(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + for _, color := range []string{"red", "5865F2", "#12345", "rgb(1,2,3)"} { + if _, err := svc.CreateRole(context.Background(), 1, RoleInput{ + Name: new("c-" + color), Color: new(color), + }); !errors.Is(err, ErrBadRequest) { + t.Errorf("color %q: want ErrBadRequest, got %v", color, err) + } + } + // An empty color is "no color", not an error. + role, err := svc.CreateRole(context.Background(), 1, RoleInput{Name: new("Plain"), Color: new("")}) + if err != nil { + t.Fatalf("empty color: %v", err) + } + if role.Color != nil { + t.Errorf("color = %v, want nil", role.Color) + } +} + +func TestCreateRole_RequiresManageRoles(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + // User 4 holds the default Member role — no MANAGE_ROLES. + if _, err := svc.CreateRole(context.Background(), 4, RoleInput{Name: new("Sneaky")}); !errors.Is(err, ErrForbidden) { + t.Fatalf("member create: want ErrForbidden, got %v", err) + } + // An unresolvable actor is Forbidden too, never a silent success. + if _, err := svc.CreateRole(context.Background(), 999, RoleInput{Name: new("Ghost")}); !errors.Is(err, ErrForbidden) { + t.Fatalf("unknown actor: want ErrForbidden, got %v", err) + } +} + +func TestCreateRole_CannotPlaceAtOrAboveOwnRank(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + // Moderator sits at 60. + for _, pos := range []int{60, 80, 100} { + if _, err := svc.CreateRole(context.Background(), 3, RoleInput{ + Name: new("Above"), Position: new(pos), + }); !errors.Is(err, ErrForbidden) { + t.Errorf("position %d: want ErrForbidden, got %v", pos, err) + } + } + if _, err := svc.CreateRole(context.Background(), 3, RoleInput{ + Name: new("Negative"), Position: new(-1), + }); !errors.Is(err, ErrBadRequest) { + t.Errorf("negative position: want ErrBadRequest, got %v", err) + } +} + +func TestCreateRole_CannotGrantUnheldBit(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + // The Moderator role has MANAGE_ROLES but not MANAGE_SERVER. + if _, err := svc.CreateRole(context.Background(), 3, RoleInput{ + Name: new("Escalation"), Permissions: new(permissions.ManageServer), + }); !errors.Is(err, ErrForbidden) { + t.Fatalf("granting an unheld bit: want ErrForbidden, got %v", err) + } + // ADMINISTRATOR bypasses: the owner may grant anything. + if _, err := svc.CreateRole(context.Background(), 1, RoleInput{ + Name: new("Powerful"), Permissions: new(permissions.ManageServer | permissions.BanMembers), + }); err != nil { + t.Fatalf("owner grant: %v", err) + } + // Bits this build does not define are dropped, not persisted. + role, err := svc.CreateRole(context.Background(), 1, RoleInput{ + Name: new("Masked"), Permissions: new(^int64(0)), + }) + if err != nil { + t.Fatalf("CreateRole with unknown bits: %v", err) + } + if role.Permissions != permissions.AllPerms { + t.Errorf("permissions = %#x, want the mask narrowed to AllPerms (%#x)", role.Permissions, permissions.AllPerms) + } +} + +// ─── Update ────────────────────────────────────────────────────────────────── + +func TestUpdateRole_PartialBodyLeavesOtherFields(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + created, err := svc.CreateRole(context.Background(), 1, RoleInput{ + Name: new("Support"), + Color: new("#ABC"), + Permissions: new(permissions.ReadMessages), + Position: new(30), + }) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + + updated, permsChanged, err := svc.UpdateRole(context.Background(), 1, created.ID, RoleInput{ + Name: new("Support Team"), + }) + if err != nil { + t.Fatalf("UpdateRole: %v", err) + } + if permsChanged { + t.Error("permsChanged must be false when the body omits permissions") + } + if updated.Name != "Support Team" { + t.Errorf("name = %q", updated.Name) + } + if updated.Color == nil || *updated.Color != "#ABC" { + t.Errorf("color = %v, want unchanged #ABC", updated.Color) + } + if updated.Permissions != permissions.ReadMessages || updated.Position != 30 { + t.Errorf("permissions/position changed unexpectedly: %#x / %d", updated.Permissions, updated.Position) + } +} + +func TestUpdateRole_ReportsPermissionChange(t *testing.T) { + svc, database := newRoleCRUDService(t) + + created, err := svc.CreateRole(context.Background(), 1, RoleInput{Name: new("Bots")}) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + _, permsChanged, err := svc.UpdateRole(context.Background(), 1, created.ID, RoleInput{ + Permissions: new(permissions.ReadMessages), + }) + if err != nil { + t.Fatalf("UpdateRole: %v", err) + } + if !permsChanged { + t.Error("permsChanged should be true when the mask moves") + } + // Re-applying the same mask is not a change. + _, permsChanged, err = svc.UpdateRole(context.Background(), 1, created.ID, RoleInput{ + Permissions: new(permissions.ReadMessages), + }) + if err != nil { + t.Fatalf("UpdateRole idempotent: %v", err) + } + if permsChanged { + t.Error("permsChanged should be false when the mask is unchanged") + } + assertAudit(t, database, "role_update") +} + +func TestUpdateRole_HierarchyAndGrantRules(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + // A moderator (60) may not edit the Admin role (80) … + if _, _, err := svc.UpdateRole(context.Background(), 3, permissions.AdminRoleID, RoleInput{ + Name: new("Pwned"), + }); !errors.Is(err, ErrForbidden) { + t.Fatalf("edit higher role: want ErrForbidden, got %v", err) + } + // … nor their own role, which is at equal rank. + if _, _, err := svc.UpdateRole(context.Background(), 3, permissions.ModeratorRoleID, RoleInput{ + Permissions: new(permissions.Administrator), + }); !errors.Is(err, ErrForbidden) { + t.Fatalf("edit own role: want ErrForbidden, got %v", err) + } + // … and may not add a bit they lack to a role they can edit. + if _, _, err := svc.UpdateRole(context.Background(), 3, permissions.MemberRoleID, RoleInput{ + Permissions: new(permissions.ManageServer), + }); !errors.Is(err, ErrForbidden) { + t.Fatalf("grant unheld bit: want ErrForbidden, got %v", err) + } + // Removing a bit they lack IS allowed — de-escalation is always safe. + member, err := svc.CreateRole(context.Background(), 1, RoleInput{ + Name: new("Overpowered"), Position: new(20), + Permissions: new(permissions.ManageServer | permissions.ReadMessages), + }) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + if _, _, err := svc.UpdateRole(context.Background(), 3, member.ID, RoleInput{ + Permissions: new(permissions.ReadMessages), + }); err != nil { + t.Fatalf("de-escalating edit: %v", err) + } +} + +func TestUpdateRole_NotFoundAndNameCollision(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + if _, _, err := svc.UpdateRole(context.Background(), 1, 9999, RoleInput{Name: new("Nope")}); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing role: want ErrNotFound, got %v", err) + } + // Renaming onto another role's name (case-insensitively) is refused … + if _, _, err := svc.UpdateRole(context.Background(), 1, permissions.MemberRoleID, RoleInput{ + Name: new("moderator"), + }); !errors.Is(err, ErrBadRequest) { + t.Fatalf("name collision: want ErrBadRequest, got %v", err) + } + // … but re-submitting a role's own name is not a collision. + if _, _, err := svc.UpdateRole(context.Background(), 1, permissions.MemberRoleID, RoleInput{ + Name: new("member"), + }); err != nil { + t.Fatalf("self-rename: %v", err) + } +} + +// ─── Delete ────────────────────────────────────────────────────────────────── + +func TestDeleteRole_ReassignsMembersAndDropsOverrides(t *testing.T) { + svc, database := newRoleCRUDService(t) + ctx := context.Background() + + role, err := svc.CreateRole(ctx, 1, RoleInput{Name: new("Contractor"), Position: new(30)}) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + seedChannel(t, database, &db.Channel{ID: 10, Name: "general"}) + if err := database.UpsertChannelOverride(ctx, 10, role.ID, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + // Two members hold the role; a third stays put so the UPDATE is proven + // to be scoped rather than global. + seedUserRole(t, database, 4, role.ID) + seedUserRole(t, database, 5, role.ID) + + deleted, fallback, moved, err := svc.DeleteRole(ctx, 1, role.ID) + if err != nil { + t.Fatalf("DeleteRole: %v", err) + } + if deleted.ID != role.ID { + t.Errorf("deleted role id = %d, want %d", deleted.ID, role.ID) + } + if fallback.ID != permissions.MemberRoleID || !fallback.IsDefault { + t.Errorf("fallback = %+v, want the default role", fallback) + } + if len(moved) != 2 { + t.Fatalf("moved = %v, want 2 members", moved) + } + + for _, uid := range []int64{4, 5} { + u, err := database.GetUserByID(ctx, uid) + if err != nil || u == nil { + t.Fatalf("GetUserByID(%d): %v", uid, err) + } + if u.RoleID != permissions.MemberRoleID { + t.Errorf("user %d role = %d, want the default %d", uid, u.RoleID, permissions.MemberRoleID) + } + } + if gone, err := database.GetRoleByID(ctx, role.ID); err != nil || gone != nil { + t.Errorf("role still present after delete: %v, %v", gone, err) + } + overrides, err := database.GetChannelOverrides(ctx, 10) + if err != nil { + t.Fatalf("GetChannelOverrides: %v", err) + } + if _, ok := overrides[role.ID]; ok { + t.Error("channel_overrides row for the deleted role survived") + } + // User 3 (Moderator) is untouched — the reassignment is scoped to the role. + if u, _ := database.GetUserByID(ctx, 3); u.RoleID != permissions.ModeratorRoleID { + t.Errorf("unrelated user role changed to %d", u.RoleID) + } + assertAudit(t, database, "role_delete") +} + +func TestDeleteRole_OwnerAndDefaultAreUndeletable(t *testing.T) { + svc, database := newRoleCRUDService(t) + ctx := context.Background() + + // Nothing outranks the Owner role, so even the owner is refused. + if _, _, _, err := svc.DeleteRole(ctx, 1, permissions.OwnerRoleID); !errors.Is(err, ErrForbidden) { + t.Fatalf("delete owner role: want ErrForbidden, got %v", err) + } + // The default role is below the owner, so it clears the hierarchy check and + // is stopped by the is_default rule specifically. + _, _, _, err := svc.DeleteRole(ctx, 1, permissions.MemberRoleID) + if !errors.Is(err, ErrBadRequest) { + t.Fatalf("delete default role: want ErrBadRequest, got %v", err) + } + if role, _ := database.GetRoleByID(ctx, permissions.MemberRoleID); role == nil { + t.Fatal("the default role was deleted") + } + + // A moderator may not delete a role at or above their own rank. + if _, _, _, err := svc.DeleteRole(ctx, 3, permissions.AdminRoleID); !errors.Is(err, ErrForbidden) { + t.Fatalf("delete higher role: want ErrForbidden, got %v", err) + } + if _, _, _, err := svc.DeleteRole(ctx, 3, permissions.ModeratorRoleID); !errors.Is(err, ErrForbidden) { + t.Fatalf("delete own role: want ErrForbidden, got %v", err) + } + if _, _, _, err := svc.DeleteRole(ctx, 1, 9999); !errors.Is(err, ErrNotFound) { + t.Fatalf("delete missing role: want ErrNotFound, got %v", err) + } +} + +func TestDeleteRole_InvalidatesReassignedMembers(t *testing.T) { + svc, database := newRoleCRUDService(t) + ctx := context.Background() + + role, err := svc.CreateRole(ctx, 1, RoleInput{ + Name: new("Temp"), Position: new(30), + Permissions: new(permissions.ReadMessages | permissions.ManageMessages), + }) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + seedUserRole(t, database, 4, role.ID) + + // Warm the cache with the doomed role's mask. + cached, err := svc.perms.GetRoleForUser(ctx, 4) + if err != nil || cached == nil { + t.Fatalf("GetRoleForUser: %v", err) + } + if cached.ID != role.ID { + t.Fatalf("cached role = %d, want %d", cached.ID, role.ID) + } + + if _, _, _, err := svc.DeleteRole(ctx, 1, role.ID); err != nil { + t.Fatalf("DeleteRole: %v", err) + } + + // Without the invalidation this still answers with the deleted role. + after, err := svc.perms.GetRoleForUser(ctx, 4) + if err != nil || after == nil { + t.Fatalf("GetRoleForUser after delete: %v", err) + } + if after.ID != permissions.MemberRoleID { + t.Errorf("cached role after delete = %d, want the default %d", after.ID, permissions.MemberRoleID) + } +} + +// ─── Reorder ───────────────────────────────────────────────────────────────── + +func TestReorderRoles_NormalizesPositions(t *testing.T) { + svc, database := newRoleCRUDService(t) + ctx := context.Background() + + extra, err := svc.CreateRole(ctx, 1, RoleInput{Name: new("Helper"), Position: new(50)}) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + + // The owner may reorder everything below position 100: Admin, Moderator, + // Helper and Member — highest first. + updated, err := svc.ReorderRoles(ctx, 1, []int64{ + permissions.AdminRoleID, extra.ID, permissions.ModeratorRoleID, permissions.MemberRoleID, + }) + if err != nil { + t.Fatalf("ReorderRoles: %v", err) + } + + want := map[int64]int{ + permissions.OwnerRoleID: 100, // untouched, still above everything + permissions.AdminRoleID: 4, + extra.ID: 3, + permissions.ModeratorRoleID: 2, + permissions.MemberRoleID: 1, + } + for _, r := range updated { + if got := want[r.ID]; r.Position != got { + t.Errorf("role %d position = %d, want %d", r.ID, r.Position, got) + } + } + // The returned list is the persisted one. + stored, err := database.ListRoles(ctx) + if err != nil { + t.Fatalf("ListRoles: %v", err) + } + for _, r := range stored { + if got := want[r.ID]; r.Position != got { + t.Errorf("stored role %d position = %d, want %d", r.ID, r.Position, got) + } + } + // Highest position first, as the ready payload expects. + if stored[0].ID != permissions.OwnerRoleID || stored[len(stored)-1].ID != permissions.MemberRoleID { + t.Errorf("order = %d…%d, want owner first and member last", stored[0].ID, stored[len(stored)-1].ID) + } + assertAudit(t, database, "role_reorder") +} + +func TestReorderRoles_RejectsPartialAndForeignLists(t *testing.T) { + svc, _ := newRoleCRUDService(t) + ctx := context.Background() + + // Three roles sit below the owner; a two-element list is refused rather + // than leaving the omitted role at a position that now collides. + if _, err := svc.ReorderRoles(ctx, 1, []int64{permissions.AdminRoleID, permissions.MemberRoleID}); !errors.Is(err, ErrBadRequest) { + t.Fatalf("partial list: want ErrBadRequest, got %v", err) + } + // Duplicates would silently drop a role. + if _, err := svc.ReorderRoles(ctx, 1, []int64{ + permissions.AdminRoleID, permissions.AdminRoleID, permissions.MemberRoleID, + }); !errors.Is(err, ErrBadRequest) { + t.Fatalf("duplicate ids: want ErrBadRequest, got %v", err) + } + // A moderator may reorder only what is below them: naming the Admin role + // is Forbidden, and the count check runs first for a short list. + if _, err := svc.ReorderRoles(ctx, 3, []int64{permissions.AdminRoleID}); !errors.Is(err, ErrForbidden) { + t.Fatalf("foreign role: want ErrForbidden, got %v", err) + } + // Same for an id that does not exist at all. + if _, err := svc.ReorderRoles(ctx, 3, []int64{9999}); !errors.Is(err, ErrForbidden) { + t.Fatalf("unknown role: want ErrForbidden, got %v", err) + } + // A member cannot reorder anything. + if _, err := svc.ReorderRoles(ctx, 4, nil); !errors.Is(err, ErrForbidden) { + t.Fatalf("member reorder: want ErrForbidden, got %v", err) + } +} + +// ─── List ──────────────────────────────────────────────────────────────────── + +func TestListRoles_CarriesMemberCounts(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + list, err := svc.ListRoles(context.Background(), 1) + if err != nil { + t.Fatalf("ListRoles: %v", err) + } + counts := map[int64]int{} + for _, r := range list { + counts[r.ID] = r.MemberCount + } + if counts[permissions.MemberRoleID] != 2 { + t.Errorf("member count = %d, want 2", counts[permissions.MemberRoleID]) + } + if counts[permissions.OwnerRoleID] != 1 { + t.Errorf("owner count = %d, want 1", counts[permissions.OwnerRoleID]) + } + if _, err := svc.ListRoles(context.Background(), 4); !errors.Is(err, ErrForbidden) { + t.Errorf("member list: want ErrForbidden, got %v", err) + } +} + +func TestAffectedUserIDs(t *testing.T) { + svc, _ := newRoleCRUDService(t) + + ids := svc.AffectedUserIDs(context.Background(), permissions.MemberRoleID) + if len(ids) != 2 { + t.Errorf("AffectedUserIDs = %v, want 2 members", ids) + } + if got := svc.AffectedUserIDs(context.Background(), 9999); len(got) != 0 { + t.Errorf("AffectedUserIDs(missing) = %v, want empty", got) + } +} + +// assertAudit fails unless an audit row with the given action exists. +func assertAudit(t *testing.T, database *db.DB, action string) { + t.Helper() + entries, err := database.GetAuditLog(context.Background(), 50, 0) + if err != nil { + t.Fatalf("GetAuditLog: %v", err) + } + for _, e := range entries { + if e.Action == action { + return + } + } + t.Errorf("no %q audit entry written", action) +} diff --git a/Server/service/sanitize_content_fuzz_test.go b/Server/service/sanitize_content_fuzz_test.go new file mode 100644 index 00000000..b9c16d19 --- /dev/null +++ b/Server/service/sanitize_content_fuzz_test.go @@ -0,0 +1,115 @@ +package service + +import ( + "regexp" + "strings" + "testing" + "unicode/utf8" +) + +// onEventAttr matches an on-event handler attribute (onerror=, onload=, etc.) +// living inside an actual surviving tag, case-insensitively, tolerating +// whitespace around the '='. +// +// The check is deliberately tag-scoped (requires a preceding unclosed '<') +// rather than a bare `\bon\w+\s*=` substring match: bluemonday's +// StrictPolicy strips every tag, so the only way "onerror=" et al. can +// survive into `out` is as inert plain text a user actually typed (e.g. a +// message that reads "use onClick= to bind a handler") — that text renders +// as a text node, not as a live attribute, so it is not an active-content +// sink. A bare substring match flags that benign case as a false positive +// (confirmed via fuzzing: seed "on0=" round-trips unchanged through +// sanitizeContent and is not exploitable). Requiring tag context is what +// actually distinguishes "typed the word onclick=" from "smuggled a live +// onclick attribute". +var onEventAttr = regexp.MustCompile(`(?i)<[^>]*\bon\w+\s*=`) + +// jsURLInTag matches a javascript: (or similar) pseudo-scheme living inside +// an actual surviving tag's attribute — the only shape that is an active +// sink. Like onEventAttr, this is tag-scoped rather than a bare substring +// match: bluemonday's StrictPolicy strips every tag (and HTML-escapes any +// stray '<'), so "javascript:" surviving into `out` at all means it arrived +// as inert plain text (e.g. a message that reads "the demo used +// javascript:void(0) links") — not as a live href. Fuzzing confirmed the +// bare-substring version false-positives on exactly that case (seed +// "jAvAsCript:0"), and the client's own markdown renderer independently +// refuses to autolink a javascript: pseudo-URL (see +// tauri-client/tests/unit/content-markdown.test.ts, "does not autolink a +// javascript: pseudo-URL"), so plain-text "javascript:" is not exploitable +// through any known rendering path. +var jsURLInTag = regexp.MustCompile(`(?i)<[^>]*\bjavascript:`) + +// FuzzSanitizeContent hammers sanitizeContent with untrusted message content +// looking for a case where the "strip everything" bluemonday policy still +// lets an active-content sink survive (a real XSS in every client that +// renders message content), or where the length/idempotency contract the +// function documents (implicitly, via maxMessageLen and repeated calls in +// the edit path) breaks. +func FuzzSanitizeContent(f *testing.F) { + seeds := []string{ + "", + "hello world", + "", + "", + "ipt>alert(1)ipt>", + ``, + ``, + `click`, + `click`, + `click`, + `
hi
`, + ``, + ``, + "", + "plain bold and italic", + strings.Repeat("a", maxMessageLen*4-1), + strings.Repeat("a", maxMessageLen*4+1), + strings.Repeat("é", maxMessageLen+10), + "\u200b\u200c\u200dzero-width", + "\u202ereversed bidi text\u202c", + "</script>", + "<script>alert(1)</script>", + "", + "\x00\x01\x02 control chars", + "line1\nline2\r\nline3", + strings.Repeat("", 50), + } + for _, s := range seeds { + f.Add(s, true) + f.Add(s, false) + } + + f.Fuzz(func(t *testing.T, raw string, allowEmpty bool) { + out, err := sanitizeContent(raw, allowEmpty) + if err != nil { + // sanitizeContent may legitimately reject input (too long, or + // empty when not allowed); nothing further to check. + return + } + + lower := strings.ToLower(out) + if strings.Contains(lower, " maxMessageLen { + t.Fatalf("sanitizeContent(%q, %v) = %q: %d runes exceeds maxMessageLen %d", raw, allowEmpty, out, n, maxMessageLen) + } + + // Idempotency: re-sanitizing already-sanitized output must be a + // no-op. allowEmpty=true so a legitimately-empty `out` doesn't + // spuriously error on the second pass. + out2, err2 := sanitizeContent(out, true) + if err2 != nil { + t.Fatalf("sanitizeContent(%q, %v) = %q, but re-sanitizing it failed: %v", raw, allowEmpty, out, err2) + } + if out2 != out { + t.Fatalf("sanitizeContent not idempotent: sanitizeContent(%q,true) = %q, sanitizing that again gave %q", raw, out, out2) + } + }) +} diff --git a/Server/service/seed_test.go b/Server/service/seed_test.go index 163c2432..30ace65b 100644 --- a/Server/service/seed_test.go +++ b/Server/service/seed_test.go @@ -35,6 +35,18 @@ func newTestDB(t *testing.T) *db.DB { // collides with one of the migration-seeded defaults). func seedRole(t *testing.T, database *db.DB, r *db.Role) { t.Helper() + // Migration 023 made role names unique case-insensitively, so a test that + // redefines role 3 as "member" now collides with the seeded "Member" + // (role 4). Free the name from whichever OTHER role holds it rather than + // making every test pick names that dodge the four defaults — the + // displaced role keeps its id, permissions and position, which is all the + // tests read it for. + if _, err := database.ExecContext(context.Background(), + `UPDATE roles SET name = name || ' #' || id WHERE name = ? COLLATE NOCASE AND id != ?`, + r.Name, r.ID, + ); err != nil { + t.Fatalf("seedRole(%d) freeing name %q: %v", r.ID, r.Name, err) + } _, err := database.ExecContext(context.Background(), `INSERT INTO roles (id, name, color, permissions, position, is_default) VALUES (?, ?, ?, ?, ?, 0) diff --git a/Server/service/service.go b/Server/service/service.go index 081e9689..6c84217e 100644 --- a/Server/service/service.go +++ b/Server/service/service.go @@ -20,6 +20,8 @@ type Services struct { Invites *InviteService Blocks *BlockService Moderation *ModerationService + Roles *RoleService + Emoji *EmojiService } // New creates all domain services wired together. @@ -35,5 +37,7 @@ func New(st Store, limiter *auth.RateLimiter) *Services { Invites: NewInviteService(st), Blocks: NewBlockService(st), Moderation: NewModerationService(st, permSvc), + Roles: NewRoleService(st, permSvc), + Emoji: NewEmojiService(st, permSvc), } } diff --git a/Server/service/testdata/fuzz/FuzzSanitizeContent/c597d5b9888593d8 b/Server/service/testdata/fuzz/FuzzSanitizeContent/c597d5b9888593d8 new file mode 100644 index 00000000..9144487d --- /dev/null +++ b/Server/service/testdata/fuzz/FuzzSanitizeContent/c597d5b9888593d8 @@ -0,0 +1,3 @@ +go test fuzz v1 +string("jAvAsCript:0") +bool(false) diff --git a/Server/service/testdata/fuzz/FuzzSanitizeContent/d5f11853eed0e37f b/Server/service/testdata/fuzz/FuzzSanitizeContent/d5f11853eed0e37f new file mode 100644 index 00000000..2160f2cd --- /dev/null +++ b/Server/service/testdata/fuzz/FuzzSanitizeContent/d5f11853eed0e37f @@ -0,0 +1,3 @@ +go test fuzz v1 +string("on0=") +bool(true) diff --git a/Server/service/user.go b/Server/service/user.go index acd3a207..7f4ebed0 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "log/slog" + "strings" "time" + "unicode/utf8" "github.com/owncord/server/db" "github.com/owncord/server/telemetry" @@ -21,9 +23,71 @@ func NewUserService(st Store) *UserService { return &UserService{st: st} } -// UpdateProfile updates a user's username and/or avatar. -// Returns the updated user for response building. -func (s *UserService) UpdateProfile(ctx context.Context, userID int64, username string, avatar *string) (*db.User, error) { +// AvatarFileURL is the server-relative path an uploaded avatar is served from. +// It is the ordinary attachment route: the upload handler writes an attachment +// row and points users.avatar here, and handleServeFile admits an unlinked +// attachment that some user's avatar names. Defined once because three places +// have to agree on the spelling — the upload response, the stored column, and +// the file route's authorization probe, which matches the column *by string*. +func AvatarFileURL(fileID string) string { + return "/api/v1/files/" + fileID +} + +// ─── Profile field bounds ─────────────────────────────────────────────────── + +const ( + // MaxDisplayNameLen bounds users.display_name. 32 matches the username + // cap: a nickname that could not fit where a username fits would render + // clipped in exactly the places the fallback puts the username. + MaxDisplayNameLen = 32 + // MaxAboutLen bounds users.about — long enough for a paragraph, short + // enough that the popup's two-line section stays a section. + MaxAboutLen = 300 + // MaxCustomStatusLen bounds users.custom_status: one line under a name. + MaxCustomStatusLen = 128 +) + +// ProfilePatch is a partial update to a user's profile. A nil field means +// "leave unchanged"; a non-nil pointer to the empty string clears the nullable +// fields (display name, about). The free-text fields are sanitized and +// length-checked *here* rather than in the handler, so every transport that +// can reach a profile gets the same rules. +type ProfilePatch struct { + Username string + Avatar *string + DisplayName *string + About *string +} + +// nullable turns a sanitized, trimmed patch value into the column value: +// empty string clears the column, anything else is stored as-is. +func nullable(v string) *string { + if v == "" { + return nil + } + return &v +} + +// cleanText strips HTML and trims a free-text profile field. Both the profile +// PATCH and the presence path run values through it before any bound check, so +// a payload cannot buy length with markup that is about to be stripped anyway. +func cleanText(v string) string { + return strings.TrimSpace(sanitizer.Sanitize(v)) +} + +// resolveOptional picks the column value for one nullable text field: the +// sanitized patch when it was supplied, the existing row otherwise. +func resolveOptional(patch *string, existing *string) *string { + if patch == nil { + return existing + } + return nullable(cleanText(*patch)) +} + +// UpdateProfile applies a ProfilePatch: username and avatar as before, plus +// the nullable display name and about text. Returns the updated user for +// response building. +func (s *UserService) UpdateProfile(ctx context.Context, userID int64, patch ProfilePatch) (*db.User, error) { ctx, span := telemetry.GlobalTracer("service/user").Start(ctx, "UserService.UpdateProfile", telemetry.Int64("user_id", userID), ) @@ -34,7 +98,28 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, username span.End() }() - if err := s.st.UpdateUserProfile(ctx, userID, username, avatar); err != nil { + if patch.DisplayName != nil && utf8.RuneCountInString(cleanText(*patch.DisplayName)) > MaxDisplayNameLen { + return nil, fmt.Errorf("%w: display_name must be at most %d characters", ErrBadRequest, MaxDisplayNameLen) + } + if patch.About != nil && utf8.RuneCountInString(cleanText(*patch.About)) > MaxAboutLen { + return nil, fmt.Errorf("%w: about must be at most %d characters", ErrBadRequest, MaxAboutLen) + } + + // The update writes every column, so a partial patch has to be merged + // against the current row first — otherwise setting only a display name + // would silently clear the about text. + current, err := s.st.GetUserByID(ctx, userID) + if err != nil || current == nil { + return nil, fmt.Errorf("%w: user not found", ErrNotFound) + } + avatar := current.Avatar + if patch.Avatar != nil { + avatar = nullable(*patch.Avatar) + } + displayName := resolveOptional(patch.DisplayName, current.DisplayName) + about := resolveOptional(patch.About, current.About) + + if err := s.st.UpdateUserProfile(ctx, userID, patch.Username, avatar, displayName, about); err != nil { if db.IsUniqueConstraintError(err) { return nil, fmt.Errorf("%w: username is already taken", ErrConflict) } @@ -46,11 +131,36 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, username } // Audit rows must survive a request canceled after the write committed. db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "profile_update", "user", userID, - fmt.Sprintf("username=%s", username)) - slog.Info("profile updated", "user_id", userID, "username", username) + fmt.Sprintf("username=%s", patch.Username)) + slog.Info("profile updated", "user_id", userID, "username", patch.Username) return user, nil } +// SetCustomStatus stores (or clears, with an empty string) the user's custom +// status line. It is the presence path's counterpart to UpdateProfile: the +// value persists across reconnects and is cleared explicitly on logout, which +// is why it is stored rather than held on the connection. +func (s *UserService) SetCustomStatus(ctx context.Context, userID int64, text string) error { + cleaned := cleanText(text) + if utf8.RuneCountInString(cleaned) > MaxCustomStatusLen { + return fmt.Errorf("%w: custom_status must be at most %d characters", ErrBadRequest, MaxCustomStatusLen) + } + if err := s.st.UpdateUserCustomStatus(ctx, userID, nullable(cleaned)); err != nil { + return fmt.Errorf("%w: failed to update custom status: %v", ErrInternal, err) + } + return nil +} + +// ClearCustomStatus wipes the custom status line. Called on logout: the text +// is a "what I am doing right now" note, and leaving it standing after the +// user signed out states something about them that is no longer true. +func (s *UserService) ClearCustomStatus(ctx context.Context, userID int64) error { + if err := s.st.UpdateUserCustomStatus(ctx, userID, nil); err != nil { + return fmt.Errorf("%w: failed to clear custom status: %v", ErrInternal, err) + } + return nil +} + // UpdateIdentityKey publishes the user's long-term E2EE identity public key // (F3 voice E2EE TOFU). Last write wins; every write is audited so a key // rotation — which peers surface as a TOFU mismatch — leaves a trail. diff --git a/Server/storage/storage_filename_fuzz_test.go b/Server/storage/storage_filename_fuzz_test.go new file mode 100644 index 00000000..5388854e --- /dev/null +++ b/Server/storage/storage_filename_fuzz_test.go @@ -0,0 +1,92 @@ +package storage + +import ( + "path/filepath" + "strings" + "testing" +) + +// FuzzSanitizeFilename checks sanitizeFilename's own documented contract +// (no separators, not "."/".."/"" and not a leading dot) and, more +// importantly, its safety composition with (*Storage).resolvedPath: any name +// that sanitizeFilename accepts must resolve to a path that both succeeds +// and stays inside the storage directory. A name that passes sanitize but +// escapes the directory in resolvedPath would be a real path-traversal bug. +func FuzzSanitizeFilename(f *testing.F) { + seeds := []string{ + "", + ".", + "..", + "...", + "/", + "\\", + "a/b", + "a\\b", + ".hidden", + "..secret", + "normal-file.txt", + "/etc/passwd", + "../../../etc/passwd", + "..\\..\\windows\\system32\\config", + "a/../../b", + "~root", + "foo/", + "/foo", + "foo\x00bar", + "con", + "NUL", + strings.Repeat("a", 300), + "..a", + "a..", + "a.b", + } + for _, s := range seeds { + f.Add(s) + } + + dir := f.TempDir() + s, err := New(dir, 10) + if err != nil { + f.Fatalf("New(%q, 10) failed: %v", dir, err) + } + absDir, err := filepath.Abs(dir) + if err != nil { + f.Fatalf("filepath.Abs(%q) failed: %v", dir, err) + } + + f.Fuzz(func(t *testing.T, name string) { + err := sanitizeFilename(name) + if err != nil { + // sanitizeFilename rejected it -- nothing further to assert about + // its own contract, but we still cross-check resolvedPath below + // isn't the sole gatekeeper (defense in depth), so just return. + return + } + + // sanitizeFilename returned nil: verify its documented contract. + if name == "" { + t.Fatalf("sanitizeFilename(%q) = nil but name is empty", name) + } + if name == "." || name == ".." { + t.Fatalf("sanitizeFilename(%q) = nil but name is a reserved dot-name", name) + } + if strings.HasPrefix(name, ".") { + t.Fatalf("sanitizeFilename(%q) = nil but name starts with '.'", name) + } + if strings.ContainsAny(name, "/\\") { + t.Fatalf("sanitizeFilename(%q) = nil but name contains a path separator", name) + } + if filepath.Base(name) != name { + t.Fatalf("sanitizeFilename(%q) = nil but filepath.Base(name) = %q differs", name, filepath.Base(name)) + } + + // Safety composition: resolvedPath must succeed and stay inside dir. + target, err := s.resolvedPath(name) + if err != nil { + t.Fatalf("sanitizeFilename(%q) = nil but resolvedPath failed: %v", name, err) + } + if target != absDir && !strings.HasPrefix(target, absDir+string(filepath.Separator)) { + t.Fatalf("sanitizeFilename(%q) = nil but resolvedPath(%q) = %q escapes storage dir %q", name, name, target, absDir) + } + }) +} diff --git a/Server/storage/validate_filetype_fuzz_test.go b/Server/storage/validate_filetype_fuzz_test.go new file mode 100644 index 00000000..066d3c94 --- /dev/null +++ b/Server/storage/validate_filetype_fuzz_test.go @@ -0,0 +1,56 @@ +package storage + +import ( + "bytes" + "testing" +) + +// FuzzValidateFileType asserts ValidateFileType never panics on any header +// byte slice (nil, empty, short, or huge) and that it returns an error iff +// the header actually starts with one of the blocked magic prefixes -- no +// false positives, no false negatives. +func FuzzValidateFileType(f *testing.F) { + seeds := [][]byte{ + nil, + {}, + {0}, + []byte("MZ"), + []byte("M"), + []byte("\x7fELF"), + []byte("\x7fEL"), + []byte("\xcf\xfa\xed\xfe"), + []byte("\xce\xfa\xed\xfe"), + []byte("#!"), + []byte("#!/bin/sh\nrm -rf /"), + []byte("\xca\xfe\xba\xbe"), + []byte("\xd0\xcf\x11\xe0"), + []byte("\x00asm"), + {0x4c, 0x00, 0x00, 0x00}, + []byte("PNG"), + []byte("\x89PNG\r\n\x1a\n"), + bytes.Repeat([]byte{0xff}, 4096), + []byte("GIF89a"), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, header []byte) { + err := ValidateFileType(header) + + wantBlocked := false + for _, blocked := range blockedMagic { + if len(header) >= len(blocked.magic) && bytes.Equal(header[:len(blocked.magic)], blocked.magic) { + wantBlocked = true + break + } + } + + if wantBlocked && err == nil { + t.Fatalf("ValidateFileType(%x) = nil, want error (header matches a blocked magic prefix)", header) + } + if !wantBlocked && err != nil { + t.Fatalf("ValidateFileType(%x) = %v, want nil (header matches no blocked magic prefix)", header, err) + } + }) +} diff --git a/Server/ws/channel_flags_ready_test.go b/Server/ws/channel_flags_ready_test.go new file mode 100644 index 00000000..14b7f952 --- /dev/null +++ b/Server/ws/channel_flags_ready_test.go @@ -0,0 +1,102 @@ +package ws_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/owncord/server/db" +) + +// The channel feature flags (nsfw + the two voice capacity limits) have to be +// in `ready` and not only in the channel_update broadcast: a client that has +// just connected has received no broadcasts, and the desktop client pre-fills +// its edit modal and draws the sidebar's age-gate indicator straight from the +// channel store. +func TestBuildReady_CarriesChannelFeatureFlags(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "flags-user") + role, err := database.GetRoleByID(context.Background(), 1) + if err != nil || role == nil { + t.Fatalf("GetRoleByID: %v", err) + } + + plainID, err := database.CreateChannel(context.Background(), "general", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + loungeID, err := database.CreateChannel(context.Background(), "lounge", "voice", "", "", 1) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.AdminUpdateChannel(context.Background(), loungeID, db.ChannelUpdate{ + Name: "lounge", + SlowMode: 30, + Position: 1, + NSFW: true, + VoiceMaxUsers: 5, + VoiceMaxVideo: 2, + }); err != nil { + t.Fatalf("AdminUpdateChannel: %v", err) + } + + msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role) + if err != nil { + t.Fatalf("BuildReadyWithRoleForTest: %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) + } + + byID := make(map[float64]map[string]any, len(env.Payload.Channels)) + for _, ch := range env.Payload.Channels { + id, ok := ch["id"].(float64) + if !ok { + t.Fatalf("channel %v has no numeric id", ch) + } + byID[id] = ch + } + + lounge, ok := byID[float64(loungeID)] + if !ok { + t.Fatalf("lounge missing from ready channels: %v", env.Payload.Channels) + } + if lounge["nsfw"] != true { + t.Errorf("lounge nsfw = %v, want true", lounge["nsfw"]) + } + if lounge["voice_max_users"] != float64(5) { + t.Errorf("lounge voice_max_users = %v, want 5", lounge["voice_max_users"]) + } + if lounge["voice_max_video"] != float64(2) { + t.Errorf("lounge voice_max_video = %v, want 2", lounge["voice_max_video"]) + } + if lounge["slow_mode"] != float64(30) { + t.Errorf("lounge slow_mode = %v, want 30", lounge["slow_mode"]) + } + + // An unflagged channel sends the keys with their zero values rather than + // omitting them — "absent" must not have to mean two different things. + plain, ok := byID[float64(plainID)] + if !ok { + t.Fatalf("general missing from ready channels: %v", env.Payload.Channels) + } + for key, want := range map[string]any{ + "nsfw": false, + "voice_max_users": float64(0), + "voice_max_video": float64(0), + } { + got, present := plain[key] + if !present { + t.Errorf("general is missing %q", key) + continue + } + if got != want { + t.Errorf("general %s = %v, want %v", key, got, want) + } + } +} diff --git a/Server/ws/channel_visibility_agreement_test.go b/Server/ws/channel_visibility_agreement_test.go index 1cadf179..2311d61c 100644 --- a/Server/ws/channel_visibility_agreement_test.go +++ b/Server/ws/channel_visibility_agreement_test.go @@ -170,6 +170,108 @@ func TestChannelVisibility_RESTWSAgreement(t *testing.T) { } } +// TestChannelVisibility_UserOverrideAgreement extends the invariant to the +// per-user override layer: the same three sites must agree for two members who +// share a role but carry different channel_user_overrides rows. A per-role memo +// anywhere in the three paths would show up here as a disagreement. +func TestChannelVisibility_UserOverrideAgreement(t *testing.T) { + database := openServeTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter, nil) + svc := service.New(database, limiter) + + openID, err := database.CreateChannel(context.Background(), "open", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel open: %v", err) + } + lockedID, err := database.CreateChannel(context.Background(), "locked", "text", "", "", 1) + if err != nil { + t.Fatalf("CreateChannel locked: %v", err) + } + + const roleMember = 4 + // The role cannot read #locked at all. + if err := database.UpsertChannelOverride(context.Background(), lockedID, roleMember, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + // Three members of the SAME role, differing only by their user override. + plain := seedVisibilityUser(t, database, "uo-plain", roleMember) + granted := seedVisibilityUser(t, database, "uo-granted", roleMember) + revoked := seedVisibilityUser(t, database, "uo-revoked", roleMember) + + // granted: user allow on the locked channel beats the role deny. + if err := database.UpsertChannelUserOverride(context.Background(), lockedID, granted.ID, permissions.ReadMessages, 0); err != nil { + t.Fatalf("UpsertChannelUserOverride granted: %v", err) + } + // revoked: user deny on the open channel beats the base READ grant. + if err := database.UpsertChannelUserOverride(context.Background(), openID, revoked.ID, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelUserOverride revoked: %v", err) + } + + cases := []struct { + name string + user *db.User + want map[int64]bool + }{ + {"no user override sees only the open channel", plain, idSet([]int64{openID})}, + {"user allow reveals the role-denied channel", granted, idSet([]int64{openID, lockedID})}, + {"user deny hides an otherwise readable channel", revoked, idSet([]int64{})}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + role, err := database.GetRoleByID(context.Background(), tc.user.RoleID) + if err != nil || role == nil { + t.Fatalf("GetRoleByID: %v", err) + } + + restChans, err := svc.Channels.ListVisibleChannels(context.Background(), tc.user.ID) + if err != nil { + t.Fatalf("ListVisibleChannels: %v", err) + } + restSet := make(map[int64]bool, len(restChans)) + for i := range restChans { + restSet[restChans[i].ID] = true + } + + readyRaw, err := hub.BuildReadyWithRoleForTest(database, tc.user.ID, role) + if err != nil { + t.Fatalf("BuildReadyWithRoleForTest: %v", err) + } + var ready struct { + Payload struct { + Channels []struct { + ID int64 `json:"id"` + } `json:"channels"` + } `json:"payload"` + } + if err := json.Unmarshal(readyRaw, &ready); err != nil { + t.Fatalf("unmarshal ready: %v", err) + } + readySet := make(map[int64]bool, len(ready.Payload.Channels)) + for _, ch := range ready.Payload.Channels { + readySet[ch.ID] = true + } + + allowed, err := hub.ComputeAllowedChannelsForTest(database, tc.user) + if err != nil { + t.Fatalf("ComputeAllowedChannelsForTest: %v", err) + } + + for label, got := range map[string]map[int64]bool{ + "REST ListVisibleChannels": restSet, + "WS buildReady": readySet, + "replay computeAllowed": allowed, + } { + if !equalSets(got, tc.want) { + t.Errorf("%s = %v, want %v", label, sortedKeys(got), sortedKeys(tc.want)) + } + } + }) + } +} + func equalSets(a, b map[int64]bool) bool { if len(a) != len(b) { return false diff --git a/Server/ws/command.go b/Server/ws/command.go index a54e0b77..b93591ec 100644 --- a/Server/ws/command.go +++ b/Server/ws/command.go @@ -91,11 +91,18 @@ func (c TypingStartCmd) ChannelID() int64 { return c.channelID } type PresenceUpdateCmd struct { userID int64 status string + // customStatus is nil when the payload carried no custom_status field at + // all, which means "leave the stored text alone". A present-but-empty + // string clears it. The distinction matters because the auto-idle timer + // sends a bare status flip several times an hour and must not wipe the + // text the user typed. + customStatus *string } -func (c PresenceUpdateCmd) Type() string { return MsgTypePresenceUpdate } -func (c PresenceUpdateCmd) UserID() int64 { return c.userID } -func (c PresenceUpdateCmd) Status() string { return c.status } +func (c PresenceUpdateCmd) Type() string { return MsgTypePresenceUpdate } +func (c PresenceUpdateCmd) UserID() int64 { return c.userID } +func (c PresenceUpdateCmd) Status() string { return c.status } +func (c PresenceUpdateCmd) CustomStatus() *string { return c.customStatus } // ChannelFocusCmd represents a channel_focus message. type ChannelFocusCmd struct { @@ -107,6 +114,19 @@ func (c ChannelFocusCmd) Type() string { return MsgTypeChannelFocus } func (c ChannelFocusCmd) UserID() int64 { return c.userID } func (c ChannelFocusCmd) ChannelID() int64 { return c.channelID } +// MarkReadCmd represents a mark_read message: advance the caller's read state +// for a channel without making it their focused channel. channel_focus already +// marks read, but it also rebinds the connection's focused channel, so it is +// the wrong tool for "mark that other channel read from its context menu". +type MarkReadCmd struct { + userID int64 + channelID int64 +} + +func (c MarkReadCmd) Type() string { return MsgTypeMarkRead } +func (c MarkReadCmd) UserID() int64 { return c.userID } +func (c MarkReadCmd) ChannelID() int64 { return c.channelID } + // ReactionAddCmd represents a reaction_add message. type ReactionAddCmd struct { userID int64 @@ -197,6 +217,62 @@ func (c VoiceScreenshareCmd) Type() string { return MsgTypeVoiceScreenshare } func (c VoiceScreenshareCmd) UserID() int64 { return c.userID } func (c VoiceScreenshareCmd) Enabled() bool { return c.enabled } +// VoiceModMuteCmd represents a voice_mod_mute message: a moderator setting +// another user's server mute. channelID is the voice channel the moderator +// believes the target is in — the handler refuses when it disagrees, so a +// stale sidebar cannot mute someone who has since moved. +type VoiceModMuteCmd struct { + userID int64 + channelID int64 + targetID int64 + muted bool +} + +func (c VoiceModMuteCmd) Type() string { return MsgTypeVoiceModMute } +func (c VoiceModMuteCmd) UserID() int64 { return c.userID } +func (c VoiceModMuteCmd) ChannelID() int64 { return c.channelID } +func (c VoiceModMuteCmd) TargetID() int64 { return c.targetID } +func (c VoiceModMuteCmd) Muted() bool { return c.muted } + +// VoiceModDeafenCmd represents a voice_mod_deafen message. See VoiceModMuteCmd +// for the channelID contract. +type VoiceModDeafenCmd struct { + userID int64 + channelID int64 + targetID int64 + deafened bool +} + +func (c VoiceModDeafenCmd) Type() string { return MsgTypeVoiceModDeafen } +func (c VoiceModDeafenCmd) UserID() int64 { return c.userID } +func (c VoiceModDeafenCmd) ChannelID() int64 { return c.channelID } +func (c VoiceModDeafenCmd) TargetID() int64 { return c.targetID } +func (c VoiceModDeafenCmd) Deafened() bool { return c.deafened } + +// VoiceModMoveCmd represents a voice_mod_move message: a moderator moving a +// user to another voice channel. +type VoiceModMoveCmd struct { + userID int64 + targetID int64 + toChannelID int64 +} + +func (c VoiceModMoveCmd) Type() string { return MsgTypeVoiceModMove } +func (c VoiceModMoveCmd) UserID() int64 { return c.userID } +func (c VoiceModMoveCmd) TargetID() int64 { return c.targetID } +func (c VoiceModMoveCmd) ToChannelID() int64 { return c.toChannelID } + +// VoiceModKickCmd represents a voice_mod_kick message: a moderator +// disconnecting a user from voice. +type VoiceModKickCmd struct { + userID int64 + targetID int64 +} + +func (c VoiceModKickCmd) Type() string { return MsgTypeVoiceModKick } +func (c VoiceModKickCmd) UserID() int64 { return c.userID } +func (c VoiceModKickCmd) TargetID() int64 { return c.targetID } + // VoiceE2EEAnnounceCmd represents a voice_e2ee_announce message. // signature is the ECDSA identity-key signature over the ephemeral public key // (F3 TOFU); optional at the protocol level — legacy clients omit it and the @@ -246,8 +322,73 @@ func (c VoiceE2EEOfferCmd) TargetUserID() int64 { return c.targetUserID } func (c VoiceE2EEOfferCmd) EncryptedKey() string { return c.encryptedKey } func (c VoiceE2EEOfferCmd) IV() string { return c.iv } +// CallRingCmd represents a call_ring message: "start ringing the other people +// in this DM". It carries only the channel — who is calling is the +// authenticated sender, and there is no call id because there is no call +// record (see registerCallHandlers). +type CallRingCmd struct { + userID int64 + channelID int64 +} + +func (c CallRingCmd) Type() string { return MsgTypeCallRing } +func (c CallRingCmd) UserID() int64 { return c.userID } +func (c CallRingCmd) ChannelID() int64 { return c.channelID } + +// CallDeclineCmd represents a call_decline message. +type CallDeclineCmd struct { + userID int64 + channelID int64 +} + +func (c CallDeclineCmd) Type() string { return MsgTypeCallDecline } +func (c CallDeclineCmd) UserID() int64 { return c.userID } +func (c CallDeclineCmd) ChannelID() int64 { return c.channelID } + // ── Command constructors ──────────────────────────────────────────────────── +// parseModTarget parses the (channel, target user) pair every voice moderation +// payload carries. Both must be positive: a non-positive id can only come from +// a malformed client, and rejecting at parse time keeps the handlers free of +// id-shape checks. +func parseModTarget(channelID, userID json.Number) (int64, int64, error) { + chID, err := channelID.Int64() + if err != nil { + return 0, 0, fmt.Errorf("channel_id must be integer: %w", err) + } + if chID <= 0 { + return 0, 0, fmt.Errorf("channel_id must be positive") + } + targetID, err := userID.Int64() + if err != nil { + return 0, 0, fmt.Errorf("user_id must be integer: %w", err) + } + if targetID <= 0 { + return 0, 0, fmt.Errorf("user_id must be positive") + } + return chID, targetID, nil +} + +// parseCallChannelID parses the lone channel_id both call signalling payloads +// carry. Shared so call_ring and call_decline cannot drift on what counts as a +// valid channel reference. +func parseCallChannelID(msgType string, raw json.RawMessage) (int64, error) { + var p struct { + ChannelID json.Number `json:"channel_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return 0, fmt.Errorf("invalid %s payload: %w", msgType, err) + } + chID, err := p.ChannelID.Int64() + if err != nil { + return 0, fmt.Errorf("channel_id must be integer: %w", err) + } + if chID <= 0 { + return 0, fmt.Errorf("channel_id must be positive") + } + return chID, nil +} + // commandConstructors maps message types to functions that parse payloads // into typed Commands. The userID and reqID come from the envelope and // authenticated client; raw is the JSON payload body. @@ -274,11 +415,17 @@ var commandConstructors = map[string]func(userID int64, reqID string, raw json.R if len(p.Attachments) > 10 { return nil, fmt.Errorf("too many attachments (max 10)") } - // TODO: validate attachment URL scheme (require https://) to prevent - // javascript:, data:, or file: URLs from being stored and relayed. - for i, url := range p.Attachments { - if len(url) > 2048 { - return nil, fmt.Errorf("attachment[%d] URL too long (max 2048)", i) + // These are upload IDs (UUIDs from POST /api/v1/uploads), NOT URLs: the + // client renders an attachment by resolving its id to /api/v1/files/{id}, + // and SendMessage links each id through LinkAttachmentsToMessage, which + // keeps only ids that name a real upload owned by the sender and still + // unlinked. A javascript:/data: string is therefore never stored or + // echoed — it simply matches no row and is dropped. A scheme check would + // be wrong here (it would reject the legitimate UUID ids); the only + // bound that applies is length. + for i, id := range p.Attachments { + if len(id) > 2048 { + return nil, fmt.Errorf("attachment[%d] id too long (max 2048)", i) } } attachments := make([]string, len(p.Attachments)) @@ -350,12 +497,13 @@ var commandConstructors = map[string]func(userID int64, reqID string, raw json.R MsgTypePresenceUpdate: func(userID int64, _ string, raw json.RawMessage) (Command, error) { var p struct { - Status string `json:"status"` + Status string `json:"status"` + CustomStatus *string `json:"custom_status"` } if err := json.Unmarshal(raw, &p); err != nil { return nil, fmt.Errorf("invalid presence_update payload: %w", err) } - return PresenceUpdateCmd{userID: userID, status: p.Status}, nil + return PresenceUpdateCmd{userID: userID, status: p.Status, customStatus: p.CustomStatus}, nil }, MsgTypeChannelFocus: func(userID int64, _ string, raw json.RawMessage) (Command, error) { @@ -375,6 +523,39 @@ var commandConstructors = map[string]func(userID int64, reqID string, raw json.R return ChannelFocusCmd{userID: userID, channelID: chID}, nil }, + MsgTypeMarkRead: func(userID int64, _ string, raw json.RawMessage) (Command, error) { + var p struct { + ChannelID json.Number `json:"channel_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("invalid mark_read payload: %w", err) + } + chID, err := p.ChannelID.Int64() + if err != nil { + return nil, fmt.Errorf("channel_id must be integer: %w", err) + } + if chID <= 0 { + return nil, fmt.Errorf("channel_id must be positive") + } + return MarkReadCmd{userID: userID, channelID: chID}, nil + }, + + MsgTypeCallRing: func(userID int64, _ string, raw json.RawMessage) (Command, error) { + chID, err := parseCallChannelID(MsgTypeCallRing, raw) + if err != nil { + return nil, err + } + return CallRingCmd{userID: userID, channelID: chID}, nil + }, + + MsgTypeCallDecline: func(userID int64, _ string, raw json.RawMessage) (Command, error) { + chID, err := parseCallChannelID(MsgTypeCallDecline, raw) + if err != nil { + return nil, err + } + return CallDeclineCmd{userID: userID, channelID: chID}, nil + }, + MsgTypeReactionAdd: func(userID int64, _ string, raw json.RawMessage) (Command, error) { var p struct { MessageID json.Number `json:"message_id"` @@ -470,6 +651,70 @@ var commandConstructors = map[string]func(userID int64, reqID string, raw json.R return VoiceScreenshareCmd{userID: userID, enabled: p.Enabled}, nil }, + MsgTypeVoiceModMute: func(userID int64, _ string, raw json.RawMessage) (Command, error) { + var p struct { + ChannelID json.Number `json:"channel_id"` + UserID json.Number `json:"user_id"` + Muted bool `json:"muted"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("invalid voice_mod_mute payload: %w", err) + } + chID, targetID, err := parseModTarget(p.ChannelID, p.UserID) + if err != nil { + return nil, err + } + return VoiceModMuteCmd{userID: userID, channelID: chID, targetID: targetID, muted: p.Muted}, nil + }, + + MsgTypeVoiceModDeafen: func(userID int64, _ string, raw json.RawMessage) (Command, error) { + var p struct { + ChannelID json.Number `json:"channel_id"` + UserID json.Number `json:"user_id"` + Deafened bool `json:"deafened"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("invalid voice_mod_deafen payload: %w", err) + } + chID, targetID, err := parseModTarget(p.ChannelID, p.UserID) + if err != nil { + return nil, err + } + return VoiceModDeafenCmd{userID: userID, channelID: chID, targetID: targetID, deafened: p.Deafened}, nil + }, + + MsgTypeVoiceModMove: func(userID int64, _ string, raw json.RawMessage) (Command, error) { + var p struct { + UserID json.Number `json:"user_id"` + ToChannelID json.Number `json:"to_channel_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("invalid voice_mod_move payload: %w", err) + } + toChID, targetID, err := parseModTarget(p.ToChannelID, p.UserID) + if err != nil { + return nil, err + } + return VoiceModMoveCmd{userID: userID, targetID: targetID, toChannelID: toChID}, nil + }, + + MsgTypeVoiceModKick: func(userID int64, _ string, raw json.RawMessage) (Command, error) { + var p struct { + UserID json.Number `json:"user_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("invalid voice_mod_kick payload: %w", err) + } + targetID, err := p.UserID.Int64() + if err != nil { + return nil, fmt.Errorf("user_id must be integer: %w", err) + } + if targetID <= 0 { + return nil, fmt.Errorf("user_id must be positive") + } + return VoiceModKickCmd{userID: userID, targetID: targetID}, nil + }, + MsgTypeVoiceE2EEAnnounce: func(userID int64, _ string, raw json.RawMessage) (Command, error) { var p struct { PublicKey string `json:"public_key"` diff --git a/Server/ws/command_test.go b/Server/ws/command_test.go index 60fd8192..f409c6e1 100644 --- a/Server/ws/command_test.go +++ b/Server/ws/command_test.go @@ -17,6 +17,7 @@ func allClientToServerTypes() []string { MsgTypeTypingStart, MsgTypePresenceUpdate, MsgTypeChannelFocus, + MsgTypeMarkRead, MsgTypeReactionAdd, MsgTypeReactionRemove, MsgTypeVoiceJoin, @@ -53,6 +54,7 @@ func TestCommandTypeAndUserID(t *testing.T) { {"TypingStartCmd", TypingStartCmd{userID: 5, channelID: 11}, MsgTypeTypingStart, 5}, {"PresenceUpdateCmd", PresenceUpdateCmd{userID: 6, status: "online"}, MsgTypePresenceUpdate, 6}, {"ChannelFocusCmd", ChannelFocusCmd{userID: 7, channelID: 12}, MsgTypeChannelFocus, 7}, + {"MarkReadCmd", MarkReadCmd{userID: 7, channelID: 12}, MsgTypeMarkRead, 7}, {"ReactionAddCmd", ReactionAddCmd{userID: 8, messageID: 40, emoji: "👍"}, MsgTypeReactionAdd, 8}, {"ReactionRemoveCmd", ReactionRemoveCmd{userID: 9, messageID: 41, emoji: "👎"}, MsgTypeReactionRemove, 9}, {"VoiceJoinCmd", VoiceJoinCmd{userID: 10, channelID: 13}, MsgTypeVoiceJoin, 10}, @@ -87,6 +89,7 @@ func TestCommandChannelScoped(t *testing.T) { {"ChatSendCmd", ChatSendCmd{channelID: 100}, 100, true}, {"TypingStartCmd", TypingStartCmd{channelID: 200}, 200, true}, {"ChannelFocusCmd", ChannelFocusCmd{channelID: 300}, 300, true}, + {"MarkReadCmd", MarkReadCmd{channelID: 301}, 301, true}, {"VoiceJoinCmd", VoiceJoinCmd{channelID: 400}, 400, true}, {"PingCmd", PingCmd{userID: 1}, 0, false}, {"VoiceLeaveCmd", VoiceLeaveCmd{userID: 1}, 0, false}, @@ -200,6 +203,17 @@ func TestCommandConstructorParseValid(t *testing.T) { } }, }, + { + name: "mark_read", + msgType: MsgTypeMarkRead, + payload: `{"channel_id": 34}`, + checkFn: func(t *testing.T, cmd Command) { + mr := cmd.(MarkReadCmd) + if mr.ChannelID() != 34 { + t.Errorf("ChannelID() = %d, want 34", mr.ChannelID()) + } + }, + }, { name: "reaction_add", msgType: MsgTypeReactionAdd, @@ -356,6 +370,7 @@ func TestCommandConstructorRejectsInvalidJSON(t *testing.T) { MsgTypeTypingStart, MsgTypePresenceUpdate, MsgTypeChannelFocus, + MsgTypeMarkRead, MsgTypeReactionAdd, MsgTypeReactionRemove, MsgTypeVoiceJoin, diff --git a/Server/ws/coverage_helpers_test.go b/Server/ws/coverage_helpers_test.go index f270f63b..5bad9839 100644 --- a/Server/ws/coverage_helpers_test.go +++ b/Server/ws/coverage_helpers_test.go @@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS voice_states ( speaking INTEGER NOT NULL DEFAULT 0, camera INTEGER NOT NULL DEFAULT 0, screenshare INTEGER NOT NULL DEFAULT 0, + server_muted INTEGER NOT NULL DEFAULT 0, + server_deafened 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); diff --git a/Server/ws/coverage_misc_test.go b/Server/ws/coverage_misc_test.go index f6819efb..e710d437 100644 --- a/Server/ws/coverage_misc_test.go +++ b/Server/ws/coverage_misc_test.go @@ -305,7 +305,7 @@ func TestHandlePresence_InvalidStatus(t *testing.T) { raw, _ := json.Marshal(map[string]any{ "type": "presence_update", "payload": map[string]any{ - "status": "invisible", // not allowed per CLAUDE.md + "status": "afk", // not a protocol status (invisible IS one since phase 6) }, }) hub.HandleMessageForTest(c, raw) diff --git a/Server/ws/deps.go b/Server/ws/deps.go index 723b0114..97da312a 100644 --- a/Server/ws/deps.go +++ b/Server/ws/deps.go @@ -15,9 +15,13 @@ import ( // Handlers receive this instead of a mutable *Client pointer, making them // easier to test and reason about. type ClientInfo struct { - UserID int64 - Username string - Avatar *string + UserID int64 + Username string + Avatar *string + // DisplayName is the connection's nickname, nil when unset. Carried + // alongside Username rather than replacing it: renderers fall back to the + // username, and mentions still resolve against it. + DisplayName *string RoleName string ReqID string VoiceChannelID int64 // 0 if not in a voice channel @@ -55,6 +59,20 @@ type VoiceTokenGenerator interface { URL() string } +// VoiceModerator applies the effects of a voice moderation action that reach +// past the acting connection: the SFU and the target's own socket. *Hub +// implements it, and VoiceDeps carries the Hub itself so SetLiveKit's late +// wiring is picked up at call time (same reason as VoiceTokenGenerator). +type VoiceModerator interface { + // MuteParticipant mutes or unmutes the target's published audio at the SFU. + MuteParticipant(ctx context.Context, channelID, userID int64, voiceJoinToken string, muted bool) error + // DisconnectFromVoice runs the voice-leave routine for the target's + // connection. Reports false when the target has no connection on this node. + DisconnectFromVoice(ctx context.Context, userID int64) bool + // SendToUser delivers one server->client frame to the target. + SendToUser(userID int64, msg []byte) bool +} + // KeyHolderChecker reports whether a user is the E2EE key holder for a voice channel. type KeyHolderChecker interface { IsVoiceKeyHolder(channelID, userID int64) bool @@ -83,6 +101,7 @@ type VoiceDeps struct { LiveKit *LiveKitClient TokenGen VoiceTokenGenerator // used by voice_token_refresh V2 KeyHolder KeyHolderChecker // used by voice_token_refresh V2 + Mod VoiceModerator // used by the voice moderation handlers } // ── V2 permission helpers ─────────────────────────────────────────────────── @@ -124,7 +143,7 @@ func requirePerm(ctx context.Context, database *db.DB, perms *permissions.Checke r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing " + label + " permission"}} return &r } - if !perms.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, perm) { + if !perms.HasChannelPerm(ctx, role.Permissions, role.ID, userID, channelID, perm) { r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing " + label + " permission"}} return &r } @@ -146,7 +165,7 @@ func hasPerm(ctx context.Context, database *db.DB, perms *permissions.Checker, p if err != nil || role == nil { return false } - return perms.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, perm) + return perms.HasChannelPerm(ctx, role.Permissions, role.ID, userID, channelID, perm) } // hasChannelAccess is the gate to use when the channel id comes from the client: @@ -217,7 +236,7 @@ func hasChannelAccessLive(ctx context.Context, database *db.DB, perms *permissio if err != nil || role == nil { return false } - if !perms.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, perm) { + if !perms.HasChannelPerm(ctx, role.Permissions, role.ID, userID, channelID, perm) { return false } ch, err := database.GetChannel(ctx, channelID) diff --git a/Server/ws/dm_group_call_test.go b/Server/ws/dm_group_call_test.go new file mode 100644 index 00000000..ded6f86e --- /dev/null +++ b/Server/ws/dm_group_call_test.go @@ -0,0 +1,334 @@ +package ws_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/db" + "github.com/owncord/server/ws" +) + +// absenceWindow is how long an "it must NOT arrive" assertion waits. The +// dispatch it is watching for is synchronous, so anything that was going to +// land has landed well inside it. +const absenceWindow = 100 * time.Millisecond + +// ─── helpers ──────────────────────────────────────────────────────────────── + +// seedGroupDM creates a group DM containing every listed user. +func seedGroupDM(t *testing.T, database *db.DB, name string, userIDs ...int64) int64 { + t.Helper() + ch, err := database.CreateGroupDMChannel(context.Background(), name, userIDs) + if err != nil { + t.Fatalf("seedGroupDM: %v", err) + } + return ch.ID +} + +func callMsg(msgType string, channelID int64) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": msgType, + "payload": map[string]any{"channel_id": channelID}, + }) + return raw +} + +// ─── group DM fan-out ─────────────────────────────────────────────────────── + +// A chat_send into a group DM must reach every other participant, not just +// "the recipient" — the single-recipient assumption the DM path started with. +func TestGroupDM_ChatSendFansOutToAllParticipants(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "grp-send-alice") + bob := seedMemberUser(t, database, "grp-send-bob") + carol := seedMemberUser(t, database, "grp-send-carol") + chID := seedGroupDM(t, database, "Trio", alice.ID, bob.ID, carol.ID) + + sendAlice := make(chan []byte, 64) + sendBob := make(chan []byte, 64) + sendCarol := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice) + cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob) + cCarol := ws.NewTestClientWithUser(hub, carol, chID, sendCarol) + hub.Register(cAlice) + hub.Register(cBob) + hub.Register(cCarol) + waitRegistered(t, hub, cCarol) + + hub.HandleMessageForTest(cAlice, dmChatSendMsg(chID, "hello everyone")) + + if dmWaitMsgType(sendBob, "chat_message", waitTimeout) == nil { + t.Error("bob did not receive the group message") + } + if dmWaitMsgType(sendCarol, "chat_message", waitTimeout) == nil { + t.Error("carol did not receive the group message") + } +} + +// Every recipient's dm_channel_open must describe the group from *their* seat: +// they never appear in their own recipients list, and the list is the other two. +func TestGroupDM_ChannelOpenIsPerViewer(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "grp-open-alice") + bob := seedMemberUser(t, database, "grp-open-bob") + carol := seedMemberUser(t, database, "grp-open-carol") + chID := seedGroupDM(t, database, "Openers", alice.ID, bob.ID, carol.ID) + + // A closed DM is what makes the send re-open it and emit dm_channel_open. + if err := database.CloseDM(context.Background(), bob.ID, chID); err != nil { + t.Fatalf("CloseDM: %v", err) + } + if err := database.CloseDM(context.Background(), carol.ID, chID); err != nil { + t.Fatalf("CloseDM: %v", err) + } + + sendAlice := make(chan []byte, 64) + sendBob := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice) + cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob) + hub.Register(cAlice) + hub.Register(cBob) + waitRegistered(t, hub, cBob) + + hub.HandleMessageForTest(cAlice, dmChatSendMsg(chID, "ping")) + + env := dmWaitMsgType(sendBob, "dm_channel_open", waitTimeout) + if env == nil { + t.Fatal("bob did not receive dm_channel_open") + } + payload, _ := env["payload"].(map[string]any) + if payload["is_group"] != true { + t.Errorf("expected is_group=true, got %v", payload["is_group"]) + } + if payload["name"] != "Openers" { + t.Errorf("expected the group name on the wire, got %v", payload["name"]) + } + recips, _ := payload["recipients"].([]any) + if len(recips) != 2 { + t.Fatalf("expected bob to see 2 other participants, got %d", len(recips)) + } + for _, r := range recips { + m, _ := r.(map[string]any) + if int64(m["id"].(float64)) == bob.ID { + t.Error("bob appears in his own recipients list") + } + } +} + +// Typing already fanned out over dm_participants; this pins that a group's +// third member is included and the sender is not. +func TestGroupDM_TypingReachesAllOthers(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "grp-type-alice") + bob := seedMemberUser(t, database, "grp-type-bob") + carol := seedMemberUser(t, database, "grp-type-carol") + chID := seedGroupDM(t, database, "Typers", alice.ID, bob.ID, carol.ID) + + sendAlice := make(chan []byte, 64) + sendBob := make(chan []byte, 64) + sendCarol := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice) + cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob) + cCarol := ws.NewTestClientWithUser(hub, carol, chID, sendCarol) + hub.Register(cAlice) + hub.Register(cBob) + hub.Register(cCarol) + waitRegistered(t, hub, cCarol) + + hub.HandleMessageForTest(cAlice, dmTypingMsg(chID)) + + if dmWaitMsgType(sendBob, "typing", waitTimeout) == nil { + t.Error("bob did not receive the typing indicator") + } + if dmWaitMsgType(sendCarol, "typing", waitTimeout) == nil { + t.Error("carol did not receive the typing indicator") + } + if got := dmFindMsgType(dmDrainAll(sendAlice), "typing"); got != nil { + t.Error("the typist received their own typing indicator") + } +} + +// A block between two members of a group must NOT silence the group: the +// composer gate is a 1:1 rule, and dropping one member's messages for one +// other member would leave them reading different conversations. +func TestGroupDM_BlockDoesNotSilenceGroupSend(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "grp-blk-alice") + bob := seedMemberUser(t, database, "grp-blk-bob") + carol := seedMemberUser(t, database, "grp-blk-carol") + chID := seedGroupDM(t, database, "Blockers", alice.ID, bob.ID, carol.ID) + + if err := database.BlockUser(context.Background(), bob.ID, alice.ID); err != nil { + t.Fatalf("BlockUser: %v", err) + } + + sendAlice := make(chan []byte, 64) + sendCarol := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice) + cCarol := ws.NewTestClientWithUser(hub, carol, chID, sendCarol) + hub.Register(cAlice) + hub.Register(cCarol) + waitRegistered(t, hub, cCarol) + + hub.HandleMessageForTest(cAlice, dmChatSendMsg(chID, "still a room")) + + if dmWaitMsgType(sendAlice, "chat_send_ok", waitTimeout) == nil { + t.Error("a group send was refused because of a block between two members") + } + if dmWaitMsgType(sendCarol, "chat_message", waitTimeout) == nil { + t.Error("carol did not receive the group message") + } +} + +// A 1:1 block must still bite — the group exemption is not a general one. +func TestOneToOneDM_BlockStillRefusesSend(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "one-blk-alice") + bob := seedMemberUser(t, database, "one-blk-bob") + chID := seedDMChannel(t, database, alice.ID, bob.ID) + + if err := database.BlockUser(context.Background(), bob.ID, alice.ID); err != nil { + t.Fatalf("BlockUser: %v", err) + } + + sendAlice := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice) + hub.Register(cAlice) + waitRegistered(t, hub, cAlice) + + hub.HandleMessageForTest(cAlice, dmChatSendMsg(chID, "blocked")) + + if code := dmFindErrorCode(dmCollectAll(sendAlice, absenceWindow)); code != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN on a blocked 1:1 send, got %q", code) + } +} + +// ─── call ringing ─────────────────────────────────────────────────────────── + +func TestCallRing_ForwardsToOtherParticipants(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "ring-alice") + bob := seedMemberUser(t, database, "ring-bob") + carol := seedMemberUser(t, database, "ring-carol") + chID := seedGroupDM(t, database, "Ringers", alice.ID, bob.ID, carol.ID) + + sendAlice := make(chan []byte, 64) + sendBob := make(chan []byte, 64) + sendCarol := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice) + cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob) + cCarol := ws.NewTestClientWithUser(hub, carol, chID, sendCarol) + hub.Register(cAlice) + hub.Register(cBob) + hub.Register(cCarol) + waitRegistered(t, hub, cCarol) + + hub.HandleMessageForTest(cAlice, callMsg("call_ring", chID)) + + env := dmWaitMsgType(sendBob, "call_incoming", waitTimeout) + if env == nil { + t.Fatal("bob did not receive call_incoming") + } + payload, _ := env["payload"].(map[string]any) + if int64(payload["channel_id"].(float64)) != chID { + t.Errorf("call_incoming carried channel %v, want %d", payload["channel_id"], chID) + } + if int64(payload["from_user"].(float64)) != alice.ID { + t.Errorf("call_incoming carried from_user %v, want %d", payload["from_user"], alice.ID) + } + if dmWaitMsgType(sendCarol, "call_incoming", waitTimeout) == nil { + t.Error("carol did not receive call_incoming") + } + // The ringer must not ring themselves. + if got := dmFindMsgType(dmDrainAll(sendAlice), "call_incoming"); got != nil { + t.Error("the ringer received their own call_incoming") + } +} + +func TestCallRing_NonParticipantForbidden(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "ring-perm-alice") + bob := seedMemberUser(t, database, "ring-perm-bob") + mallory := seedMemberUser(t, database, "ring-perm-mallory") + chID := seedDMChannel(t, database, alice.ID, bob.ID) + + sendMallory := make(chan []byte, 64) + sendBob := make(chan []byte, 64) + cMallory := ws.NewTestClientWithUser(hub, mallory, chID, sendMallory) + cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob) + hub.Register(cMallory) + hub.Register(cBob) + waitRegistered(t, hub, cBob) + + hub.HandleMessageForTest(cMallory, callMsg("call_ring", chID)) + + if code := dmFindErrorCode(dmCollectAll(sendMallory, absenceWindow)); code != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN ringing a DM she is not in, got %q", code) + } + if got := dmFindMsgType(dmDrainAll(sendBob), "call_incoming"); got != nil { + t.Error("a non-participant's ring reached a participant") + } +} + +func TestCallDecline_ForwardsToOtherParticipants(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "decline-alice") + bob := seedMemberUser(t, database, "decline-bob") + chID := seedDMChannel(t, database, alice.ID, bob.ID) + + sendAlice := make(chan []byte, 64) + sendBob := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice) + cBob := ws.NewTestClientWithUser(hub, bob, chID, sendBob) + hub.Register(cAlice) + hub.Register(cBob) + waitRegistered(t, hub, cBob) + + hub.HandleMessageForTest(cBob, callMsg("call_decline", chID)) + + env := dmWaitMsgType(sendAlice, "call_declined", waitTimeout) + if env == nil { + t.Fatal("the ringer did not receive call_declined") + } + payload, _ := env["payload"].(map[string]any) + if int64(payload["from_user"].(float64)) != bob.ID { + t.Errorf("call_declined carried from_user %v, want %d", payload["from_user"], bob.ID) + } +} + +func TestCallRing_RateLimited(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "ringlimit-alice") + bob := seedMemberUser(t, database, "ringlimit-bob") + chID := seedDMChannel(t, database, alice.ID, bob.ID) + + sendAlice := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, chID, sendAlice) + hub.Register(cAlice) + waitRegistered(t, hub, cAlice) + + hub.HandleMessageForTest(cAlice, callMsg("call_ring", chID)) + hub.HandleMessageForTest(cAlice, callMsg("call_ring", chID)) + + if code := dmFindErrorCode(dmCollectAll(sendAlice, absenceWindow)); code != "RATE_LIMITED" { + t.Errorf("expected RATE_LIMITED on a second immediate ring, got %q", code) + } +} + +func TestCallRing_RejectsNonPositiveChannel(t *testing.T) { + hub, database := newHandlerHub(t) + alice := seedOwnerUser(t, database, "ringbad-alice") + + sendAlice := make(chan []byte, 64) + cAlice := ws.NewTestClientWithUser(hub, alice, 0, sendAlice) + hub.Register(cAlice) + waitRegistered(t, hub, cAlice) + + hub.HandleMessageForTest(cAlice, callMsg("call_ring", 0)) + + if code := dmFindErrorCode(dmCollectAll(sendAlice, absenceWindow)); code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST for channel_id 0, got %q", code) + } +} diff --git a/Server/ws/errors.go b/Server/ws/errors.go index 5abcbb7c..f92dc010 100644 --- a/Server/ws/errors.go +++ b/Server/ws/errors.go @@ -18,4 +18,7 @@ const ( ErrCodeConflict = "CONFLICT" ErrCodeBadPayload = "BAD_PAYLOAD" ErrCodeNotKeyHolder = "NOT_KEY_HOLDER" + // Returned when a user tries to lift a moderator-imposed voice state. + ErrCodeServerMuted = "SERVER_MUTED" + ErrCodeServerDeafened = "SERVER_DEAFENED" ) diff --git a/Server/ws/event.go b/Server/ws/event.go index 59267310..8370d7a7 100644 --- a/Server/ws/event.go +++ b/Server/ws/event.go @@ -1,5 +1,7 @@ package ws +import "github.com/owncord/server/db" + // ClientError represents an error to send back to the requesting client. // It implements the error interface so it can be used as Result.Error. type ClientError struct { @@ -240,6 +242,57 @@ type PresenceEvent struct { func (e PresenceEvent) EventType() string { return MsgTypePresence } func (e PresenceEvent) Payload() []byte { return e.payload } +// PresenceOthersEvent is the public half of an invisible user's presence: the +// mapped ("offline") payload, broadcast to everyone except the user it +// describes. Satisfies ExcludeSenderEvent with a channel id of 0, which +// broadcastExcludeLow routes as a global publish minus one subscriber. +type PresenceOthersEvent struct { + excludeUserID int64 + payload []byte +} + +func (e PresenceOthersEvent) EventType() string { return MsgTypePresence } +func (e PresenceOthersEvent) ChannelID() int64 { return 0 } +func (e PresenceOthersEvent) ExcludeUserID() int64 { return e.excludeUserID } +func (e PresenceOthersEvent) Payload() []byte { return e.payload } + +// PresenceSelfEvent is the private half: the owner's own true status, sent +// only to them. Without it a user who went invisible would be told they are +// offline by the very broadcast that hid them, and would re-announce online on +// the next reconnect. +type PresenceSelfEvent struct { + targetUserID int64 + payload []byte +} + +func (e PresenceSelfEvent) EventType() string { return MsgTypePresence } +func (e PresenceSelfEvent) TargetUserID() int64 { return e.targetUserID } +func (e PresenceSelfEvent) Payload() []byte { return e.payload } + +// presenceEvents builds the events one presence change needs. +// +// The common case is one global broadcast. Invisible is the exception and the +// reason this helper exists: what others see and what the owner sees differ, +// so the broadcast excludes the owner and a second, targeted event carries +// their real status. Every presence emitter goes through here, so no new call +// site can leak an invisible user by forgetting the mapping. +func presenceEvents(userID int64, status string, customStatus *string) []Event { + public := db.BroadcastStatus(status) + if public == status { + return []Event{PresenceEvent{payload: buildPresenceMsg(userID, status, customStatus)}} + } + return []Event{ + PresenceOthersEvent{ + excludeUserID: userID, + payload: buildPresenceMsg(userID, public, customStatus), + }, + PresenceSelfEvent{ + targetUserID: userID, + payload: buildPresenceMsg(userID, status, customStatus), + }, + } +} + // ReactionChannelEvent is a reaction update broadcast to a non-DM channel. type ReactionChannelEvent struct { channelID int64 @@ -324,3 +377,17 @@ type DMChannelOpenEvent struct { func (e DMChannelOpenEvent) EventType() string { return MsgTypeDMChannelOpen } func (e DMChannelOpenEvent) TargetUserID() int64 { return e.targetUserID } func (e DMChannelOpenEvent) Payload() []byte { return e.payload } + +// CallSignalEvent delivers a DM call signal (call_incoming / call_declined) to +// one participant. Satisfies UserTargetedEvent, so an offline addressee is a +// no-op — which is the correct behaviour for ringing: a ring that arrives +// after the fact is worse than no ring. +type CallSignalEvent struct { + eventType string + targetUserID int64 + payload []byte +} + +func (e CallSignalEvent) EventType() string { return e.eventType } +func (e CallSignalEvent) TargetUserID() int64 { return e.targetUserID } +func (e CallSignalEvent) Payload() []byte { return e.payload } diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 61ca31aa..1f13596d 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -151,6 +151,12 @@ func ClientUserIDForTest(c *Client) int64 { return c.userID } +// ClientChannelIDForTest returns the client's currently focused channel for +// external tests. +func ClientChannelIDForTest(c *Client) int64 { + return c.getChannelID() +} + // TouchForTest exposes Client.touch for external tests. func TouchForTest(c *Client) { c.touch() @@ -208,6 +214,13 @@ func (h *Hub) BuildAuthOKForTest(user *db.User, roleName string) []byte { return h.buildAuthOK(context.Background(), user, roleName, "none") } +// RunMentionCountsInlineForTest makes the hub's MessageService apply mention +// counts synchronously instead of on a background goroutine, so a test can read +// the counts deterministically right after driving a chat_send through the hub. +func (h *Hub) RunMentionCountsInlineForTest() { + h.messageSvc.RunBackgroundInlineForTest() +} + // BuildReadyForTest exposes Hub.buildReady for external tests. // Passes nil role so no channels are visible (fail-closed, BUG-094). func (h *Hub) BuildReadyForTest(database *db.DB, userID int64) ([]byte, error) { @@ -305,9 +318,19 @@ func QualityBitrateForTest(quality string) int { return qualityBitrate(quality) } -// BuildDMChannelOpenForTest exposes buildDMChannelOpen for external tests. +// BuildDMChannelOpenForTest exposes buildDMChannelOpenFor for external tests. func BuildDMChannelOpenForTest(channelID int64, recipient *db.User) []byte { - return buildDMChannelOpen(channelID, recipient) + return buildDMChannelOpenFor(channelID, recipient, 0) +} + +// BuildDMChannelOpenInfoForTest exposes the group-aware buildDMChannelOpen. +func BuildDMChannelOpenInfoForTest(info db.DMChannelInfo) []byte { + return buildDMChannelOpen(info) +} + +// BuildCallSignalForTest exposes buildCallSignal for external tests. +func BuildCallSignalForTest(msgType string, channelID, fromUserID int64, username string) []byte { + return buildCallSignal(msgType, channelID, fromUserID, username) } // HandleWebhookParticipantLeftForTest exposes handleWebhookParticipantLeft for @@ -355,3 +378,11 @@ func (h *Hub) MustFullResyncForTest(lastSeq uint64) bool { func (h *Hub) HasChannelPermForTest(c *Client, channelID, perm int64) bool { return h.hasChannelPerm(context.Background(), c, channelID, perm) } + +// BroadcastVoiceEventForTest exposes Hub.broadcastVoiceEvent for external +// tests so a load/soak test can drive the channelReadAudience-resolved +// voice_state/voice_leave fan-out directly, without a full LiveKit join +// round-trip. +func (h *Hub) BroadcastVoiceEventForTest(channelID int64, msg []byte) { + h.broadcastVoiceEvent(context.Background(), channelID, msg) +} diff --git a/Server/ws/handler_v2_mark_read_test.go b/Server/ws/handler_v2_mark_read_test.go new file mode 100644 index 00000000..f588361c --- /dev/null +++ b/Server/ws/handler_v2_mark_read_test.go @@ -0,0 +1,128 @@ +package ws_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/permissions" + "github.com/owncord/server/ws" +) + +// ─── mark_read ────────────────────────────────────────────────────────────── +// +// mark_read exists because channel_focus conflates two things: "this is the +// channel I am looking at" and "I have read this channel". Marking a *different* +// channel read from its context menu must do the second without the first. + +func markReadEnvelope(channelID int64) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "mark_read", + "payload": map[string]any{"channel_id": channelID}, + }) + return raw +} + +func TestMarkRead_AdvancesReadStateWithoutChangingFocus(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "markread-user") + focused := seedTestChannel(t, database, "markread-focused") + other := seedTestChannel(t, database, "markread-other") + + latest, err := database.CreateMessage(context.Background(), other, user.ID, "unread", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, focused, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, markReadEnvelope(other)) + + if code := drainForErrorCode(send, 100*time.Millisecond); code != "" { + t.Fatalf("unexpected error code %q for a valid mark_read", code) + } + + // The read state for the *other* channel advanced … + counts, err := database.GetChannelUnreadCounts(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + if cu := counts[other]; cu.UnreadCount != 0 { + t.Errorf("unread for marked channel = %d, want 0", cu.UnreadCount) + } + if cu := counts[other]; cu.LastMessageID != latest { + t.Errorf("last message id = %d, want %d", cu.LastMessageID, latest) + } + + // … while the connection still points at the channel the user is viewing. + if got := ws.ClientChannelIDForTest(c); got != focused { + t.Errorf("focused channel = %d, want %d (mark_read must not move focus)", got, focused) + } +} + +// mark_read clears the mention badge too — read_states.mention_count is zeroed +// by UpdateReadState, which is what makes "Mark as Read" clear a red badge. +func TestMarkRead_ClearsMentionCount(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "markread-mention-user") + chID := seedTestChannel(t, database, "markread-mention-chan") + if _, err := database.CreateMessage(context.Background(), chID, user.ID, "@you", nil); err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if err := database.IncrementMentionCounts(context.Background(), chID, []int64{user.ID}); err != nil { + t.Fatalf("IncrementMentionCounts: %v", err) + } + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, markReadEnvelope(chID)) + + counts, err := database.GetChannelUnreadCounts(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetChannelUnreadCounts: %v", err) + } + if cu := counts[chID]; cu.MentionCount != 0 { + t.Errorf("mention count = %d, want 0", cu.MentionCount) + } +} + +func TestMarkRead_DeniedChannelIsForbidden(t *testing.T) { + hub, database := newHandlerHub(t) + chID := seedTestChannel(t, database, "markread-denied-chan") + user := seedMemberUser(t, database, "markread-denied-user") + denyReadOnChannel(t, database, chID, permissions.MemberRoleID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, markReadEnvelope(chID)) + + if code := drainForErrorCode(send, 300*time.Millisecond); code != "FORBIDDEN" { + t.Errorf("error code = %q, want FORBIDDEN", code) + } +} + +func TestMarkRead_RejectsInvalidPayload(t *testing.T) { + hub, database := newCoverageHub(t) + user := seedCoverageOwner(t, database, "markread-badpayload-user") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, markReadEnvelope(0)) + + if code := drainForErrorCode(send, 300*time.Millisecond); code != "BAD_REQUEST" { + t.Errorf("error code = %q, want BAD_REQUEST for channel_id 0", code) + } +} diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 589c9799..b59da2ba 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -110,16 +110,18 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { } var username string - var avatar *string + var avatar, displayName *string if c.user != nil { username = c.user.Username avatar = c.user.Avatar + displayName = c.user.DisplayName } voiceChID, voiceJoinTok := c.getVoiceState() info := ClientInfo{ UserID: c.userID, Username: username, Avatar: avatar, + DisplayName: displayName, RoleName: c.roleName, ReqID: env.ID, VoiceChannelID: voiceChID, @@ -220,7 +222,7 @@ func (h *Hub) hasChannelPerm(ctx context.Context, c *Client, channelID int64, pe if err != nil || role == nil { return false } - return h.permChecker.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, perm) + return h.permChecker.HasChannelPerm(ctx, role.Permissions, role.ID, c.userID, channelID, perm) } // requireChannelAccess checks whether the client may act on the channel with the diff --git a/Server/ws/handlers_call.go b/Server/ws/handlers_call.go new file mode 100644 index 00000000..bf5464ee --- /dev/null +++ b/Server/ws/handlers_call.go @@ -0,0 +1,94 @@ +package ws + +import ( + "context" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/service" +) + +// Ringing is rate limited per user rather than per (user, channel): the abuse +// it exists to stop is spamming *someone* with call banners, and a per-channel +// key would let one user ring five different DMs a second. One ring every three +// seconds is far below what a human does and far above what a redial costs. +const ( + callRingRateLimit = 1 + callRingWindow = 3 * time.Second +) + +// registerCallHandlers registers the DM call signalling handlers. +// +// There is deliberately no call state on the server. A "call" in a DM is +// nothing more than somebody being present in that DM's voice channel — which +// voice_state already broadcasts — and a ring is a transient nudge to come +// look. Persisting a call row would add a thing that a crashed client can +// leave dangling, in exchange for information the presence already carries. +func registerCallHandlers(r *HandlerRegistry, deps CallDeps) { + r.RegisterV2(MsgTypeCallRing, handleCallRingV2, deps) + r.RegisterV2(MsgTypeCallDecline, handleCallDeclineV2, deps) +} + +// handleCallRingV2 forwards a call_ring to the DM's other participants as +// call_incoming. Only a participant of the DM may ring it; the fan-out reaches +// whichever of them are connected, because a targeted event to an offline user +// is a no-op by construction. +func handleCallRingV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { + d := deps.(CallDeps) + ringCmd := cmd.(CallRingCmd) + + ratKey := auth.Key("call_ring", info.UserID) + if d.Limiter != nil && !d.Limiter.Allow(ratKey, callRingRateLimit, callRingWindow) { + return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many call attempts"}} + } + + targets, err := d.DMSvc.RingTargets(ctx, info.UserID, ringCmd.ChannelID()) + if err != nil { + return serviceErrorToResult(err) + } + + payload := buildCallSignal(MsgTypeCallIncoming, ringCmd.ChannelID(), info.UserID, info.Username) + events := make([]Event, 0, len(targets)) + for _, pid := range targets { + events = append(events, CallSignalEvent{ + eventType: MsgTypeCallIncoming, + targetUserID: pid, + payload: payload, + }) + } + return Result{Events: events} +} + +// handleCallDeclineV2 tells the DM's other participants that the caller is not +// picking up, so a ringing client can stop ringing before the 30s timeout. +// +// It is addressed to every other participant rather than to "the ringer" +// because the server does not know who that was — no call state, by design — +// and in a group DM more than one person may be ringing anyway. The decline is +// advisory: a client that has already been answered ignores it. +func handleCallDeclineV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { + d := deps.(CallDeps) + declineCmd := cmd.(CallDeclineCmd) + + targets, err := d.DMSvc.RingTargets(ctx, info.UserID, declineCmd.ChannelID()) + if err != nil { + return serviceErrorToResult(err) + } + + payload := buildCallSignal(MsgTypeCallDeclined, declineCmd.ChannelID(), info.UserID, info.Username) + events := make([]Event, 0, len(targets)) + for _, pid := range targets { + events = append(events, CallSignalEvent{ + eventType: MsgTypeCallDeclined, + targetUserID: pid, + payload: payload, + }) + } + return Result{Events: events} +} + +// CallDeps holds dependencies for the DM call signalling handlers. +type CallDeps struct { + Limiter *auth.RateLimiter + DMSvc *service.DMService +} diff --git a/Server/ws/handlers_chat.go b/Server/ws/handlers_chat.go index 5910ae21..0f573da3 100644 --- a/Server/ws/handlers_chat.go +++ b/Server/ws/handlers_chat.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/owncord/server/db" "github.com/owncord/server/service" ) @@ -46,9 +47,21 @@ func handleChatSendV2(ctx context.Context, cmd Command, info ClientInfo, deps an } reply := buildChatSendOK(info.ReqID, result.MessageID, result.Timestamp) - broadcast := buildChatMessage(result.MessageID, sendCmd.ChannelID(), info.UserID, - info.Username, info.Avatar, info.RoleName, result.Content, result.Timestamp, - sendCmd.ReplyTo(), attData) + broadcast := buildChatMessage(chatMessageArgs{ + MsgID: result.MessageID, + ChannelID: sendCmd.ChannelID(), + UserID: info.UserID, + Username: info.Username, + Avatar: info.Avatar, + DisplayName: info.DisplayName, + RoleName: info.RoleName, + Content: result.Content, + Timestamp: result.Timestamp, + ReplyTo: sendCmd.ReplyTo(), + Attachments: attData, + Mentions: result.Mentions, + MentionsEveryone: result.MentionsEveryone, + }) if !result.IsDM { return Result{ @@ -59,11 +72,25 @@ func handleChatSendV2(ctx context.Context, cmd Command, info ClientInfo, deps an // DM path: build dm_channel_open events + sequenced message. var events []Event - if result.SenderUser != nil && len(result.OpenedDMFor) > 0 { - // The payload is identical for every recipient, so marshal it once - // outside the loop (delivery wraps it per-send without mutating it). - openPayload := buildDMChannelOpen(sendCmd.ChannelID(), result.SenderUser) + if len(result.OpenedDMFor) > 0 { + // One payload per recipient, not one for all of them: `recipient` and + // `recipients` are defined relative to who is reading, so a shared + // payload would list a group member as their own DM partner. + chName := "" + if result.Channel != nil { + chName = result.Channel.Name + } for _, pid := range result.OpenedDMFor { + var openPayload []byte + if len(result.DMParticipants) > 0 { + openPayload = buildDMChannelOpen( + db.NewDMChannelInfo(sendCmd.ChannelID(), chName, result.DMIsGroup, result.DMParticipants, pid)) + } else { + openPayload = buildDMChannelOpenFor(sendCmd.ChannelID(), result.SenderUser, pid) + } + if openPayload == nil { + continue + } events = append(events, DMChannelOpenEvent{ targetUserID: pid, payload: openPayload, @@ -90,7 +117,8 @@ func handleChatEditV2(ctx context.Context, cmd Command, info ClientInfo, deps an return serviceErrorToResult(err) } - editedPayload := buildChatEdited(result.MessageID, result.ChannelID, result.Content, result.EditedAt) + editedPayload := buildChatEdited(result.MessageID, result.ChannelID, result.Content, result.EditedAt, + result.Mentions, result.MentionsEveryone) if result.IsDM { return Result{Events: []Event{MessageEditedDMEvent{ channelID: result.ChannelID, diff --git a/Server/ws/handlers_presence.go b/Server/ws/handlers_presence.go index 0aae0f7f..4b41b026 100644 --- a/Server/ws/handlers_presence.go +++ b/Server/ws/handlers_presence.go @@ -13,6 +13,7 @@ func registerPresenceHandlers(r *HandlerRegistry, deps PresenceDeps) { r.RegisterV2(MsgTypeTypingStart, handleTypingV2, deps) r.RegisterV2(MsgTypePresenceUpdate, handlePresenceV2, deps) r.RegisterV2(MsgTypeChannelFocus, handleChannelFocusV2, deps) + r.RegisterV2(MsgTypeMarkRead, handleMarkReadV2, deps) } // handleTypingV2 is the V2 handler for typing_start messages. @@ -68,15 +69,12 @@ func handlePresenceV2(ctx context.Context, cmd Command, info ClientInfo, deps an userID := info.UserID status := presenceCmd.Status() - if err := d.ChannelSvc.HandlePresenceUpdate(ctx, userID, status, d.Limiter); err != nil { + customStatus, err := d.ChannelSvc.HandlePresenceUpdate(ctx, userID, status, presenceCmd.CustomStatus(), d.Limiter) + if err != nil { return serviceErrorToResult(err) } - return Result{ - Events: []Event{ - PresenceEvent{payload: buildPresenceMsg(userID, status)}, - }, - } + return Result{Events: presenceEvents(userID, status, customStatus)} } // handleChannelFocusV2 is the V2 handler for channel_focus messages. @@ -97,3 +95,22 @@ func handleChannelFocusV2(ctx context.Context, cmd Command, info ClientInfo, dep return Result{SetChannelID: &chID} } + +// handleMarkReadV2 is the V2 handler for mark_read messages. It runs the same +// access check and read-state advance as channel_focus but deliberately leaves +// SetChannelID unset: marking a channel read from its context menu must not +// move the connection's focus off the channel the user is actually looking at +// (which would misroute typing/read bookkeeping for the visible channel). +func handleMarkReadV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { + d := deps.(PresenceDeps) + markCmd := cmd.(MarkReadCmd) + + _, err := d.ChannelSvc.HandleChannelFocus(ctx, info.UserID, markCmd.ChannelID()) + if err != nil { + if errors.Is(err, service.ErrForbidden) { + return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "access denied"}} + } + return Result{} // silently drop other errors + } + return Result{} +} diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index 665ab7a4..6cb2c1a9 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "testing" "testing/fstest" "time" @@ -26,6 +27,8 @@ CREATE TABLE IF NOT EXISTS voice_states ( muted INTEGER NOT NULL DEFAULT 0, deafened INTEGER NOT NULL DEFAULT 0, speaking INTEGER NOT NULL DEFAULT 0, + server_muted INTEGER NOT NULL DEFAULT 0, + server_deafened 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); @@ -1361,7 +1364,9 @@ func TestReaction_EmptyEmoji_ReturnsBadRequest(t *testing.T) { } // TestReaction_TooLongEmoji_ReturnsBadRequest verifies that an emoji string -// exceeding 32 bytes is rejected. +// past the reaction length cap is rejected. The cap is derived from the custom +// emoji shortcode limit (32) plus its two colons, so a reaction has to clear 34 +// runes to be refused. func TestReaction_TooLongEmoji_ReturnsBadRequest(t *testing.T) { hub, database := newHandlerHub(t) user := seedOwnerUser(t, database, "react-owner5") @@ -1373,8 +1378,8 @@ func TestReaction_TooLongEmoji_ReturnsBadRequest(t *testing.T) { hub.Register(c) waitRegistered(t, hub, c) - // 33-character emoji string — exceeds the 32-byte limit. - longEmoji := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 33 chars + // 35 runes — one past the ":" + 32-rune shortcode + ":" ceiling. + longEmoji := strings.Repeat("a", 35) hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, longEmoji)) code := receiveErrorCode(send, 300*time.Millisecond) @@ -1744,7 +1749,7 @@ func TestPresence_InvalidStatus_ReturnsBadRequest(t *testing.T) { hub.Register(c) waitRegistered(t, hub, c) - hub.HandleMessageForTest(c, presenceUpdateMsg("invisible")) + hub.HandleMessageForTest(c, presenceUpdateMsg("afk")) code := receiveErrorCode(send, 300*time.Millisecond) if code != "BAD_REQUEST" { diff --git a/Server/ws/handlers_voice.go b/Server/ws/handlers_voice.go index ce98ea09..4b138004 100644 --- a/Server/ws/handlers_voice.go +++ b/Server/ws/handlers_voice.go @@ -25,6 +25,7 @@ func registerVoiceControlsV2(r *HandlerRegistry, deps VoiceDeps) { r.RegisterV2(MsgTypeVoiceE2EEAnnounce, handleVoiceE2EEAnnounceV2, deps) r.RegisterV2(MsgTypeVoiceE2EEOffer, handleVoiceE2EEOfferV2, deps) r.RegisterV2(MsgTypeVoiceTokenRefresh, handleVoiceTokenRefreshV2, deps) + registerVoiceModerationV2(r, deps) } // handleVoiceJoinV2 gates parsing via the VoiceJoinCmd constructor (which diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 4b2a71e2..144bbe5d 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -136,10 +136,12 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * Limiter: h.limiter, } reactionDeps := ReactionDeps{} + callDeps := CallDeps{Limiter: h.limiter} if svc != nil { chatDeps.MessageSvc = svc.Messages presenceDeps.ChannelSvc = svc.Channels reactionDeps.MessageSvc = svc.Messages + callDeps.DMSvc = svc.DMs h.messageSvc = svc.Messages h.perms = svc.Permissions } @@ -147,6 +149,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * registerChatHandlers(reg, chatDeps) registerPresenceHandlers(reg, presenceDeps) registerReactionHandlers(reg, reactionDeps) + registerCallHandlers(reg, callDeps) // Phase C Step 9 — plugin slash commands. Registry is read live because // SetPluginRegistry wires it after NewHub; MessageSvc gates broadcasts. reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{ @@ -161,6 +164,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * LiveKit: h.livekit, TokenGen: h, // Hub delegates to h.livekit at call time (set via SetLiveKit) KeyHolder: h, + Mod: h, }) h.refreshSettingsLocked(context.Background()) diff --git a/Server/ws/hub_broadcast.go b/Server/ws/hub_broadcast.go index 58e07abb..084f6676 100644 --- a/Server/ws/hub_broadcast.go +++ b/Server/ws/hub_broadcast.go @@ -114,6 +114,42 @@ func (h *Hub) channelReadAudience(ctx context.Context, channelID int64) []int64 } h.mu.RUnlock() + // A DM channel carries no channel_overrides rows, so every connected + // user whose base role holds READ_MESSAGES would otherwise pass the role + // scan below — leaking a private DM call's voice_state/voice_leave + // events to the whole server. Resolve the DM's real audience (its + // participants, intersected with who is actually connected) instead, + // mirroring the IsDMParticipant membership rule hasChannelAccess uses. + if h.db != nil { + ch, err := h.db.GetChannel(ctx, channelID) + if err != nil { + // Fail closed: an unresolvable channel must not fall through to + // the role scan, which would treat it as a readable non-DM channel. + slog.Error("ws: channelReadAudience GetChannel failed, denying", + "channel_id", channelID, "err", err) + return []int64{} + } + if ch != nil && ch.Type == "dm" { + participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID) + if err != nil { + slog.Error("ws: channelReadAudience GetDMParticipantIDs failed, denying", + "channel_id", channelID, "err", err) + return []int64{} + } + connected := make(map[int64]struct{}, len(userIDs)) + for _, uid := range userIDs { + connected[uid] = struct{}{} + } + audience := make([]int64, 0, len(participantIDs)) + for _, uid := range participantIDs { + if _, ok := connected[uid]; ok { + audience = append(audience, uid) + } + } + return audience + } + } + audience := make([]int64, 0, len(userIDs)) if h.perms != nil { for _, uid := range userIDs { @@ -126,18 +162,16 @@ func (h *Hub) channelReadAudience(ctx context.Context, channelID int64) []int64 if h.db == nil || h.permChecker == nil { return audience } - visibleByRole := make(map[int64]bool) + // Resolved per USER, not memoised per role: channel_user_overrides is the + // last layer of the resolution order, so two members of the same role can + // legitimately disagree about one channel and a per-role memo would hand + // one of them the other's verdict. for _, uid := range userIDs { role, err := h.db.GetRoleForUser(ctx, uid) if err != nil || role == nil { continue } - visible, ok := visibleByRole[role.ID] - if !ok { - visible = h.permChecker.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, permissions.ReadMessages) - visibleByRole[role.ID] = visible - } - if visible { + if h.permChecker.HasChannelPerm(ctx, role.Permissions, role.ID, uid, channelID, permissions.ReadMessages) { audience = append(audience, uid) } } @@ -215,22 +249,21 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { // calling into the hub, so the lookups below repopulate from post-change // data; the 30s TTL is only a backstop and the F6 gen-counter guard keeps // a racing populate from caching stale rows. Without a service (bare test - // hubs) each role is resolved live, once. - visibleByRole := make(map[int64]bool) - roleVisible := func(roleID int64) bool { - if v, ok := visibleByRole[roleID]; ok { - return v - } - visible := false + // hubs) each client is resolved live. + // + // Deliberately NOT memoised per role: channel_user_overrides is the last + // layer of the resolution order, so two members of the same role can + // legitimately disagree about one channel — exactly the case a per-user + // override edit creates, and exactly the fan-out this function targets. + userVisible := func(userID, roleID int64) bool { role, err := h.db.GetRoleByID(ctx, roleID) - if err == nil && role != nil { - // Single visibility predicate shared with buildReady / REST - // ListVisibleChannels; the checker fails closed on a lookup error - // and bypasses for admins, matching the other sites exactly. - visible = h.permChecker.HasChannelPerm(ctx, role.Permissions, roleID, ch.ID, permissions.ReadMessages) + if err != nil || role == nil { + return false } - visibleByRole[roleID] = visible - return visible + // Single visibility predicate shared with buildReady / REST + // ListVisibleChannels; the checker fails closed on a lookup error + // and bypasses for admins, matching the other sites exactly. + return h.permChecker.HasChannelPerm(ctx, role.Permissions, roleID, userID, ch.ID, permissions.ReadMessages) } for _, c := range clients { @@ -238,12 +271,17 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { continue } var visible bool - if h.perms != nil { + switch { + case ch.Archived: + // Archived channels are hidden from every client regardless of + // permissions, mirroring VisibleChannelIDs. + visible = false + case h.perms != nil: // The service resolves the user's CURRENT role internally (c.user // is a connect-time snapshot), failing closed — an unresolvable // role loses visibility rather than keeping a stale grant. visible = h.perms.HasChannelPerm(ctx, c.user.ID, ch.ID, permissions.ReadMessages) - } else { + default: // c.user is a connect-time snapshot; an admin may have changed the // user's role mid-session, so resolve the current role from the DB. // Fail closed: on error send nothing rather than mis-target. @@ -253,7 +291,7 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { "user_id", c.user.ID, "err", err) continue } - visible = roleVisible(fresh.RoleID) + visible = userVisible(fresh.ID, fresh.RoleID) } if visible { // Idempotent add on the client; also refreshes channel metadata. @@ -276,6 +314,64 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { h.visibilityChangeSeq.Store(atomic.LoadUint64(&h.seq)) } +// RefreshAllChannelVisibility re-runs RefreshChannelVisibility for every +// non-DM channel. A role's permission mask is the base every channel's +// effective permission is computed from, so editing or deleting a role can +// change visibility of *any* channel at once — where a channel_overrides edit +// touches exactly one. DM channels are skipped: their access is participant- +// based and no role change can alter it. +// +// Called via the admin HubBroadcaster interface (no context), so the channel +// list is read against Background — the re-sync must complete regardless of the +// triggering request. The caller invalidates the permission cache first, as the +// channel-override handlers do, so the per-client lookups below repopulate from +// post-change data. +func (h *Hub) RefreshAllChannelVisibility() { + if h.db == nil { + return + } + ctx := context.Background() + channels, err := h.db.ListChannels(ctx) + if err != nil { + slog.Warn("hub: RefreshAllChannelVisibility could not list channels", "err", err) + return + } + for i := range channels { + if channels[i].Type == "dm" { + continue + } + h.RefreshChannelVisibility(&channels[i]) + } +} + +// BroadcastRolesUpdate sends the full role list to every connected client so +// name colors and permission-gated affordances converge without a reconnect. +// +// Unfiltered on purpose: the role list is already in every client's ready +// payload, so it discloses nothing a connected client cannot already read. +func (h *Hub) BroadcastRolesUpdate(roles []*db.Role) { + h.BroadcastToAll(buildRolesUpdate(roles)) +} + +// BroadcastEmojiUpdate sends the full custom-emoji set to every connected +// client so a newly uploaded (or deleted) emoji renders in messages, the +// picker and reaction pills without a reconnect. +// +// Unfiltered, like BroadcastRolesUpdate: emoji are server-wide with no channel +// scope, and every client may already GET the same list. +func (h *Hub) BroadcastEmojiUpdate(list []*db.Emoji) { + h.BroadcastToAll(buildEmojiUpdate(list)) +} + +// BroadcastChatBulkDeleted sends one chat_bulk_deleted message carrying every +// purged message id to the subscribers of channelID, replacing the N separate +// chat_deleted broadcasts a loop of single deletes would produce. Fan-out goes +// through the ordinary sequenced channel path, so the event replays on +// reconnect exactly like chat_deleted does. +func (h *Hub) BroadcastChatBulkDeleted(channelID int64, messageIDs []int64) { + h.BroadcastToChannel(channelID, buildChatBulkDeleted(channelID, messageIDs)) +} + // BroadcastMemberBan sends a member_ban message to all connected clients // and immediately disconnects the banned user's WebSocket connection (BUG-113). func (h *Hub) BroadcastMemberBan(userID int64) { @@ -298,9 +394,24 @@ func (h *Hub) DisconnectUser(userID int64) { } // BroadcastUserUpdate sends a user_update message to all connected clients -// when a user changes their profile (username, avatar, identity key). -func (h *Hub) BroadcastUserUpdate(userID int64, username string, avatar *string, identityPublicKey *string) { - h.BroadcastToAll(buildUserUpdate(userID, username, avatar, identityPublicKey)) +// when a user changes their profile (username, avatar, display name, about, +// identity key). +func (h *Hub) BroadcastUserUpdate(u UserUpdate) { + h.BroadcastToAll(buildUserUpdate(u)) +} + +// BroadcastPresence fans a presence change out with the invisible mapping +// applied: everyone else sees db.BroadcastStatus(status), the user themselves +// sees the truth. It is the non-handler counterpart of presenceEvents, used by +// the connect and disconnect paths which write to the hub directly. +func (h *Hub) BroadcastPresence(userID int64, status string, customStatus *string) { + public := db.BroadcastStatus(status) + if public == status { + h.BroadcastToAll(buildPresenceMsg(userID, status, customStatus)) + return + } + h.broadcastExcludeLow(0, userID, buildPresenceMsg(userID, public, customStatus)) + h.SendToUser(userID, buildPresenceMsg(userID, status, customStatus)) } // BroadcastMemberUpdate sends a member_update message to all connected clients diff --git a/Server/ws/hub_broadcast_test.go b/Server/ws/hub_broadcast_test.go index 45fa7e60..88cc547a 100644 --- a/Server/ws/hub_broadcast_test.go +++ b/Server/ws/hub_broadcast_test.go @@ -40,7 +40,7 @@ func TestHub_BroadcastUserUpdate(t *testing.T) { avatar := "avatar.png" identityKey := "pubkey-abc" - hub.BroadcastUserUpdate(42, "renamed", &avatar, &identityKey) + hub.BroadcastUserUpdate(ws.UserUpdate{UserID: 42, Username: "renamed", Avatar: &avatar, IdentityPublicKey: &identityKey}) msg := awaitMessage(t, send) if msg["type"] != "user_update" { @@ -74,7 +74,7 @@ func TestHub_BroadcastUserUpdate_NilOptionalFields(t *testing.T) { send := make(chan []byte, 8) hub.RegisterNowForTest(ws.NewTestClient(hub, 1, send)) - hub.BroadcastUserUpdate(42, "noextras", nil, nil) + hub.BroadcastUserUpdate(ws.UserUpdate{UserID: 42, Username: "noextras"}) msg := awaitMessage(t, send) payload, ok := msg["payload"].(map[string]any) @@ -104,7 +104,7 @@ func TestHub_BroadcastUserUpdate_ReachesEveryClient(t *testing.T) { hub.RegisterNowForTest(ws.NewTestClient(hub, 1, a)) hub.RegisterNowForTest(ws.NewTestClient(hub, 2, b)) - hub.BroadcastUserUpdate(7, "everyone", nil, nil) + hub.BroadcastUserUpdate(ws.UserUpdate{UserID: 7, Username: "everyone"}) for i, ch := range []chan []byte{a, b} { msg := awaitMessage(t, ch) @@ -129,7 +129,7 @@ func TestHub_BroadcastDropCount(t *testing.T) { send := make(chan []byte, 8) hub.RegisterNowForTest(ws.NewTestClient(hub, 1, send)) - hub.BroadcastUserUpdate(1, "u", nil, nil) + hub.BroadcastUserUpdate(ws.UserUpdate{UserID: 1, Username: "u"}) awaitMessage(t, send) // A single delivered broadcast must not increment the drop counter. diff --git a/Server/ws/hub_emoji_test.go b/Server/ws/hub_emoji_test.go new file mode 100644 index 00000000..3c432da0 --- /dev/null +++ b/Server/ws/hub_emoji_test.go @@ -0,0 +1,107 @@ +package ws_test + +import ( + "encoding/json" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/ws" +) + +// ─── emoji_update ──────────────────────────────────────────────────────────── + +// emojiUpdatePayload mirrors the wire shape the client parses. Declared here +// rather than reused from ws so a change to the broadcast that the client would +// notice also fails this test. +type emojiUpdatePayload struct { + Emoji []struct { + ID int64 `json:"id"` + Shortcode string `json:"shortcode"` + URL string `json:"url"` + } `json:"emoji"` +} + +func decodeEmojiUpdate(t *testing.T, msg map[string]any) emojiUpdatePayload { + t.Helper() + raw, err := json.Marshal(msg["payload"]) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + var payload emojiUpdatePayload + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + return payload +} + +func TestBroadcastEmojiUpdate_CarriesShortcodeAndURL(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + user := seedOwnerUser(t, database, "emoji-owner") + send := make(chan []byte, 16) + client := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(client) + waitRegistered(t, hub, client) + + hub.BroadcastEmojiUpdate([]*db.Emoji{ + {ID: 3, Shortcode: "wave", StoredAs: "uuid-a", MimeType: "image/png"}, + {ID: 9, Shortcode: "party", StoredAs: "uuid-b", MimeType: "image/gif"}, + }) + + payload := decodeEmojiUpdate(t, drainForMsgType(t, send, "emoji_update")) + if len(payload.Emoji) != 2 { + t.Fatalf("emoji count = %d, want 2", len(payload.Emoji)) + } + if payload.Emoji[0].Shortcode != "wave" || payload.Emoji[0].URL != "/api/v1/emoji/3/image" { + t.Errorf("first entry = %+v, want wave at /api/v1/emoji/3/image", payload.Emoji[0]) + } + if payload.Emoji[1].ID != 9 || payload.Emoji[1].URL != "/api/v1/emoji/9/image" { + t.Errorf("second entry = %+v, want id 9", payload.Emoji[1]) + } +} + +func TestBroadcastEmojiUpdate_EmptySetIsAnArray(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + user := seedOwnerUser(t, database, "emoji-empty-owner") + send := make(chan []byte, 16) + client := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(client) + waitRegistered(t, hub, client) + + // Deleting the last emoji broadcasts an empty set; it must be [] and not + // null, so the client can replace its map unconditionally. + hub.BroadcastEmojiUpdate(nil) + + msg := drainForMsgType(t, send, "emoji_update") + raw, err := json.Marshal(msg["payload"]) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + if got := string(raw); got != `{"emoji":[]}` { + t.Errorf("payload = %s, want {\"emoji\":[]}", got) + } +} + +func TestBroadcastEmojiUpdate_NilEntriesAreSkipped(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + user := seedOwnerUser(t, database, "emoji-nil-owner") + send := make(chan []byte, 16) + client := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(client) + waitRegistered(t, hub, client) + + hub.BroadcastEmojiUpdate([]*db.Emoji{nil, {ID: 1, Shortcode: "wave"}}) + + payload := decodeEmojiUpdate(t, drainForMsgType(t, send, "emoji_update")) + if len(payload.Emoji) != 1 || payload.Emoji[0].Shortcode != "wave" { + t.Fatalf("emoji = %+v, want just the non-nil entry", payload.Emoji) + } +} diff --git a/Server/ws/hub_roles_test.go b/Server/ws/hub_roles_test.go new file mode 100644 index 00000000..8fa55281 --- /dev/null +++ b/Server/ws/hub_roles_test.go @@ -0,0 +1,168 @@ +package ws_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/ws" +) + +// ─── roles_update ──────────────────────────────────────────────────────────── + +func TestBroadcastRolesUpdate_CarriesFullList(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + user := seedOwnerUser(t, database, "roles-owner") + send := make(chan []byte, 16) + client := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(client) + waitRegistered(t, hub, client) + + roles, err := database.ListRoles(context.Background()) + if err != nil { + t.Fatalf("ListRoles: %v", err) + } + hub.BroadcastRolesUpdate(roles) + + msg := drainForMsgType(t, send, "roles_update") + payload, ok := msg["payload"].(map[string]any) + if !ok { + t.Fatalf("payload missing: %v", msg) + } + list, ok := payload["roles"].([]any) + if !ok { + t.Fatalf("roles missing: %v", payload) + } + if len(list) != len(roles) { + t.Fatalf("broadcast carried %d roles, want %d", len(list), len(roles)) + } + first, ok := list[0].(map[string]any) + if !ok { + t.Fatalf("role entry is not an object: %v", list[0]) + } + // The client refreshes channelsStore.roles from this, so it needs the same + // fields the ready payload's role list carries. + for _, key := range []string{"id", "name", "color", "permissions", "position", "is_default"} { + if _, present := first[key]; !present { + t.Errorf("role entry missing %q: %v", key, first) + } + } +} + +func TestBroadcastRolesUpdate_NilEntriesAreSkipped(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + user := seedOwnerUser(t, database, "roles-nil-owner") + send := make(chan []byte, 16) + client := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(client) + waitRegistered(t, hub, client) + + // A nil in the slice must not produce a null entry the client would have + // to defend against. + hub.BroadcastRolesUpdate([]*db.Role{nil, {ID: 7, Name: "Helper", Position: 3}}) + + msg := drainForMsgType(t, send, "roles_update") + raw, err := json.Marshal(msg["payload"]) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + var payload struct { + Roles []db.Role `json:"roles"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if len(payload.Roles) != 1 || payload.Roles[0].Name != "Helper" { + t.Fatalf("roles = %+v, want just the non-nil entry", payload.Roles) + } +} + +// ─── RefreshAllChannelVisibility ───────────────────────────────────────────── + +func TestRefreshAllChannelVisibility_CoversEveryChannel(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + firstID := seedTestChannel(t, database, "room-one") + secondID := seedTestChannel(t, database, "room-two") + + memberID := seedTestUser(t, database, "vis-all-member") + member, err := database.GetUserByID(context.Background(), memberID) + if err != nil || member == nil { + t.Fatalf("GetUserByID: %v", err) + } + send := make(chan []byte, 32) + client := ws.NewTestClientWithUser(hub, member, firstID, send) + hub.Register(client) + waitRegistered(t, hub, client) + + // Strip READ_MESSAGES from the Member role itself — the case a role edit + // produces, where no single channel's overrides changed but every channel's + // audience did. + if _, err := database.ExecContext(context.Background(), + `UPDATE roles SET permissions = 0 WHERE id = 4`, + ); err != nil { + t.Fatalf("strip role permissions: %v", err) + } + + hub.RefreshAllChannelVisibility() + + // Both channels are revoked, not just the focused one. + seen := map[int64]bool{} + for range 2 { + msg := drainForMsgType(t, send, "channel_delete") + payload, ok := msg["payload"].(map[string]any) + if !ok { + t.Fatalf("channel_delete payload missing: %v", msg) + } + id, ok := payload["id"].(float64) + if !ok { + t.Fatalf("channel_delete carries no id: %v", payload) + } + seen[int64(id)] = true + } + if !seen[firstID] || !seen[secondID] { + t.Errorf("revoked channels = %v, want both %d and %d", seen, firstID, secondID) + } +} + +func TestRefreshAllChannelVisibility_SkipsDMChannels(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + memberID := seedTestUser(t, database, "vis-dm-member") + member, err := database.GetUserByID(context.Background(), memberID) + if err != nil || member == nil { + t.Fatalf("GetUserByID: %v", err) + } + otherID := seedTestUser(t, database, "vis-dm-other") + dm, _, err := database.GetOrCreateDMChannel(context.Background(), memberID, otherID) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + + send := make(chan []byte, 32) + client := ws.NewTestClientWithUser(hub, member, dm.ID, send) + hub.Register(client) + waitRegistered(t, hub, client) + + // A DM's access is participation, which no role change can revoke — so a + // role-driven visibility sweep must leave it alone. + if _, err := database.ExecContext(context.Background(), + `UPDATE roles SET permissions = 0 WHERE id = 4`, + ); err != nil { + t.Fatalf("strip role permissions: %v", err) + } + hub.RefreshAllChannelVisibility() + + assertNoMsgType(t, send, "channel_delete") +} diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index d993e880..ee6b43e5 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -236,6 +236,58 @@ func TestHub_BroadcastToChannel_ZeroChannelSendsToAll(t *testing.T) { assertReceived(t, s1, msg, "client") } +// ─── BroadcastChatBulkDeleted ───────────────────────────────────────────────── + +// One chat_bulk_deleted carrying every purged id reaches the channel's +// subscribers, and nobody else — a purge must not fan out N chat_deleted events +// nor disclose ids to a client focused elsewhere. +func TestHub_BroadcastChatBulkDeleted_OneEventToChannelMembers(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + chID := seedTestChannel(t, database, "purged") + u1 := seedTestUser(t, database, "bulk1") + u2 := seedTestUser(t, database, "bulk2") + + s1 := make(chan []byte, 4) + s2 := make(chan []byte, 4) + hub.Register(ws.NewTestClientWithChannel(hub, u1, chID, s1)) + c2 := ws.NewTestClientWithChannel(hub, u2, 999, s2) + hub.Register(c2) + waitRegistered(t, hub, c2) + + hub.BroadcastChatBulkDeleted(chID, []int64{7, 6, 5}) + + select { + case got := <-s1: + var env struct { + Type string `json:"type"` + Payload struct { + ChannelID int64 `json:"channel_id"` + IDs []int64 `json:"ids"` + } `json:"payload"` + } + if err := json.Unmarshal(got, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "chat_bulk_deleted" { + t.Errorf("type = %q, want chat_bulk_deleted", env.Type) + } + if env.Payload.ChannelID != chID { + t.Errorf("channel_id = %d, want %d", env.Payload.ChannelID, chID) + } + if !slices.Equal(env.Payload.IDs, []int64{7, 6, 5}) { + t.Errorf("ids = %v, want [7 6 5]", env.Payload.IDs) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("channel member did not receive chat_bulk_deleted") + } + + assertNotReceived(t, s1, "channel member (second event)") + assertNotReceived(t, s2, "client focused on another channel") +} + // ─── BUG-122: Unfocused client must NOT receive channel-scoped broadcasts ──── func TestHub_BroadcastToChannel_SkipsUnfocusedClient(t *testing.T) { @@ -1208,7 +1260,10 @@ CREATE TABLE IF NOT EXISTS users ( banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT, - identity_public_key TEXT + identity_public_key TEXT, + display_name TEXT, + about TEXT, + custom_status TEXT ); CREATE TABLE IF NOT EXISTS sessions ( @@ -1235,7 +1290,9 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, - voice_max_video INTEGER NOT NULL DEFAULT 0 + voice_max_video INTEGER NOT NULL DEFAULT 0, + nsfw INTEGER NOT NULL DEFAULT 0, + is_group INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channel_overrides ( @@ -1247,6 +1304,14 @@ CREATE TABLE IF NOT EXISTS channel_overrides ( UNIQUE(channel_id, role_id) ); +CREATE TABLE IF NOT EXISTS channel_user_overrides ( + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (channel_id, user_id) +); + CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, @@ -1256,8 +1321,15 @@ CREATE TABLE IF NOT EXISTS messages ( edited_at TEXT, deleted INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, - timestamp TEXT NOT NULL DEFAULT (datetime('now')) + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + mentions_everyone INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS message_mentions ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, mentioned_user_id) +); + CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content, diff --git a/Server/ws/livekit.go b/Server/ws/livekit.go index a1392e00..fcd45738 100644 --- a/Server/ws/livekit.go +++ b/Server/ws/livekit.go @@ -173,6 +173,46 @@ func (c *LiveKitClient) RemoveParticipant(ctx context.Context, channelID int64, return nil } +// MuteParticipantAudio mutes or unmutes every microphone track the participant +// publishes, so a moderator's server mute holds at the SFU instead of relying +// on the target's client to honor it. A participant with no published audio +// track yet is not an error: the room join grant is re-derived on the next +// token mint, and the client refuses its own unmute while server_muted. +func (c *LiveKitClient) MuteParticipantAudio(ctx context.Context, channelID, userID int64, voiceJoinToken string, muted bool) error { + roomName := RoomName(channelID) + identity := participantIdentity(userID, voiceJoinToken) + + ctx, cancel := context.WithTimeout(ctx, lkTimeout) + defer cancel() + p, err := c.roomSvc.GetParticipant(ctx, &livekit.RoomParticipantIdentity{ + Room: roomName, + Identity: identity, + }) + if err != nil { + return fmt.Errorf("livekit: getting participant %s in %s: %w", identity, roomName, err) + } + + for _, t := range p.Tracks { + if t.Type != livekit.TrackType_AUDIO { + continue + } + if _, mErr := c.roomSvc.MutePublishedTrack(ctx, &livekit.MuteRoomTrackRequest{ + Room: roomName, + Identity: identity, + TrackSid: t.Sid, + Muted: muted, + }); mErr != nil { + return fmt.Errorf("livekit: muting track %s of %s: %w", t.Sid, identity, mErr) + } + } + + slog.Info("livekit: server mute applied", + "identity", identity, + "room", roomName, + "muted", muted) + return nil +} + // ListParticipants returns all participants in a channel's voice room. func (c *LiveKitClient) ListParticipants(channelID int64) ([]*livekit.ParticipantInfo, error) { roomName := RoomName(channelID) diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index e3c6c268..131c62e1 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -81,11 +81,21 @@ func (p *LiveKitProcess) generateConfig() (string, error) { // Sanitize credentials for safe YAML interpolation: reject strings // containing characters that could break YAML structure. - for _, cred := range []string{p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret} { - for _, ch := range cred { - if ch == ':' || ch == '#' || ch == '{' || ch == '}' || ch == '\n' || ch == '\r' || ch == '"' || ch == '\\' { - return "", fmt.Errorf("LiveKit credential contains unsafe YAML character %q", string(ch)) - } + // + // The error names the offending config field but never echoes the + // offending byte: this error is wrapped by Start() and logged by the + // caller, so quoting a character from the key or secret would write a + // byte of a credential to the server log in clear text. + const unsafeYAML = ":#{}\n\r\"\\" + for _, cred := range []struct { + field string + value string + }{ + {"voice.livekit_api_key", p.cfg.LiveKitAPIKey}, + {"voice.livekit_api_secret", p.cfg.LiveKitAPISecret}, + } { + if strings.ContainsAny(cred.value, unsafeYAML) { + return "", fmt.Errorf(`%s contains a character that cannot be safely written to livekit.yaml (one of : # { } " \ CR LF)`, cred.field) } } // Build node_ip line only when configured (required for remote users behind NAT). diff --git a/Server/ws/livekit_test.go b/Server/ws/livekit_test.go index 7debe3c1..ea4f04f6 100644 --- a/Server/ws/livekit_test.go +++ b/Server/ws/livekit_test.go @@ -629,6 +629,56 @@ func TestGenerateConfig_UnsafeCredentialChars(t *testing.T) { } } +// The rejection error is wrapped by Start() and logged by the caller, so it +// must never echo any part of the credential it rejected — quoting even the +// single offending byte writes a piece of a secret to the server log in clear +// text (CodeQL go/clear-text-logging). It must still name the field at fault. +func TestGenerateConfig_UnsafeCredentialErrorDoesNotLeakCredential(t *testing.T) { + t.Parallel() + + const ( + key = "sup3rsecret:apikey" + secret = "sup3rsecret{apisecret" + ) + + for _, tt := range []struct { + name string + key string + sec string + field string + leak string + }{ + {"key", key, "safesecret", "voice.livekit_api_key", key}, + {"secret", "safekey", secret, "voice.livekit_api_secret", secret}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + proc := ws.NewLiveKitProcess(&config.VoiceConfig{ + LiveKitAPIKey: tt.key, + LiveKitAPISecret: tt.sec, + LiveKitURL: "ws://localhost:7880", + }, &config.TLSConfig{}, t.TempDir()) + + _, err := proc.GenerateConfigForTest() + if err == nil { + t.Fatal("expected an error for the unsafe credential, got nil") + } + msg := err.Error() + if strings.Contains(msg, tt.leak) { + t.Errorf("error leaks the credential verbatim: %q", msg) + } + // The distinctive prefix must not appear even partially quoted. + if strings.Contains(msg, "sup3rsecret") { + t.Errorf("error leaks part of the credential: %q", msg) + } + if !strings.Contains(msg, tt.field) { + t.Errorf("error should name the offending field %q, got %q", tt.field, msg) + } + }) + } +} + func TestGenerateConfig_UnsafeNodeIPChars(t *testing.T) { t.Parallel() diff --git a/Server/ws/livekit_webhook_fuzz_test.go b/Server/ws/livekit_webhook_fuzz_test.go new file mode 100644 index 00000000..7ca051cf --- /dev/null +++ b/Server/ws/livekit_webhook_fuzz_test.go @@ -0,0 +1,115 @@ +package ws + +import ( + "strconv" + "strings" + "testing" +) + +// FuzzParseParticipantIdentity checks parseParticipantIdentity, which parses +// an untrusted LiveKit webhook participant identity of the form +// "user-{id}" or "user-{id}:{joinToken}". Invariant: it never panics on any +// input, and on success the returned (userID, joinToken) are exactly what +// the "user-" + strconv.ParseInt + strings.Cut(":") pipeline the source uses +// would produce — i.e. identity must start with "user-", and the ID part +// (up to the first ':') must parse as the returned userID with the returned +// joinToken being everything after that first ':' (or "" if none). +func FuzzParseParticipantIdentity(f *testing.F) { + seeds := []string{ + "", + "user-", + "user-1", + "user-1:tok", + "user-1:tok:extra:colons", + "user--1", + "user-9223372036854775807", // max int64 + "user-9223372036854775808", // overflow + "user--9223372036854775808", // min int64 + "user-abc", + "user-1abc", + "user-1 ", + "USER-1", + "user-1\x00:tok", + "user-1:", + "user-:tok", + "not-a-user", + "user", + "user-0", + "user-+1", + "user-01", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, identity string) { + userID, joinToken, err := parseParticipantIdentity(identity) + if err != nil { + return + } + + if !strings.HasPrefix(identity, "user-") { + t.Fatalf("parseParticipantIdentity(%q) = (%d,%q,nil), but input lacks the required user- prefix", identity, userID, joinToken) + } + body := identity[len("user-"):] + idPart, wantJoinToken, _ := strings.Cut(body, ":") + + wantUserID, perr := strconv.ParseInt(idPart, 10, 64) + if perr != nil { + t.Fatalf("parseParticipantIdentity(%q) = (%d,%q,nil), but the id part %q does not itself parse as int64: %v", identity, userID, joinToken, idPart, perr) + } + if wantUserID != userID { + t.Fatalf("parseParticipantIdentity(%q) = userID %d, want %d (parsed from id part %q)", identity, userID, wantUserID, idPart) + } + if wantJoinToken != joinToken { + t.Fatalf("parseParticipantIdentity(%q) = joinToken %q, want %q", identity, joinToken, wantJoinToken) + } + }) +} + +// FuzzParseRoomChannelID checks parseRoomChannelID, which parses an +// untrusted LiveKit webhook room name of the form "channel-{id}". Invariant: +// it never panics on any input, and on success the input must start with +// "channel-" and the remainder must parse as the returned channelID. +func FuzzParseRoomChannelID(f *testing.F) { + seeds := []string{ + "", + "channel-", + "channel-1", + "channel--1", + "channel-9223372036854775807", + "channel-9223372036854775808", + "channel--9223372036854775808", + "channel-abc", + "channel-1abc", + "CHANNEL-1", + "not-a-channel", + "channel", + "channel-0", + "channel-+1", + "channel-01", + "channel-1\x00", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, roomName string) { + channelID, err := parseRoomChannelID(roomName) + if err != nil { + return + } + + if !strings.HasPrefix(roomName, "channel-") { + t.Fatalf("parseRoomChannelID(%q) = (%d,nil), but input lacks the required channel- prefix", roomName, channelID) + } + idPart := roomName[len("channel-"):] + wantChannelID, perr := strconv.ParseInt(idPart, 10, 64) + if perr != nil { + t.Fatalf("parseRoomChannelID(%q) = (%d,nil), but the id part %q does not itself parse as int64: %v", roomName, channelID, idPart, perr) + } + if wantChannelID != channelID { + t.Fatalf("parseRoomChannelID(%q) = %d, want %d (parsed from id part %q)", roomName, channelID, wantChannelID, idPart) + } + }) +} diff --git a/Server/ws/load_soak_test.go b/Server/ws/load_soak_test.go new file mode 100644 index 00000000..bb54a47f --- /dev/null +++ b/Server/ws/load_soak_test.go @@ -0,0 +1,295 @@ +package ws_test + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + "testing/fstest" + "time" + + "go.uber.org/goleak" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/service" + "github.com/owncord/server/ws" +) + +// TestTheLoadTest is a load/soak test for the hub's concurrency machinery: it +// churns a few hundred WS clients through Register/Unregister from many +// goroutines while other goroutines drive broadcasts (chat_message via the +// real chat_send handler path, voice_state, presence, and raw channel-scoped +// fan-out) and channel focus changes, all against one live hub. +// +// It exercises two paths that are easy to get right in isolation but wrong +// under contention: +// - channelReadAudience, the per-broadcast permission-audience resolution +// used by voice_state/voice_leave and channel_create/update fan-out; +// - MessageService's background mention-count bookkeeping, which the +// production code fires with a bare `go fn()` per send (see +// RunBackgroundInlineForTest's doc comment on MessageService.bg). +// +// Skipped under -short. Run explicitly with: +// +// go test ./ws/ -race -run TheLoadTest -count=1 +func TestTheLoadTest(t *testing.T) { + if testing.Short() { + t.Skip("skipping load/soak test in -short mode") + } + + // Baseline before anything is started, so this test only fails on + // goroutines IT leaked — not on background goroutines belonging to + // earlier tests in the same binary that happen to still be unwinding. + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) + + const ( + numChannels = 4 + numAnchors = 8 // steady readers, registered for the whole run + numChurnUsers = 200 // distinct users churned through register/unregister + numChurnWorkers = 20 + churnRounds = 4 + numBroadcasters = 6 + broadcastIters = 30 + overallTimeout = 90 * time.Second + ) + + // Deliberately not the shared openServeTestDB(t) helper: it closes the DB + // via t.Cleanup, which fires AFTER this function's own defers (including + // the goleak check above) — leaving database/sql's connectionOpener + // goroutine looking "leaked" at check time even though it would close + // fine a moment later. Closing it with an ordinary defer, positioned + // after the goleak defer so it runs first (defers are LIFO), keeps the + // check honest. + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + defer func() { _ = database.Close() }() + migrFS := fstest.MapFS{"001_schema.sql": {Data: serveTestSchema}} + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + + limiter := auth.NewRateLimiter() + svc := service.New(database, limiter) + hub := ws.NewHub(database, limiter, svc) + + runDone := make(chan struct{}) + go func() { + hub.Run() + close(runDone) + }() + waitFor(t, waitTimeout, hub.RunningForTest, "hub Run loop to start") + // Registered after database's defer above, so it runs first (LIFO): the + // hub — and every background goroutine it owns — is fully stopped before + // the DB closes under it. + defer func() { + hub.Stop() + select { + case <-runDone: + case <-time.After(5 * time.Second): + t.Error("hub.Run() did not stop after hub.Stop()") + } + }() + + ctx := context.Background() + + // ── shared channels every client fans in and out of ──────────────────── + chIDs := make([]int64, numChannels) + for i := range chIDs { + chIDs[i] = seedTestChannel(t, database, fmt.Sprintf("load-ch-%d", i)) + } + + // ── anchors: registered once, stay up for the whole run, and are the + // steady audience broadcasts land on plus the @mention targets that + // exercise applyMentionCounts. ─────────────────────────────────────────── + type anchor struct { + user *db.User + c *ws.Client + send chan []byte + stopDrain chan struct{} + } + anchors := make([]anchor, 0, numAnchors) + for i := range numAnchors { + u := seedOwnerUser(t, database, fmt.Sprintf("load-anchor-%d", i)) + send := make(chan []byte, 1024) + c := ws.NewTestClientWithUser(hub, u, chIDs[i%numChannels], send) + hub.Register(c) + + // Drain continuously so a busy channel never trips the client's + // full-buffer auto-disconnect (BUG-124 behavior) — that disconnect is + // correct production behavior but not what this test means to probe. + stop := make(chan struct{}) + go func(ch chan []byte, stop chan struct{}) { + for { + select { + case <-ch: + case <-stop: + return + } + } + }(send, stop) + + anchors = append(anchors, anchor{user: u, c: c, send: send, stopDrain: stop}) + } + for _, a := range anchors { + waitRegistered(t, hub, a.c) + } + mentionNames := make([]string, len(anchors)) + for i, a := range anchors { + mentionNames[i] = a.user.Username + } + + // ── churn pool: pre-seeded so DB writes stay off the timed section; the + // timed section only churns hub Register/Unregister + message dispatch. ── + churnUsers := make([]*db.User, numChurnUsers) + for i := range numChurnUsers { + uid := seedTestUser(t, database, fmt.Sprintf("load-churn-%d", i)) + u, err := database.GetUserByID(ctx, uid) + if err != nil || u == nil { + t.Fatalf("GetUserByID(%d): %v", uid, err) + } + churnUsers[i] = u + } + + var wg sync.WaitGroup + usersPerWorker := numChurnUsers / numChurnWorkers + + // ── churn goroutines: concurrent register -> channel_focus -> chat_send + // (mentioning an anchor) -> presence_update -> unregister, repeated for + // churnRounds per assigned user. ──────────────────────────────────────── + for w := range numChurnWorkers { + wg.Add(1) + go func(workerIdx int) { + defer wg.Done() + start := workerIdx * usersPerWorker + end := start + usersPerWorker + for round := range churnRounds { + for ui := start; ui < end; ui++ { + user := churnUsers[ui] + chID := chIDs[(ui+round)%numChannels] + focusChID := chIDs[(ui+round+1)%numChannels] + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + + focusPayload, _ := json.Marshal(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{"channel_id": focusChID}, + }) + hub.HandleMessageForTest(c, focusPayload) + + target := mentionNames[(ui+round)%len(mentionNames)] + content := fmt.Sprintf("hi @%s from churn user %d round %d", target, user.ID, round) + chatPayload, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "id": fmt.Sprintf("req-%d-%d", user.ID, round), + "payload": map[string]any{ + "channel_id": chID, + "content": content, + }, + }) + hub.HandleMessageForTest(c, chatPayload) + + status := []string{"online", "idle", "dnd"}[round%3] + presPayload, _ := json.Marshal(map[string]any{ + "type": "presence_update", + "payload": map[string]any{"status": status}, + }) + hub.HandleMessageForTest(c, presPayload) + + hub.Unregister(c) + } + } + }(w) + } + + // ── broadcaster goroutines: hammer the real broadcast paths (channel- + // scoped fan-out, voice_state/channelReadAudience, presence, and + // channel_update/channelReadAudience) concurrently with the churn above. ─ + for b := range numBroadcasters { + wg.Add(1) + go func(idx int) { + defer wg.Done() + for i := range broadcastIters { + chID := chIDs[(idx+i)%numChannels] + switch i % 4 { + case 0: + msg := fmt.Appendf(nil, `{"type":"chat_message","payload":{"synthetic":%d}}`, i) + hub.BroadcastToChannel(chID, msg) + case 1: + msg := fmt.Appendf(nil, `{"type":"voice_state","payload":{"channel_id":%d,"user_id":%d}}`, chID, idx) + hub.BroadcastVoiceEventForTest(chID, msg) + case 2: + anchorUser := anchors[idx%len(anchors)].user + status := []string{"online", "idle", "dnd", "invisible"}[i%4] + hub.BroadcastPresence(anchorUser.ID, status, nil) + case 3: + // BroadcastChannelUpdate is called by the admin HubBroadcaster + // interface, which carries no context (see hub_broadcast.go); + // context.Background() matches that production call shape, + // not the request-scoped ctx used elsewhere in this test. + ch, err := database.GetChannel(context.Background(), chID) + if err == nil && ch != nil { + hub.BroadcastChannelUpdate(ch) // exercises channelReadAudience + } + } + } + }(b) + } + + // Fail loudly instead of hanging if anything above ever deadlocks: every + // send in the production path is non-blocking (select+default), so the + // only way this fires is a genuine bug (e.g. the hub loop stuck, or a + // lock held across a blocking call). + allDone := make(chan struct{}) + go func() { + wg.Wait() + close(allDone) + }() + select { + case <-allDone: + case <-time.After(overallTimeout): + t.Fatal("load test workers did not finish within timeout — possible deadlock in the hub") + } + + if !hub.RunningForTest() { + t.Error("hub stopped running mid-test (panic-loop guard tripped?)") + } + + // Only the anchors should remain registered — every churned client + // unregistered itself at the end of its own round. + waitFor(t, 5*time.Second, func() bool { return hub.ClientCount() == numAnchors }, + "churned clients to fully unregister") + if got := hub.ClientCount(); got != numAnchors { + t.Errorf("ClientCount = %d after churn settled, want %d (anchors only)", got, numAnchors) + } + + t.Logf("load test done: %d anchors, %d churn users x %d rounds, %d broadcasters x %d iters, broadcast drops=%d", + numAnchors, numChurnUsers, churnRounds, numBroadcasters, broadcastIters, hub.BroadcastDropCount()) + + for _, a := range anchors { + hub.Unregister(a.c) + } + waitFor(t, 5*time.Second, func() bool { return hub.ClientCount() == 0 }, "anchors to unregister") + for _, a := range anchors { + close(a.stopDrain) + } + + // SendMessage fires mention-count bookkeeping with a bare `go fn()` and + // deliberately does not wait for it (see MessageService.bg) — that is the + // exact background path this test means to exercise. Give the last few + // in-flight goroutines a moment to finish their handful of DB queries + // before the deferred teardown stops the hub and closes the DB out from + // under them; without this they still exit cleanly (goleak retries with + // backoff), but they'd do it via a "database is closed" error instead of + // completing the work, which is a false alarm this bounded wait avoids. + time.Sleep(300 * time.Millisecond) + + // hub.Stop()/runDone wait and the DB close happen in the deferred cleanup + // above (LIFO: hub first, then DB, then the goleak check registered at + // the top of this test). +} diff --git a/Server/ws/mentions_ready_test.go b/Server/ws/mentions_ready_test.go new file mode 100644 index 00000000..a5ef7e88 --- /dev/null +++ b/Server/ws/mentions_ready_test.go @@ -0,0 +1,163 @@ +package ws_test + +// mentions_ready_test.go: the ready payload's per-channel mention_count and the +// chat_message broadcast's mention fields (phase 3). + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/ws" +) + +// TestChatSend_BroadcastCarriesMentions locks that the chat_message fan-out +// ships server-resolved mention ids and the @everyone flag, so clients never +// have to re-guess them from the content. +func TestChatSend_BroadcastCarriesMentions(t *testing.T) { + hub, database := newCoverageHub(t) + // Mention counts are written on a background goroutine in production; run + // them inline so this test can read GetMentionCount right after the send. + hub.RunMentionCountsInlineForTest() + ctx := context.Background() + + author := seedCoverageOwner(t, database, "mention-author") // owner role holds MENTION_EVERYONE + target := seedCoverageOwner(t, database, "mention-target") + chID := seedTestChannel(t, database, "mention-chan") + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, author, chID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": chID, + "content": "@everyone please review, @mention-target", + }, + }) + hub.HandleMessageForTest(c, raw) + + var found bool + for _, msg := range drainChanTimeout(send, 300*time.Millisecond) { + var env struct { + Type string `json:"type"` + Payload struct { + Mentions []int64 `json:"mentions"` + MentionsEveryone bool `json:"mentions_everyone"` + } `json:"payload"` + } + if json.Unmarshal(msg, &env) != nil || env.Type != "chat_message" { + continue + } + found = true + if !env.Payload.MentionsEveryone { + t.Error("mentions_everyone = false, want true") + } + if len(env.Payload.Mentions) != 1 || env.Payload.Mentions[0] != target.ID { + t.Errorf("mentions = %v, want [%d]", env.Payload.Mentions, target.ID) + } + } + if !found { + t.Fatal("no chat_message broadcast received") + } + + // The mentioned reader's badge went up; the author's did not. + if n, _ := database.GetMentionCount(ctx, target.ID, chID); n != 1 { + t.Errorf("target mention_count = %d, want 1", n) + } + if n, _ := database.GetMentionCount(ctx, author.ID, chID); n != 0 { + t.Errorf("author mention_count = %d, want 0", n) + } +} + +func TestBuildReady_CarriesMentionCount(t *testing.T) { + hub, database := newCoverageHub(t) + ctx := context.Background() + + user := seedCoverageOwner(t, database, "mention-reader") + role, err := database.GetRoleByID(ctx, 1) + if err != nil || role == nil { + t.Fatalf("GetRoleByID: %v", err) + } + chID, err := database.CreateChannel(ctx, "general", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.IncrementMentionCounts(ctx, chID, []int64{user.ID}); err != nil { + t.Fatalf("IncrementMentionCounts: %v", err) + } + + msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role) + if err != nil { + t.Fatalf("BuildReadyWithRoleForTest: %v", err) + } + + var env struct { + Payload struct { + Channels []struct { + ID int64 `json:"id"` + MentionCount *int `json:"mention_count"` + } `json:"channels"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + var found bool + for _, ch := range env.Payload.Channels { + if ch.ID != chID { + continue + } + found = true + if ch.MentionCount == nil { + t.Fatal("channel is missing mention_count") + } + if *ch.MentionCount != 1 { + t.Errorf("mention_count = %d, want 1", *ch.MentionCount) + } + } + if !found { + t.Fatalf("channel %d missing from ready payload", chID) + } +} + +// TestBuildReady_MentionCountZeroWithoutReadState locks that a channel with no +// read_states row still ships the field, so the client never sees it undefined. +func TestBuildReady_MentionCountZeroWithoutReadState(t *testing.T) { + hub, database := newCoverageHub(t) + ctx := context.Background() + + user := seedCoverageOwner(t, database, "mention-fresh") + role, err := database.GetRoleByID(ctx, 1) + if err != nil || role == nil { + t.Fatalf("GetRoleByID: %v", err) + } + if _, err := database.CreateChannel(ctx, "quiet", "text", "", "", 0); err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role) + if err != nil { + t.Fatalf("BuildReadyWithRoleForTest: %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) == 0 { + t.Fatal("no channels in ready payload") + } + for _, ch := range env.Payload.Channels { + if _, ok := ch["mention_count"]; !ok { + t.Errorf("text channel %v is missing mention_count", ch["id"]) + } + } +} diff --git a/Server/ws/message_types.go b/Server/ws/message_types.go index 7e08c2a5..dc258fd9 100644 --- a/Server/ws/message_types.go +++ b/Server/ws/message_types.go @@ -16,6 +16,7 @@ const ( MsgTypeReactionRemove = "reaction_remove" MsgTypeTypingStart = "typing_start" MsgTypeChannelFocus = "channel_focus" + MsgTypeMarkRead = "mark_read" MsgTypePresenceUpdate = "presence_update" MsgTypeVoiceJoin = "voice_join" MsgTypeVoiceLeave = "voice_leave" @@ -23,10 +24,16 @@ const ( MsgTypeVoiceDeafen = "voice_deafen" MsgTypeVoiceCamera = "voice_camera" MsgTypeVoiceScreenshare = "voice_screenshare" + MsgTypeVoiceModMute = "voice_mod_mute" + MsgTypeVoiceModDeafen = "voice_mod_deafen" + MsgTypeVoiceModMove = "voice_mod_move" + MsgTypeVoiceModKick = "voice_mod_kick" MsgTypePing = "ping" MsgTypeVoiceTokenRefresh = "voice_token_refresh" //nolint:gosec // G101: false positive — message type constant, not a credential MsgTypeVoiceE2EEAnnounce = "voice_e2ee_announce" MsgTypeVoiceE2EEOffer = "voice_e2ee_offer" + MsgTypeCallRing = "call_ring" + MsgTypeCallDecline = "call_decline" ) // Server → Client message types (sent in broadcasts/responses). @@ -38,6 +45,7 @@ const ( MsgTypeChatSendOK = "chat_send_ok" MsgTypeChatEdited = "chat_edited" MsgTypeChatDeleted = "chat_deleted" + MsgTypeChatBulkDeleted = "chat_bulk_deleted" MsgTypeReactionUpdate = "reaction_update" MsgTypeTyping = "typing" MsgTypePresence = "presence" @@ -49,16 +57,22 @@ const ( MsgTypeVoiceToken = "voice_token" MsgTypeVoiceSpeakers = "voice_speakers" MsgTypeVoiceLeaveBC = "voice_leave" // broadcast (same string as client msg) + MsgTypeVoiceMoved = "voice_moved" + MsgTypeVoiceDisconnected = "voice_disconnected" MsgTypeMemberJoin = "member_join" MsgTypeMemberLeave = "member_leave" MsgTypeMemberUpdate = "member_update" MsgTypeUserUpdate = "user_update" MsgTypeMemberBan = "member_ban" + MsgTypeRolesUpdate = "roles_update" + MsgTypeEmojiUpdate = "emoji_update" MsgTypeServerRestart = "server_restart" MsgTypeError = "error" MsgTypePong = "pong" MsgTypeDMChannelOpen = "dm_channel_open" MsgTypeDMChannelClose = "dm_channel_close" + MsgTypeCallIncoming = "call_incoming" + MsgTypeCallDeclined = "call_declined" MsgTypeVoiceE2EEAnnounceBC = "voice_e2ee_announce" // broadcast (same string as client msg) MsgTypeVoiceE2EEOfferRelay = "voice_e2ee_offer" // relay (same string as client msg) ) diff --git a/Server/ws/messages.go b/Server/ws/messages.go index a19b2ebd..38f6193c 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -6,6 +6,7 @@ import ( "log/slog" "github.com/owncord/server/db" + "github.com/owncord/server/service" ) // envelope is the common wrapper for all WebSocket messages. @@ -29,6 +30,11 @@ type wsMsg struct { type presencePayload struct { UserID int64 `json:"user_id"` Status string `json:"status"` + // CustomStatus is the user's free-text status line. Always present (null + // when unset) rather than omitempty: a client has to be able to tell + // "cleared it" from "this event does not mention it", and every presence + // broadcast carries the current value. + CustomStatus *string `json:"custom_status"` } type memberUserPayload struct { @@ -36,6 +42,10 @@ type memberUserPayload struct { Username string `json:"username"` Avatar *string `json:"avatar"` Role string `json:"role"` + // DisplayName is the nickname to render instead of Username. Omitted when + // unset; clients fall back to Username. Username stays on the wire because + // it is still the unique handle mentions resolve against. + DisplayName *string `json:"display_name,omitempty"` // IdentityPublicKey is the user's long-term E2EE identity public key // (base64), pinned by peers on first sight (F3 TOFU). Omitted when the // user has not published one (legacy client) and in payloads that do not @@ -45,6 +55,15 @@ type memberUserPayload struct { type memberJoinPayload struct { User memberUserPayload `json:"user"` + // Status is the viewer-safe presence the connecting user comes online as + // (db.BroadcastStatus of the ConnectStatus-mapped value): an invisible + // connector reports "offline" here, never their true chosen status. This + // is BroadcastToAll, not the channel-scoped presence path, so every + // connected client — invisible or not — receives it; the client MUST + // render members from this field rather than assuming "online" just + // because a member_join arrived, or an invisible user renders online + // until the (droppable, low-priority) presence correction catches up. + Status string `json:"status"` } type chatMessagePayload struct { @@ -57,6 +76,11 @@ type chatMessagePayload struct { Attachments []map[string]any `json:"attachments"` Reactions []any `json:"reactions"` Pinned bool `json:"pinned"` + // Mentions carries the server-resolved user ids; MentionsEveryone reports + // an @everyone/@here that cleared MENTION_EVERYONE. Clients highlight from + // these instead of re-parsing the content. + Mentions []int64 `json:"mentions"` + MentionsEveryone bool `json:"mentions_everyone"` } type memberUpdatePayload struct { @@ -68,6 +92,11 @@ type userUpdatePayload struct { UserID int64 `json:"user_id"` Username string `json:"username"` Avatar *string `json:"avatar"` + // DisplayName and About are always present (null = cleared) so a profile + // edit that removes either one is distinguishable from one that leaves it + // alone — user_update replaces the client's copy wholesale. + DisplayName *string `json:"display_name"` + About *string `json:"about"` // IdentityPublicKey mirrors memberUserPayload — carried so peers can // detect an identity-key change (TOFU mismatch) as it happens. IdentityPublicKey *string `json:"identity_public_key,omitempty"` @@ -77,6 +106,33 @@ type memberBanPayload struct { UserID int64 `json:"user_id"` } +// rolesUpdatePayload carries the whole role list rather than a delta. The +// client's role state is a flat list keyed by id that drives name colors and +// permission gating; replacing it wholesale is both smaller to reason about +// than a patch protocol and immune to a dropped intermediate event leaving a +// deleted role on screen. +type rolesUpdatePayload struct { + Roles []db.Role `json:"roles"` +} + +// emojiInfo is the client-facing shape of one custom emoji: enough to render +// it and to spell it. The storage id and mime type stay server-side -- the +// image route is the only thing that needs them. +type emojiInfo struct { + ID int64 `json:"id"` + Shortcode string `json:"shortcode"` + URL string `json:"url"` +} + +// emojiUpdatePayload carries the whole emoji set, for the same reason +// rolesUpdatePayload carries the whole role list: the client's emoji state is a +// flat map keyed by shortcode that message rendering, the picker and reaction +// pills all read, and a wholesale replace cannot leave a deleted emoji on +// screen after a dropped event. +type emojiUpdatePayload struct { + Emoji []emojiInfo `json:"emoji"` +} + type chatSendOKPayload struct { MessageID int64 `json:"message_id"` Timestamp string `json:"timestamp"` @@ -87,6 +143,10 @@ type chatEditedPayload struct { ChannelID int64 `json:"channel_id"` Content string `json:"content"` EditedAt string `json:"edited_at"` + // Mentions/MentionsEveryone are re-resolved from the edited content, so an + // edit that adds or drops a mention updates the highlight too. + Mentions []int64 `json:"mentions"` + MentionsEveryone bool `json:"mentions_everyone"` } type chatDeletedPayload struct { @@ -94,6 +154,11 @@ type chatDeletedPayload struct { ChannelID int64 `json:"channel_id"` } +type chatBulkDeletedPayload struct { + ChannelID int64 `json:"channel_id"` + IDs []int64 `json:"ids"` +} + type reactionUpdatePayload struct { MessageID int64 `json:"message_id"` ChannelID int64 `json:"channel_id"` @@ -117,6 +182,25 @@ type voiceStatePayload struct { Speaking bool `json:"speaking"` Camera bool `json:"camera"` Screenshare bool `json:"screenshare"` + // ServerMuted/ServerDeafened are moderator-imposed. Muted/Deafened are + // always set alongside them, so a client that ignores these two still + // renders the user as silenced; they exist so the UI can distinguish a + // self-mute from one the user may not lift. + ServerMuted bool `json:"server_muted"` + ServerDeafened bool `json:"server_deafened"` +} + +// voiceMovedPayload tells one client its moderator moved it to another voice +// channel. The client tears down its LiveKit session and re-joins to_channel_id +// through the normal voice_join path. +type voiceMovedPayload struct { + ToChannelID int64 `json:"to_channel_id"` +} + +// voiceDisconnectedPayload tells one client a moderator removed it from voice. +type voiceDisconnectedPayload struct { + ChannelID int64 `json:"channel_id"` + Reason string `json:"reason"` } type voiceConfigPayload struct { @@ -171,6 +255,33 @@ type channelPayload struct { // instead of accepting a message the server will refuse with SLOW_MODE. // Seconds; 0 means off. SlowMode int `json:"slow_mode"` + // NSFW marks the channel as possibly carrying sensitive content. It is + // shipped so clients can gate or label it; the server applies no content + // behaviour of its own to a flagged channel. + NSFW bool `json:"nsfw"` + // Voice capacity limits (0 = unlimited), the same values the voice-join + // path enforces with CHANNEL_FULL / VIDEO_LIMIT. Sent so the sidebar can + // show "3/5" and the client can explain a refusal it could have predicted. + VoiceMaxUsers int `json:"voice_max_users"` + VoiceMaxVideo int `json:"voice_max_video"` +} + +// channelPayloadFrom narrows a channel row to the wire shape shared by the +// channel_create and channel_update broadcasts. One constructor so the two +// events can never disagree about which fields a client is told about. +func channelPayloadFrom(ch *db.Channel) channelPayload { + return channelPayload{ + ID: ch.ID, + Name: ch.Name, + Type: ch.Type, + Category: ch.Category, + Topic: ch.Topic, + Position: ch.Position, + SlowMode: ch.SlowMode, + NSFW: ch.NSFW, + VoiceMaxUsers: ch.VoiceMaxUsers, + VoiceMaxVideo: ch.VoiceMaxVideo, + } } type channelDeletePayload struct { @@ -182,18 +293,13 @@ type serverRestartPayload struct { DelaySeconds int `json:"delay_seconds"` } -// dmChannelOpenPayload is sent when a DM is opened/reopened for a user. -type dmChannelOpenPayload struct { - ChannelID int64 `json:"channel_id"` - Recipient dmUserPayload `json:"recipient"` -} - -// dmUserPayload is the public-facing shape for a DM participant in WS events. -type dmUserPayload struct { - ID int64 `json:"id"` - Username string `json:"username"` - Avatar string `json:"avatar"` - Status string `json:"status"` +// callSignalPayload carries an ephemeral DM call signal (call_incoming / +// call_declined). There is no call id: the "call" is presence in the DM's +// voice channel, so channel_id plus who is signalling is the whole state. +type callSignalPayload struct { + ChannelID int64 `json:"channel_id"` + FromUser int64 `json:"from_user"` + Username string `json:"username"` } // --------------------------------------------------------------------------- @@ -256,14 +362,24 @@ func buildAuthError(message string) []byte { // --------------------------------------------------------------------------- // buildPresenceMsg constructs a presence broadcast payload. -func buildPresenceMsg(userID int64, status string) []byte { +// +// status is taken verbatim: callers decide whose eyes the payload is for and +// pass db.BroadcastStatus(status) for everyone but the owner. Keeping the +// mapping out of the builder is deliberate — a builder that always collapsed +// invisible could never produce the owner's own true-state message. +func buildPresenceMsg(userID int64, status string, customStatus *string) []byte { return buildJSON(wsMsg{ Type: MsgTypePresence, - Payload: presencePayload{UserID: userID, Status: status}, + Payload: presencePayload{UserID: userID, Status: status, CustomStatus: customStatus}, }) } -// buildMemberJoin constructs a member_join broadcast for when a user comes online. +// buildMemberJoin constructs a member_join broadcast for when a user comes +// online. user.Status is expected to already carry the ConnectStatus-mapped +// value the caller settled the session on (see serve.go's applyConnectStatus, +// which runs before this); Status here applies the BroadcastStatus collapse +// so an invisible connector's member_join reports "offline" like every other +// payload another user can see, instead of the raw chosen status. func buildMemberJoin(user *db.User, roleName string) []byte { return buildJSON(wsMsg{ Type: MsgTypeMemberJoin, @@ -273,35 +389,63 @@ func buildMemberJoin(user *db.User, roleName string) []byte { Username: user.Username, Avatar: user.Avatar, Role: roleName, + DisplayName: user.DisplayName, IdentityPublicKey: user.IdentityPublicKey, }, + Status: db.BroadcastStatus(user.Status), }, }) } +// chatMessageArgs is the input to buildChatMessage. It is a struct rather than +// a positional list because the payload has outgrown readable call sites. +type chatMessageArgs struct { + MsgID int64 + ChannelID int64 + UserID int64 + Username string + Avatar *string + DisplayName *string + RoleName string + Content string + Timestamp string + ReplyTo *int64 + Attachments []map[string]any + Mentions []int64 + MentionsEveryone bool +} + // 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 { +func buildChatMessage(a chatMessageArgs) []byte { + attachments := a.Attachments if attachments == nil { attachments = []map[string]any{} } + mentions := a.Mentions + if mentions == nil { + mentions = []int64{} + } return buildJSON(wsMsg{ Type: MsgTypeChatMessage, Payload: chatMessagePayload{ - ID: msgID, - ChannelID: channelID, + ID: a.MsgID, + ChannelID: a.ChannelID, User: memberUserPayload{ - ID: userID, - Username: username, - Avatar: avatar, - Role: roleName, + ID: a.UserID, + Username: a.Username, + Avatar: a.Avatar, + Role: a.RoleName, + DisplayName: a.DisplayName, }, - Content: content, - ReplyTo: replyTo, - Timestamp: timestamp, - Attachments: attachments, - Reactions: []any{}, - Pinned: false, + Content: a.Content, + ReplyTo: a.ReplyTo, + Timestamp: a.Timestamp, + Attachments: attachments, + Reactions: []any{}, + Pinned: false, + Mentions: mentions, + MentionsEveryone: a.MentionsEveryone, }, }) } @@ -314,16 +458,23 @@ func buildMemberUpdate(userID int64, roleName string) []byte { }) } +// UserUpdate is the profile snapshot a user_update broadcast carries. It is a +// struct rather than five positional arguments because every field is a +// nullable string and a swapped pair would compile. +type UserUpdate struct { + UserID int64 + Username string + Avatar *string + DisplayName *string + About *string + IdentityPublicKey *string +} + // buildUserUpdate constructs a user_update broadcast for profile changes. -func buildUserUpdate(userID int64, username string, avatar *string, identityPublicKey *string) []byte { +func buildUserUpdate(u UserUpdate) []byte { return buildJSON(wsMsg{ - Type: MsgTypeUserUpdate, - Payload: userUpdatePayload{ - UserID: userID, - Username: username, - Avatar: avatar, - IdentityPublicKey: identityPublicKey, - }, + Type: MsgTypeUserUpdate, + Payload: userUpdatePayload(u), }) } @@ -335,6 +486,41 @@ func buildMemberBan(userID int64) []byte { }) } +// buildRolesUpdate constructs a roles_update broadcast carrying the full role +// list, ordered highest position first exactly like the ready payload's. +func buildRolesUpdate(roles []*db.Role) []byte { + flat := make([]db.Role, 0, len(roles)) + for _, r := range roles { + if r != nil { + flat = append(flat, *r) + } + } + return buildJSON(wsMsg{ + Type: MsgTypeRolesUpdate, + Payload: rolesUpdatePayload{Roles: flat}, + }) +} + +// buildEmojiUpdate constructs an emoji_update broadcast carrying the full +// custom-emoji set, ordered the way the server listed it. +func buildEmojiUpdate(list []*db.Emoji) []byte { + flat := make([]emojiInfo, 0, len(list)) + for _, e := range list { + if e == nil { + continue + } + flat = append(flat, emojiInfo{ + ID: e.ID, + Shortcode: e.Shortcode, + URL: service.EmojiImageURL(e.ID), + }) + } + return buildJSON(wsMsg{ + Type: MsgTypeEmojiUpdate, + Payload: emojiUpdatePayload{Emoji: flat}, + }) +} + // buildChatSendOK constructs a chat_send_ok ack. func buildChatSendOK(requestID string, msgID int64, timestamp string) []byte { return buildJSON(wsMsg{ @@ -345,14 +531,19 @@ func buildChatSendOK(requestID string, msgID int64, timestamp string) []byte { } // buildChatEdited constructs a chat_edited broadcast. -func buildChatEdited(msgID, channelID int64, content, editedAt string) []byte { +func buildChatEdited(msgID, channelID int64, content, editedAt string, mentions []int64, mentionsEveryone bool) []byte { + if mentions == nil { + mentions = []int64{} + } return buildJSON(wsMsg{ Type: MsgTypeChatEdited, Payload: chatEditedPayload{ - MessageID: msgID, - ChannelID: channelID, - Content: content, - EditedAt: editedAt, + MessageID: msgID, + ChannelID: channelID, + Content: content, + EditedAt: editedAt, + Mentions: mentions, + MentionsEveryone: mentionsEveryone, }, }) } @@ -365,6 +556,19 @@ func buildChatDeleted(msgID, channelID int64) []byte { }) } +// buildChatBulkDeleted constructs a chat_bulk_deleted broadcast. ids is +// emitted as an empty array rather than null when nothing was purged, so +// clients can iterate it unconditionally. +func buildChatBulkDeleted(channelID int64, ids []int64) []byte { + if ids == nil { + ids = []int64{} + } + return buildJSON(wsMsg{ + Type: MsgTypeChatBulkDeleted, + Payload: chatBulkDeletedPayload{ChannelID: channelID, IDs: ids}, + }) +} + // buildReactionUpdate constructs a reaction_update broadcast. func buildReactionUpdate(msgID, channelID, userID int64, emoji, action string) []byte { return buildJSON(wsMsg{ @@ -404,10 +608,30 @@ func buildVoiceState(state db.VoiceState) []byte { Speaking: state.Speaking, Camera: state.Camera, Screenshare: state.Screenshare, + + ServerMuted: state.ServerMuted, + ServerDeafened: state.ServerDeafened, }, }) } +// buildVoiceMoved constructs a voice_moved message for the moved client. +func buildVoiceMoved(toChannelID int64) []byte { + return buildJSON(wsMsg{ + Type: MsgTypeVoiceMoved, + Payload: voiceMovedPayload{ToChannelID: toChannelID}, + }) +} + +// buildVoiceDisconnected constructs a voice_disconnected message for the +// client a moderator removed from voice. +func buildVoiceDisconnected(channelID int64, reason string) []byte { + return buildJSON(wsMsg{ + Type: MsgTypeVoiceDisconnected, + Payload: voiceDisconnectedPayload{ChannelID: channelID, Reason: reason}, + }) +} + // buildVoiceConfig constructs a voice_config message sent after voice_join acceptance. func buildVoiceConfig(channelID int64, quality string, bitrate int, maxUsers int) []byte { return buildJSON(wsMsg{ @@ -476,32 +700,16 @@ func buildVoiceLeave(channelID, userID int64) []byte { // buildChannelCreate constructs a channel_create broadcast. func buildChannelCreate(ch *db.Channel) []byte { return buildJSON(wsMsg{ - Type: MsgTypeChannelCreate, - Payload: channelPayload{ - ID: ch.ID, - Name: ch.Name, - Type: ch.Type, - Category: ch.Category, - Topic: ch.Topic, - Position: ch.Position, - SlowMode: ch.SlowMode, - }, + Type: MsgTypeChannelCreate, + Payload: channelPayloadFrom(ch), }) } // buildChannelUpdate constructs a channel_update broadcast. func buildChannelUpdate(ch *db.Channel) []byte { return buildJSON(wsMsg{ - Type: MsgTypeChannelUpdate, - Payload: channelPayload{ - ID: ch.ID, - Name: ch.Name, - Type: ch.Type, - Category: ch.Category, - Topic: ch.Topic, - Position: ch.Position, - SlowMode: ch.SlowMode, - }, + Type: MsgTypeChannelUpdate, + Payload: channelPayloadFrom(ch), }) } @@ -513,27 +721,57 @@ func buildChannelDelete(channelID int64) []byte { }) } -// buildDMChannelOpen constructs a dm_channel_open event sent to a user. -// Returns nil if recipient is nil to avoid a panic on dereferencing. -func buildDMChannelOpen(channelID int64, recipient *db.User) []byte { +// buildDMChannelOpen constructs a dm_channel_open event for one viewer. +// +// The payload is a db.DMChannelInfo, the same shape the REST list and the +// ready payload carry, so a client has exactly one DM shape to parse. It is +// built per viewer rather than once per channel because `recipient` and +// `recipients` are both defined relative to who is reading them. +func buildDMChannelOpen(info db.DMChannelInfo) []byte { + return buildJSON(wsMsg{ + Type: MsgTypeDMChannelOpen, + Payload: info, + }) +} + +// buildDMChannelOpenFor constructs a dm_channel_open event announcing a 1:1 DM +// to the user on the other end of it. Returns nil if recipient is nil to avoid +// a panic on dereferencing. +func buildDMChannelOpenFor(channelID int64, recipient *db.User, viewerID int64) []byte { if recipient == nil { - slog.Warn("buildDMChannelOpen called with nil recipient", "channel_id", channelID) + slog.Warn("buildDMChannelOpenFor called with nil recipient", "channel_id", channelID) return nil } avatarStr := "" if recipient.Avatar != nil { avatarStr = *recipient.Avatar } + displayName := "" + if recipient.DisplayName != nil { + displayName = *recipient.DisplayName + } + other := db.DMUser{ + ID: recipient.ID, + Username: recipient.Username, + Avatar: avatarStr, + Status: db.StatusForViewer(recipient.Status, recipient.ID, viewerID), + DisplayName: displayName, + } + return buildDMChannelOpen(db.DMChannelInfo{ + ChannelID: channelID, + Recipient: other, + Recipients: []db.DMUser{other}, + }) +} + +// buildCallSignal constructs a call_incoming or call_declined frame. +func buildCallSignal(msgType string, channelID, fromUserID int64, username string) []byte { return buildJSON(wsMsg{ - Type: MsgTypeDMChannelOpen, - Payload: dmChannelOpenPayload{ + Type: msgType, + Payload: callSignalPayload{ ChannelID: channelID, - Recipient: dmUserPayload{ - ID: recipient.ID, - Username: recipient.Username, - Avatar: avatarStr, - Status: recipient.Status, - }, + FromUser: fromUserID, + Username: username, }, }) } diff --git a/Server/ws/messages_test.go b/Server/ws/messages_test.go index 5b23e996..473a6ed0 100644 --- a/Server/ws/messages_test.go +++ b/Server/ws/messages_test.go @@ -2,6 +2,8 @@ package ws import ( "encoding/json" + "slices" + "strings" "testing" "github.com/owncord/server/db" @@ -287,6 +289,44 @@ func TestBuildMemberJoin_NonNilAvatar(t *testing.T) { } } +// An invisible connector's member_join must carry the collapsed "offline" +// status, not their true chosen status — member_join goes out via +// BroadcastToAll to every connected client, so it must never leak "invisible" +// the way the channel-scoped presence path already avoids. +func TestBuildMemberJoin_StatusField_InvisibleCollapsesToOffline(t *testing.T) { + user := &db.User{ID: 1, Username: "ghost", Status: db.StatusInvisible} + msg := buildMemberJoin(user, "member") + var env struct { + Payload struct { + Status string `json:"status"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.Status != db.StatusOffline { + t.Errorf("status = %q, want %q", env.Payload.Status, db.StatusOffline) + } +} + +// A non-invisible status (already ConnectStatus-mapped by the caller) passes +// through unchanged. +func TestBuildMemberJoin_StatusField_PassesThroughNonInvisible(t *testing.T) { + user := &db.User{ID: 1, Username: "idler", Status: db.StatusIdle} + msg := buildMemberJoin(user, "member") + var env struct { + Payload struct { + Status string `json:"status"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload.Status != db.StatusIdle { + t.Errorf("status = %q, want %q", env.Payload.Status, db.StatusIdle) + } +} + // ─── buildMemberUpdate ──────────────────────────────────────────────────────── func TestBuildMemberUpdate_Type(t *testing.T) { @@ -360,7 +400,7 @@ func TestBuildMemberBan_ValidJSON(t *testing.T) { // ─── buildChatEdited ────────────────────────────────────────────────────────── func TestBuildChatEdited_Type(t *testing.T) { - msg := buildChatEdited(10, 20, "new content", "2024-01-01T00:00:00Z") + msg := buildChatEdited(10, 20, "new content", "2024-01-01T00:00:00Z", []int64{7}, true) var env struct { Type string `json:"type"` } @@ -373,13 +413,15 @@ func TestBuildChatEdited_Type(t *testing.T) { } func TestBuildChatEdited_Payload(t *testing.T) { - msg := buildChatEdited(10, 20, "new content", "2024-01-01T00:00:00Z") + msg := buildChatEdited(10, 20, "new content", "2024-01-01T00:00:00Z", []int64{7}, true) var env struct { Payload struct { - MessageID int64 `json:"message_id"` - ChannelID int64 `json:"channel_id"` - Content string `json:"content"` - EditedAt string `json:"edited_at"` + MessageID int64 `json:"message_id"` + ChannelID int64 `json:"channel_id"` + Content string `json:"content"` + EditedAt string `json:"edited_at"` + Mentions []int64 `json:"mentions"` + MentionsEveryone bool `json:"mentions_everyone"` } `json:"payload"` } if err := json.Unmarshal(msg, &env); err != nil { @@ -398,6 +440,12 @@ func TestBuildChatEdited_Payload(t *testing.T) { if p.EditedAt != "2024-01-01T00:00:00Z" { t.Errorf("payload.edited_at = %q, want 2024-01-01T00:00:00Z", p.EditedAt) } + if len(p.Mentions) != 1 || p.Mentions[0] != 7 { + t.Errorf("payload.mentions = %v, want [7]", p.Mentions) + } + if !p.MentionsEveryone { + t.Error("payload.mentions_everyone = false, want true") + } } // ─── buildChatDeleted ───────────────────────────────────────────────────────── @@ -440,6 +488,42 @@ func TestBuildChatDeleted_ValidJSON(t *testing.T) { } } +// ─── buildChatBulkDeleted ───────────────────────────────────────────────────── + +func TestBuildChatBulkDeleted_TypeAndPayload(t *testing.T) { + msg := buildChatBulkDeleted(22, []int64{11, 10, 9}) + var env struct { + Type string `json:"type"` + Payload struct { + ChannelID int64 `json:"channel_id"` + IDs []int64 `json:"ids"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "chat_bulk_deleted" { + t.Errorf("type = %q, want chat_bulk_deleted", env.Type) + } + if env.Payload.ChannelID != 22 { + t.Errorf("payload.channel_id = %d, want 22", env.Payload.ChannelID) + } + if !slices.Equal(env.Payload.IDs, []int64{11, 10, 9}) { + t.Errorf("payload.ids = %v, want [11 10 9]", env.Payload.IDs) + } +} + +func TestBuildChatBulkDeleted_NilIDsEncodesAsEmptyArray(t *testing.T) { + msg := buildChatBulkDeleted(5, nil) + if !json.Valid(msg) { + t.Fatal("buildChatBulkDeleted output is not valid JSON") + } + // Clients iterate ids unconditionally, so null would be a crash. + if !strings.Contains(string(msg), `"ids":[]`) { + t.Errorf("nil ids encoded as %s, want an empty array", msg) + } +} + // ─── buildReactionUpdate ────────────────────────────────────────────────────── func TestBuildReactionUpdate_Type(t *testing.T) { @@ -699,7 +783,7 @@ func TestBuildMemberJoin_NoIdentityKey_Omitted(t *testing.T) { func TestBuildUserUpdate_IncludesIdentityKey(t *testing.T) { key := "dXBkYXRlZGtleQ==" - msg := buildUserUpdate(9, "rotator", nil, &key) + msg := buildUserUpdate(UserUpdate{UserID: 9, Username: "rotator", IdentityPublicKey: &key}) var env struct { Type string `json:"type"` Payload struct { @@ -721,3 +805,90 @@ func TestBuildUserUpdate_IncludesIdentityKey(t *testing.T) { t.Errorf("identity_public_key = %q, want %q", env.Payload.IdentityPublicKey, key) } } + +// ─── channel feature flags on the wire ─────────────────────────────────────── + +// nsfwSampleChannel is a voice channel carrying every field the phase-5 flags +// added, so a builder that drops one is caught by an explicit assertion rather +// than by a client behaving oddly. +func flaggedSampleChannel() *db.Channel { + return &db.Channel{ + ID: 7, + Name: "lounge", + Type: "voice", + Category: "Hangout", + Position: 1, + SlowMode: 30, + NSFW: true, + VoiceMaxUsers: 5, + VoiceMaxVideo: 2, + } +} + +// Both builders share one payload constructor, so both are asserted: the point +// is that channel_create and channel_update can never disagree about which +// fields a client is told about. +func TestBuildChannelMessages_CarryFeatureFlags(t *testing.T) { + ch := flaggedSampleChannel() + for name, msg := range map[string][]byte{ + "channel_create": buildChannelCreate(ch), + "channel_update": buildChannelUpdate(ch), + } { + t.Run(name, func(t *testing.T) { + 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.NSFW { + t.Error("payload.nsfw = false, want true") + } + if p.SlowMode != ch.SlowMode { + t.Errorf("payload.slow_mode = %d, want %d", p.SlowMode, ch.SlowMode) + } + if p.VoiceMaxUsers != ch.VoiceMaxUsers { + t.Errorf("payload.voice_max_users = %d, want %d", p.VoiceMaxUsers, ch.VoiceMaxUsers) + } + if p.VoiceMaxVideo != ch.VoiceMaxVideo { + t.Errorf("payload.voice_max_video = %d, want %d", p.VoiceMaxVideo, ch.VoiceMaxVideo) + } + }) + } +} + +// The JSON keys are the contract the client reads; a Go-side rename that kept +// the struct field would pass the assertions above and still break every +// client, so the wire names are pinned explicitly. +func TestBuildChannelUpdate_FeatureFlagWireNames(t *testing.T) { + var env struct { + Payload map[string]any `json:"payload"` + } + if err := json.Unmarshal(buildChannelUpdate(flaggedSampleChannel()), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, key := range []string{"nsfw", "slow_mode", "voice_max_users", "voice_max_video"} { + if _, ok := env.Payload[key]; !ok { + t.Errorf("payload is missing %q; got keys %v", key, env.Payload) + } + } +} + +// An unflagged channel must send the flags as their zero values rather than +// omitting them: a client that reads `nsfw` as undefined would fall back to +// its own default, and "absent" would then mean two different things. +func TestBuildChannelUpdate_UnflaggedChannelSendsZeroes(t *testing.T) { + var env struct { + Payload map[string]any `json:"payload"` + } + if err := json.Unmarshal(buildChannelUpdate(sampleChannel()), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Payload["nsfw"] != false { + t.Errorf("payload.nsfw = %v, want false", env.Payload["nsfw"]) + } + if env.Payload["voice_max_users"] != float64(0) { + t.Errorf("payload.voice_max_users = %v, want 0", env.Payload["voice_max_users"]) + } +} diff --git a/Server/ws/presence_invisible_test.go b/Server/ws/presence_invisible_test.go new file mode 100644 index 00000000..6523c913 --- /dev/null +++ b/Server/ws/presence_invisible_test.go @@ -0,0 +1,283 @@ +package ws_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/owncord/server/db" + "github.com/owncord/server/ws" +) + +// Phase 6 "real invisible". The property under test is a single sentence: +// an invisible user is offline to everyone but themselves, on every surface — +// the ready member list, the presence broadcast, and the connect-time +// announcement that used to stamp everyone online. + +// readPresence drains ch until a presence message arrives, returning its +// payload. Returns nil if none arrives before the deadline. +func readPresence(ch <-chan []byte, deadline time.Duration) map[string]any { + timer := time.NewTimer(deadline) + defer timer.Stop() + for { + select { + case raw := <-ch: + var env map[string]any + if json.Unmarshal(raw, &env) != nil { + continue + } + if env["type"] != "presence" { + continue + } + payload, _ := env["payload"].(map[string]any) + return payload + case <-timer.C: + return nil + } + } +} + +// readyMembers pulls the members array out of a ready payload, keyed by id. +func readyMembers(t *testing.T, raw []byte) map[int64]map[string]any { + t.Helper() + var env struct { + Payload struct { + Members []map[string]any `json:"members"` + } `json:"payload"` + } + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatalf("unmarshal ready: %v", err) + } + out := make(map[int64]map[string]any, len(env.Payload.Members)) + for _, m := range env.Payload.Members { + id, _ := m["id"].(float64) + out[int64(id)] = m + } + return out +} + +func TestReady_InvisibleMemberIsOfflineToOthersAndTrueToSelf(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + t.Cleanup(hub.Stop) + ctx := context.Background() + + ghost := seedOwnerUser(t, database, "ghost") + watcher := seedOwnerUser(t, database, "watcher") + if err := database.UpdateUserStatus(ctx, ghost.ID, db.StatusInvisible); err != nil { + t.Fatalf("UpdateUserStatus: %v", err) + } + if err := database.UpdateUserStatus(ctx, watcher.ID, db.StatusOnline); err != nil { + t.Fatalf("UpdateUserStatus: %v", err) + } + + // Both must be connected — a member with no live session renders offline + // regardless, which would mask the mapping this test is about. + gc := ws.NewTestClientWithUser(hub, ghost, 0, make(chan []byte, 8)) + wc := ws.NewTestClientWithUser(hub, watcher, 0, make(chan []byte, 8)) + hub.Register(gc) + hub.Register(wc) + waitRegistered(t, hub, gc) + waitRegistered(t, hub, wc) + + forWatcher, err := hub.BuildReadyForTest(database, watcher.ID) + if err != nil { + t.Fatalf("buildReady(watcher): %v", err) + } + if got := readyMembers(t, forWatcher)[ghost.ID]["status"]; got != db.StatusOffline { + t.Errorf("ghost as seen by watcher = %v, want offline", got) + } + + forGhost, err := hub.BuildReadyForTest(database, ghost.ID) + if err != nil { + t.Fatalf("buildReady(ghost): %v", err) + } + if got := readyMembers(t, forGhost)[ghost.ID]["status"]; got != db.StatusInvisible { + t.Errorf("ghost as seen by themselves = %v, want invisible", got) + } + // The watcher's own status is unaffected in either payload. + if got := readyMembers(t, forGhost)[watcher.ID]["status"]; got != db.StatusOnline { + t.Errorf("watcher in ghost's ready = %v, want online", got) + } +} + +func TestReady_DisconnectedMemberWithChosenStatusRendersOffline(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + t.Cleanup(hub.Stop) + ctx := context.Background() + + absent := seedOwnerUser(t, database, "absent") + viewer := seedOwnerUser(t, database, "viewer") + // A chosen dnd survives a disconnect in the column so the next connect can + // honour it — but it must not render as "present" in the meantime. + if err := database.UpdateUserStatus(ctx, absent.ID, db.StatusDND); err != nil { + t.Fatalf("UpdateUserStatus: %v", err) + } + + vc := ws.NewTestClientWithUser(hub, viewer, 0, make(chan []byte, 8)) + hub.Register(vc) + waitRegistered(t, hub, vc) + + raw, err := hub.BuildReadyForTest(database, viewer.ID) + if err != nil { + t.Fatalf("buildReady: %v", err) + } + if got := readyMembers(t, raw)[absent.ID]["status"]; got != db.StatusOffline { + t.Errorf("disconnected dnd member = %v, want offline", got) + } +} + +func TestBroadcastPresence_InvisibleSplitsSelfFromEveryoneElse(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + t.Cleanup(hub.Stop) + + ghost := seedOwnerUser(t, database, "bc-ghost") + other := seedOwnerUser(t, database, "bc-other") + ghostCh := make(chan []byte, 8) + otherCh := make(chan []byte, 8) + gc := ws.NewTestClientWithUser(hub, ghost, 0, ghostCh) + oc := ws.NewTestClientWithUser(hub, other, 0, otherCh) + hub.Register(gc) + hub.Register(oc) + waitRegistered(t, hub, gc) + waitRegistered(t, hub, oc) + + text := "heads down" + hub.BroadcastPresence(ghost.ID, db.StatusInvisible, &text) + + self := readPresence(ghostCh, 500*time.Millisecond) + if self == nil { + t.Fatal("owner received no presence message") + } + if self["status"] != db.StatusInvisible { + t.Errorf("owner sees status = %v, want invisible", self["status"]) + } + if self["custom_status"] != text { + t.Errorf("owner custom_status = %v, want %q", self["custom_status"], text) + } + + seen := readPresence(otherCh, 500*time.Millisecond) + if seen == nil { + t.Fatal("other client received no presence message") + } + if seen["status"] != db.StatusOffline { + t.Errorf("other sees status = %v, want offline", seen["status"]) + } +} + +func TestBroadcastPresence_NonInvisibleGoesToEveryoneUnchanged(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + t.Cleanup(hub.Stop) + + user := seedOwnerUser(t, database, "bc-dnd") + ch := make(chan []byte, 8) + c := ws.NewTestClientWithUser(hub, user, 0, ch) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.BroadcastPresence(user.ID, db.StatusDND, nil) + + got := readPresence(ch, 500*time.Millisecond) + if got == nil { + t.Fatal("no presence message") + } + if got["status"] != db.StatusDND { + t.Errorf("status = %v, want dnd", got["status"]) + } + if got["custom_status"] != nil { + t.Errorf("custom_status = %v, want null", got["custom_status"]) + } +} + +func TestAuthOK_CarriesOwnTrueStatusAndProfileFields(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + t.Cleanup(hub.Stop) + ctx := context.Background() + + user := seedOwnerUser(t, database, "authok-ghost") + name, about, custom := "Ghosty", "boo", "lurking" + if err := database.UpdateUserProfile(ctx, user.ID, user.Username, nil, &name, &about); err != nil { + t.Fatalf("UpdateUserProfile: %v", err) + } + if err := database.UpdateUserCustomStatus(ctx, user.ID, &custom); err != nil { + t.Fatalf("UpdateUserCustomStatus: %v", err) + } + if err := database.UpdateUserStatus(ctx, user.ID, db.StatusInvisible); err != nil { + t.Fatalf("UpdateUserStatus: %v", err) + } + fresh, err := database.GetUserByID(ctx, user.ID) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + + var env struct { + Payload struct { + User map[string]any `json:"user"` + } `json:"payload"` + } + if err := json.Unmarshal(hub.BuildAuthOKForTest(fresh, "owner"), &env); err != nil { + t.Fatalf("unmarshal auth_ok: %v", err) + } + u := env.Payload.User + if u["status"] != db.StatusInvisible { + t.Errorf("auth_ok status = %v, want the owner's true invisible", u["status"]) + } + if u["display_name"] != name { + t.Errorf("auth_ok display_name = %v, want %q", u["display_name"], name) + } + if u["about"] != about { + t.Errorf("auth_ok about = %v, want %q", u["about"], about) + } + if u["custom_status"] != custom { + t.Errorf("auth_ok custom_status = %v, want %q", u["custom_status"], custom) + } +} + +func TestPresenceUpdate_InvisibleIsAcceptedAndCarriesCustomStatus(t *testing.T) { + // The coverage hub carries a real service layer, which the presence + // handler needs — newTestHub's hub has none. + hub, database := newCoverageHub(t) + + ghost := seedCoverageOwner(t, database, "cmd-ghost") + other := seedCoverageOwner(t, database, "cmd-other") + ghostCh := make(chan []byte, 16) + otherCh := make(chan []byte, 16) + gc := ws.NewTestClientWithUser(hub, ghost, 0, ghostCh) + oc := ws.NewTestClientWithUser(hub, other, 0, otherCh) + hub.Register(gc) + hub.Register(oc) + waitRegistered(t, hub, gc) + waitRegistered(t, hub, oc) + + raw, _ := json.Marshal(map[string]any{ + "type": "presence_update", + "payload": map[string]any{ + "status": db.StatusInvisible, + "custom_status": "shhh", + }, + }) + hub.HandleMessageForTest(gc, raw) + + if got := readPresence(otherCh, 500*time.Millisecond); got == nil || got["status"] != db.StatusOffline { + t.Errorf("other sees %v, want offline", got) + } + if got := readPresence(ghostCh, 500*time.Millisecond); got == nil || got["status"] != db.StatusInvisible { + t.Errorf("owner sees %v, want invisible", got) + } + + stored, err := database.GetUserByID(context.Background(), ghost.ID) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if stored.Status != db.StatusInvisible { + t.Errorf("stored status = %q, want invisible (uncollapsed)", stored.Status) + } + if stored.CustomStatus == nil || *stored.CustomStatus != "shhh" { + t.Errorf("stored custom_status = %v, want %q", stored.CustomStatus, "shhh") + } +} diff --git a/Server/ws/protocol_contract_test.go b/Server/ws/protocol_contract_test.go new file mode 100644 index 00000000..2dc99239 --- /dev/null +++ b/Server/ws/protocol_contract_test.go @@ -0,0 +1,228 @@ +package ws_test + +// protocol_contract_test.go — locks docs/protocol-schema.json and the +// generated ws/message_types.go together so the two cannot silently drift. +// +// message_types.go is documented as "Code generated by scripts/genprotocol +// from docs/protocol-schema.json; DO NOT EDIT" and CI runs +// `make protocol-verify`, but that only re-runs the generator and diffs its +// output — it says nothing about message-type constants that exist in the ws +// package outside the generated file (e.g. a handler defining its own +// MsgTypeFoo instead of adding it to the schema). This test instead parses +// both the schema and every non-test .go file in this package for `MsgType*` +// string constants and asserts they agree in both directions: +// +// - every wire value the schema lists has a matching Go constant with the +// schema's stated name and value ("schema -> code"), and +// - every MsgType* constant declared anywhere in the ws package appears in +// the schema ("code -> schema"), with ONE documented exception. +// +// The exception: MsgTypeChatCommand ("chat_command", ws/handlers_command.go) +// is a client->server wire message with no schema entry. It predates the +// schema/genprotocol pipeline and was deliberately left out — plugin slash +// commands are dispatched through the plugin registry rather than the fixed +// handler table the generated constants serve, so it was judged internal +// wiring rather than part of the documented protocol surface. If a second +// undocumented constant ever appears, this test fails and names it — that is +// the drift this test exists to catch, not something to silently allowlist. + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" +) + +var knownUndocumentedConstants = map[string]string{ + "MsgTypeChatCommand": "chat_command", +} + +// protocolSchemaEntry mirrors one element of the client_to_server / +// server_to_client arrays in docs/protocol-schema.json. +type protocolSchemaEntry struct { + Wire string `json:"wire"` + Go string `json:"go"` + TS string `json:"ts"` +} + +type protocolSchema struct { + Version int `json:"version"` + ClientToServer []protocolSchemaEntry `json:"client_to_server"` + ServerToClient []protocolSchemaEntry `json:"server_to_client"` +} + +// loadProtocolSchema locates and parses docs/protocol-schema.json relative to +// this test file (ws/ -> Server/ -> repo root -> docs/), so the test does not +// depend on the working directory `go test` happens to be invoked from. +func loadProtocolSchema(t *testing.T) protocolSchema { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed to resolve test file path") + } + wsDir := filepath.Dir(thisFile) + schemaPath := filepath.Join(wsDir, "..", "..", "docs", "protocol-schema.json") + + raw, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("reading protocol schema at %s: %v", schemaPath, err) + } + + var schema protocolSchema + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("parsing protocol schema: %v", err) + } + return schema +} + +// loadGoMsgTypeConstants statically parses every non-test .go file in the ws +// package directory and returns a map of MsgType* constant name -> its wire +// string value, for top-level `const ( Name = "value" )` declarations. +func loadGoMsgTypeConstants(t *testing.T) map[string]string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed to resolve test file path") + } + wsDir := filepath.Dir(thisFile) + + entries, err := os.ReadDir(wsDir) + if err != nil { + t.Fatalf("reading ws package directory %s: %v", wsDir, err) + } + + out := make(map[string]string) + fset := token.NewFileSet() + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + + file, err := parser.ParseFile(fset, filepath.Join(wsDir, name), nil, 0) + if err != nil { + t.Fatalf("parsing %s: %v", name, err) + } + + for _, decl := range file.Decls { + gen, isGen := decl.(*ast.GenDecl) + if !isGen || gen.Tok != token.CONST { + continue + } + for _, spec := range gen.Specs { + vspec, isVal := spec.(*ast.ValueSpec) + if !isVal { + continue + } + for i, ident := range vspec.Names { + if !strings.HasPrefix(ident.Name, "MsgType") { + continue + } + if i >= len(vspec.Values) { + continue // no explicit value on this line (e.g. iota-style) + } + lit, isLit := vspec.Values[i].(*ast.BasicLit) + if !isLit || lit.Kind != token.STRING { + continue + } + val, err := strconv.Unquote(lit.Value) + if err != nil { + t.Fatalf("unquoting %s = %s in %s: %v", ident.Name, lit.Value, name, err) + } + if existing, dup := out[ident.Name]; dup && existing != val { + t.Fatalf("constant %s declared twice with different values (%q vs %q) across ws package files", + ident.Name, existing, val) + } + out[ident.Name] = val + } + } + } + } + return out +} + +// TestProtocolSchema_MatchesGeneratedGoConstants is the "schema -> code" +// direction: every wire constant docs/protocol-schema.json lists (in either +// direction of traffic) must have a same-named Go constant in the ws package +// carrying exactly the schema's wire string. +func TestProtocolSchema_MatchesGeneratedGoConstants(t *testing.T) { + schema := loadProtocolSchema(t) + goConsts := loadGoMsgTypeConstants(t) + + check := func(direction string, entries []protocolSchemaEntry) { + for _, e := range entries { + got, ok := goConsts[e.Go] + if !ok { + t.Errorf("%s: schema entry wire=%q go=%q has no matching Go constant in ws package", + direction, e.Wire, e.Go) + continue + } + if got != e.Wire { + t.Errorf("%s: ws.%s = %q, want %q per protocol-schema.json", direction, e.Go, got, e.Wire) + } + } + } + + if len(schema.ClientToServer) == 0 { + t.Fatal("protocol-schema.json client_to_server is empty — schema failed to load") + } + if len(schema.ServerToClient) == 0 { + t.Fatal("protocol-schema.json server_to_client is empty — schema failed to load") + } + + check("client_to_server", schema.ClientToServer) + check("server_to_client", schema.ServerToClient) +} + +// TestProtocolSchema_NoUndocumentedGoConstants is the "code -> schema" +// direction: every MsgType* constant declared in the ws package must appear +// in docs/protocol-schema.json, except the documented exceptions in +// knownUndocumentedConstants (see its doc comment). This is what catches a +// handler minting its own wire constant instead of adding it to the schema. +func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) { + schema := loadProtocolSchema(t) + goConsts := loadGoMsgTypeConstants(t) + + documented := make(map[string]string, len(schema.ClientToServer)+len(schema.ServerToClient)) + for _, e := range schema.ClientToServer { + documented[e.Go] = e.Wire + } + for _, e := range schema.ServerToClient { + documented[e.Go] = e.Wire + } + + for name, wire := range goConsts { + if _, inSchema := documented[name]; inSchema { + continue + } + exceptWire, isException := knownUndocumentedConstants[name] + if !isException { + t.Errorf("ws.%s = %q is not in docs/protocol-schema.json and is not a documented exception "+ + "(knownUndocumentedConstants) — add it to the schema or the exception list", name, wire) + continue + } + if exceptWire != wire { + t.Errorf("documented exception ws.%s has wire value %q, but knownUndocumentedConstants says %q — update the exception list", + name, wire, exceptWire) + } + } + + // Guard against the exception list growing silently: it must contain + // exactly the constants we can currently account for as intentionally + // undocumented, and nothing that has since been added to the schema. + for name := range knownUndocumentedConstants { + if _, stillMissing := goConsts[name]; !stillMissing { + t.Errorf("knownUndocumentedConstants lists %q but no such Go constant exists anymore — remove it from the exception list", name) + continue + } + if _, nowDocumented := documented[name]; nowDocumented { + t.Errorf("knownUndocumentedConstants lists %q but it is now in protocol-schema.json — remove it from the exception list", name) + } + } +} diff --git a/Server/ws/registry_test.go b/Server/ws/registry_test.go index c7cd55ce..4f1ec088 100644 --- a/Server/ws/registry_test.go +++ b/Server/ws/registry_test.go @@ -18,6 +18,7 @@ func fullV2Registry() *HandlerRegistry { registerReactionHandlers(r, ReactionDeps{}) r.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{}) registerVoiceControlsV2(r, VoiceDeps{}) + registerCallHandlers(r, CallDeps{}) return r } @@ -111,6 +112,7 @@ func TestHandlerRegistry_AllExpectedTypesRegistered(t *testing.T) { "typing_start", "presence_update", "channel_focus", + "mark_read", "reaction_add", "reaction_remove", "chat_send", @@ -123,9 +125,15 @@ func TestHandlerRegistry_AllExpectedTypesRegistered(t *testing.T) { "voice_deafen", "voice_camera", "voice_screenshare", + "voice_mod_mute", + "voice_mod_deafen", + "voice_mod_move", + "voice_mod_kick", "voice_e2ee_announce", "voice_e2ee_offer", "voice_token_refresh", + "call_ring", + "call_decline", } registered := r.RegisteredV2Types() @@ -179,6 +187,7 @@ func TestAllV2Types_SmokeDispatch(t *testing.T) { MsgTypeTypingStart: TypingStartCmd{userID: 1, channelID: 1}, MsgTypePresenceUpdate: PresenceUpdateCmd{userID: 1, status: "online"}, MsgTypeChannelFocus: ChannelFocusCmd{userID: 1, channelID: 1}, + MsgTypeMarkRead: MarkReadCmd{userID: 1, channelID: 1}, MsgTypeReactionAdd: ReactionAddCmd{userID: 1, messageID: 1, emoji: "👍"}, MsgTypeReactionRemove: ReactionRemoveCmd{userID: 1, messageID: 1, emoji: "👍"}, MsgTypeVoiceJoin: VoiceJoinCmd{userID: 1, channelID: 1}, @@ -187,9 +196,15 @@ func TestAllV2Types_SmokeDispatch(t *testing.T) { MsgTypeVoiceDeafen: VoiceDeafenCmd{userID: 1}, MsgTypeVoiceCamera: VoiceCameraCmd{userID: 1}, MsgTypeVoiceScreenshare: VoiceScreenshareCmd{userID: 1}, + MsgTypeVoiceModMute: VoiceModMuteCmd{userID: 1, channelID: 1, targetID: 2}, + MsgTypeVoiceModDeafen: VoiceModDeafenCmd{userID: 1, channelID: 1, targetID: 2}, + MsgTypeVoiceModMove: VoiceModMoveCmd{userID: 1, targetID: 2, toChannelID: 2}, + MsgTypeVoiceModKick: VoiceModKickCmd{userID: 1, targetID: 2}, MsgTypeVoiceE2EEAnnounce: VoiceE2EEAnnounceCmd{userID: 1}, MsgTypeVoiceE2EEOffer: VoiceE2EEOfferCmd{userID: 1}, MsgTypeVoiceTokenRefresh: VoiceTokenRefreshCmd{userID: 1}, + MsgTypeCallRing: CallRingCmd{userID: 1, channelID: 1}, + MsgTypeCallDecline: CallDeclineCmd{userID: 1, channelID: 1}, } for _, typ := range r.RegisteredV2Types() { diff --git a/Server/ws/serve.go b/Server/ws/serve.go index bcf17620..3dea17ef 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -194,14 +194,39 @@ func (h *Hub) handleReconnect( slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq, "source", replaySource) // Update presence but skip member_join — user was already known. - if updateErr := database.UpdateUserStatus(ctx, c.userID, "online"); updateErr != nil { - slog.Warn("ws UpdateUserStatus", "err", updateErr) - } - h.BroadcastToAll(buildPresenceMsg(c.userID, "online")) + applyConnectStatus(ctx, database, c) + h.announceConnectPresence(c) return true } +// applyConnectStatus writes the status this session comes online as and caches +// it on the client. +// +// It is db.ConnectStatus(saved) rather than a flat "online": stamping online on +// every connect is what made a saved Do Not Disturb — and, before this phase, +// an "appear offline" — flash back to online on every reconnect, with the +// client racing to re-assert its choice afterwards. idle/dnd/invisible are +// deliberate choices and survive; anything else becomes online. The write still +// happens when the status is unchanged, because UpdateUserStatus also refreshes +// last_seen. +// +// It runs BEFORE the ready payload is built so the member list the client is +// handed already agrees with the presence broadcast that follows it. +func applyConnectStatus(ctx context.Context, database *db.DB, c *Client) { + status := db.ConnectStatus(c.user.Status) + if updateErr := database.UpdateUserStatus(ctx, c.userID, status); updateErr != nil { + slog.Warn("ws UpdateUserStatus", "err", updateErr) + } + c.user.Status = status +} + +// announceConnectPresence fans out the status applyConnectStatus settled on, +// with the invisible mapping applied. +func (h *Hub) announceConnectPresence(c *Client) { + h.BroadcastPresence(c.userID, c.user.Status, c.user.CustomStatus) +} + // computeAllowedChannels returns the set of channel IDs a user may access, // including both server channels (filtered by ReadMessages permission) and // the user's open DM channels. The server-channel set comes from the single @@ -224,9 +249,9 @@ func (h *Hub) computeAllowedChannels(ctx context.Context, database *db.DB, user if role != nil { var overrides map[int64]db.ChannelOverride if !permissions.HasAdmin(role.Permissions) { - overrides, err = database.GetAllChannelPermissionsForRole(ctx, role.ID) + overrides, err = database.GetChannelOverridesFor(ctx, role.ID, user.ID) if err != nil { - return nil, fmt.Errorf("computeAllowedChannels GetAllChannelPermissionsForRole: %w", err) + return nil, fmt.Errorf("computeAllowedChannels GetChannelOverridesFor: %w", err) } } allowed = h.permChecker.VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides)) @@ -315,6 +340,10 @@ func (h *Hub) handleFreshConnect( } h.registerNow(c, allowedChannelIDs) + // Settle the session's status before buildReady reads the member list, so + // the ready payload and the presence broadcast below cannot disagree. + applyConnectStatus(ctx, database, c) + // Fresh connection or replay fallback: full auth_ok + ready flow. slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName) if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, "none")); err != nil { @@ -340,13 +369,9 @@ func (h *Hub) handleFreshConnect( return readyErr } - if updateErr := database.UpdateUserStatus(ctx, c.userID, "online"); updateErr != nil { - slog.Warn("ws UpdateUserStatus", "err", updateErr) - } - slog.Info("ws broadcasting member_join and presence", "user_id", c.userID, "username", c.user.Username) h.BroadcastToAll(buildMemberJoin(c.user, c.roleName)) - h.BroadcastToAll(buildPresenceMsg(c.userID, "online")) + h.announceConnectPresence(c) return nil } diff --git a/Server/ws/serve_pumps.go b/Server/ws/serve_pumps.go index 892373cf..c006f37f 100644 --- a/Server/ws/serve_pumps.go +++ b/Server/ws/serve_pumps.go @@ -6,6 +6,8 @@ import ( "time" "github.com/coder/websocket" + + "github.com/owncord/server/db" ) // writePump drains the client's send channels and writes to the WebSocket. @@ -122,8 +124,17 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { slog.Info("websocket disconnected", attrs...) if !replaced { - _ = hub.db.UpdateUserStatus(cleanupCtx, c.userID, "offline") - hub.BroadcastToAll(buildPresenceMsg(c.userID, "offline")) + // A real disconnect is offline for everyone, the user + // included, so this path needs no invisible mapping. The row, + // however, keeps a *chosen* status (idle/dnd/invisible) + // standing — that is what the next connect reads to avoid + // stamping the user back online. MarkUserDisconnected clears + // only the non-choice "online" and refreshes last_seen; the + // stale-choice problem it would otherwise create is handled at + // read time, where a member with no live connection renders + // offline no matter what the column says. + _ = hub.db.MarkUserDisconnected(cleanupCtx, c.userID) + hub.BroadcastToAll(buildPresenceMsg(c.userID, db.StatusOffline, c.user.CustomStatus)) } } }() diff --git a/Server/ws/serve_ready.go b/Server/ws/serve_ready.go index 38d53b42..a320dbf4 100644 --- a/Server/ws/serve_ready.go +++ b/Server/ws/serve_ready.go @@ -32,6 +32,17 @@ func (h *Hub) buildAuthOK(ctx context.Context, user *db.User, roleName string, r "username": user.Username, "avatar": avatarVal, "role": roleName, + // The signed-in user's own profile fields. Null when unset; + // display_name falls back to username client-side, and about + // is what the "edit my profile" form pre-fills from. + "display_name": user.DisplayName, + "about": user.About, + "custom_status": user.CustomStatus, + // The user's own true status — invisible included. Only their + // own auth_ok ever carries it, which is the whole point: the + // picker has to render what they chose, while every other + // client is told offline. + "status": user.Status, }, "server_name": serverName, "motd": motd, @@ -40,21 +51,63 @@ func (h *Hub) buildAuthOK(ctx context.Context, user *db.User, roleName string, r }) } +// presentableMembers rewrites each member's status into what viewerID may see. +// +// Two rules, both applied here so no payload builder can implement only one: +// +// 1. A member with no live connection is offline, whatever the row says. +// users.status keeps a *chosen* idle/dnd/invisible across a disconnect so +// the next connect can honour it, which would otherwise leave a signed-out +// user showing as "Do Not Disturb" indefinitely. +// 2. An invisible member is offline to everyone but themselves +// (db.StatusForViewer). The owner keeps their true state so their own +// picker renders the status they actually chose. +func (h *Hub) presentableMembers(members []db.MemberSummary, viewerID int64) []db.MemberSummary { + connected := h.connectedUserIDs() + out := make([]db.MemberSummary, 0, len(members)) + for _, m := range members { + if !connected[m.ID] { + m.Status = db.StatusOffline + m.CustomStatus = nil + } + out = append(out, m.ForViewer(viewerID)) + } + return out +} + +// connectedUserIDs snapshots the ids with a live WebSocket connection. +func (h *Hub) connectedUserIDs() map[int64]bool { + h.mu.RLock() + defer h.mu.RUnlock() + set := make(map[int64]bool, len(h.clients)) + for uid := range h.clients { + set[uid] = true + } + return set +} + // channelRefs maps db channels to the checker's db-agnostic ChannelRef so // buildReady and computeAllowedChannels can share permissions.VisibleChannelIDs. func channelRefs(channels []db.Channel) []permissions.ChannelRef { refs := make([]permissions.ChannelRef, len(channels)) for i := range channels { - refs[i] = permissions.ChannelRef{ID: channels[i].ID, Type: channels[i].Type} + refs[i] = permissions.ChannelRef{ID: channels[i].ID, Type: channels[i].Type, Archived: channels[i].Archived} } return refs } -// permOverrides maps a db override map to the checker's override map. +// permOverrides maps a db override map to the checker's override map, carrying +// BOTH layers — the role override and the per-user override — so the checker +// resolves the full order (base -> role -> user) rather than half of it. func permOverrides(overrides map[int64]db.ChannelOverride) map[int64]permissions.ChannelOverride { out := make(map[int64]permissions.ChannelOverride, len(overrides)) for id, o := range overrides { - out[id] = permissions.ChannelOverride{Allow: o.Allow, Deny: o.Deny} + out[id] = permissions.ChannelOverride{ + Allow: o.Allow, + Deny: o.Deny, + UserAllow: o.UserAllow, + UserDeny: o.UserDeny, + } } return out } @@ -70,7 +123,9 @@ func channelCanSend(role *db.Role, o db.ChannelOverride, chanType string) bool { if permissions.HasAdmin(role.Permissions) { return true } - eff := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny) + eff := permissions.EffectiveChannelPerms(role.Permissions, permissions.ChannelOverride{ + Allow: o.Allow, Deny: o.Deny, UserAllow: o.UserAllow, UserDeny: o.UserDeny, + }) need := permissions.ReadMessages | permissions.SendMessages if eff&need != need { return false @@ -99,6 +154,7 @@ func (h *Hub) buildReady(ctx context.Context, database *db.DB, userID int64, rol slog.Warn("buildReady ListMembers", "err", err) members = []db.MemberSummary{} } + members = h.presentableMembers(members, userID) // Filter channels by READ_MESSAGES through the single permissions.Checker // predicate shared with REST ListVisibleChannels and reconnect replay @@ -108,9 +164,9 @@ func (h *Hub) buildReady(ctx context.Context, database *db.DB, userID int64, rol overrides := map[int64]db.ChannelOverride{} if role != nil && !permissions.HasAdmin(role.Permissions) { var oErr error - overrides, oErr = database.GetAllChannelPermissionsForRole(ctx, role.ID) + overrides, oErr = database.GetChannelOverridesFor(ctx, role.ID, userID) if oErr != nil { - return nil, fmt.Errorf("buildReady GetAllChannelPermissionsForRole: %w", oErr) + return nil, fmt.Errorf("buildReady GetChannelOverridesFor: %w", oErr) } } var visibleChannels []db.Channel @@ -142,6 +198,7 @@ func (h *Hub) buildReady(ctx context.Context, database *db.DB, userID int64, rol "name": visibleChannels[i].Name, "type": visibleChannels[i].Type, "category": visibleChannels[i].Category, + "topic": visibleChannels[i].Topic, "position": visibleChannels[i].Position, // can_send drives the client's composer affordance. It mirrors // MessageService.checkSendPermission for non-DM channels: base role @@ -153,14 +210,24 @@ func (h *Hub) buildReady(ctx context.Context, database *db.DB, userID int64, rol // for the window instead of accepting a send the server refuses // with SLOW_MODE. The server still enforces. "slow_mode": visibleChannels[i].SlowMode, + // Age-gate flag. Shipped so a client can label or gate the + // channel; the server applies no content behaviour of its own to + // a flagged channel (migration 025). + "nsfw": visibleChannels[i].NSFW, + // Voice capacity limits (0 = unlimited) — the same values the + // voice-join path enforces with CHANNEL_FULL / VIDEO_LIMIT. + "voice_max_users": visibleChannels[i].VoiceMaxUsers, + "voice_max_video": visibleChannels[i].VoiceMaxVideo, } if visibleChannels[i].Type == "text" || visibleChannels[i].Type == "announcement" { if u, ok := unreadMap[visibleChannels[i].ID]; ok { entry["unread_count"] = u.UnreadCount entry["last_message_id"] = u.LastMessageID + entry["mention_count"] = u.MentionCount } else { entry["unread_count"] = 0 entry["last_message_id"] = 0 + entry["mention_count"] = 0 } } channelPayloads = append(channelPayloads, entry) @@ -190,6 +257,14 @@ func (h *Hub) buildReady(ctx context.Context, database *db.DB, userID int64, rol slog.Warn("buildReady GetUserDMChannels", "err", err) dmChannels = []db.DMChannelInfo{} } + // GetUserDMChannels computes unread from read_states but carries no mention + // count, so a DM mention badge used to vanish on every reconnect. The + // unread map now includes the user's DM rows — pull mention_count from it. + for i := range dmChannels { + if u, ok := unreadMap[dmChannels[i].ChannelID]; ok { + dmChannels[i].MentionCount = u.MentionCount + } + } serverName, motd := h.getCachedSettings(ctx) diff --git a/Server/ws/serve_test.go b/Server/ws/serve_test.go index 2500f2f8..ebd943a4 100644 --- a/Server/ws/serve_test.go +++ b/Server/ws/serve_test.go @@ -25,6 +25,8 @@ CREATE TABLE IF NOT EXISTS voice_states ( speaking INTEGER NOT NULL DEFAULT 0, camera INTEGER NOT NULL DEFAULT 0, screenshare INTEGER NOT NULL DEFAULT 0, + server_muted INTEGER NOT NULL DEFAULT 0, + server_deafened 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); diff --git a/Server/ws/voice_broadcast.go b/Server/ws/voice_broadcast.go index 3bfe4761..31088bef 100644 --- a/Server/ws/voice_broadcast.go +++ b/Server/ws/voice_broadcast.go @@ -14,6 +14,10 @@ const ( voiceCameraWindow = time.Second voiceScreenshareRateLimit = 2 voiceScreenshareWindow = time.Second + // Shared by the four voice moderation commands. Each fans a voice_state or + // voice_leave broadcast out to everyone who can see the channel. + voiceModRateLimit = 5 + voiceModWindow = time.Second ) // voiceQualities maps accepted voice quality presets to their target bitrate diff --git a/Server/ws/voice_controls.go b/Server/ws/voice_controls.go index 39436901..c83ac278 100644 --- a/Server/ws/voice_controls.go +++ b/Server/ws/voice_controls.go @@ -24,6 +24,14 @@ func handleVoiceMuteV2(ctx context.Context, cmd Command, info ClientInfo, deps a return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } + // A moderator-imposed mute is not the user's to lift. Only the unmute + // direction reads the row: muting oneself is always allowed. + if !muteCmd.Muted() { + if r := refuseIfServerSilenced(ctx, d, userID, false); r != nil { + return *r + } + } + if err := d.DB.UpdateVoiceMute(ctx, userID, muteCmd.Muted()); err != nil { slog.Error("ws handleVoiceMuteV2 UpdateVoiceMute", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update mute state"}} @@ -48,6 +56,13 @@ func handleVoiceDeafenV2(ctx context.Context, cmd Command, info ClientInfo, deps return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } + // See handleVoiceMuteV2: server deafen is the moderator's to lift. + if !deafenCmd.Deafened() { + if r := refuseIfServerSilenced(ctx, d, userID, true); r != nil { + return *r + } + } + if err := d.DB.UpdateVoiceDeafen(ctx, userID, deafenCmd.Deafened()); err != nil { slog.Error("ws handleVoiceDeafenV2 UpdateVoiceDeafen", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update deafen state"}} @@ -142,6 +157,34 @@ func handleVoiceScreenshareV2(ctx context.Context, cmd Command, info ClientInfo, return voiceStateBroadcast(ctx, d, userID) } +// refuseIfServerSilenced refuses a self-unmute (deafen=false) or self-undeafen +// (deafen=true) while the corresponding moderator-imposed flag is set. A read +// error is not a denial: it is reported as INTERNAL so an operator sees it +// rather than the user seeing a permission-shaped refusal. +func refuseIfServerSilenced(ctx context.Context, d VoiceDeps, userID int64, deafen bool) *Result { + state, err := d.DB.GetVoiceState(ctx, userID) + if err != nil { + slog.Error("ws refuseIfServerSilenced GetVoiceState", "err", err, "user_id", userID) + return &Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to read voice state"}} + } + if state == nil { + return nil + } + if deafen && state.ServerDeafened { + return &Result{Error: ClientError{ + Code: ErrCodeServerDeafened, + Message: "you were deafened by a moderator", + }} + } + if !deafen && state.ServerMuted { + return &Result{Error: ClientError{ + Code: ErrCodeServerMuted, + Message: "you were muted by a moderator", + }} + } + return nil +} + // voiceStateBroadcast reads the current voice state from DB and returns a // BroadcastAll event. Shared by all voice control V2 handlers. func voiceStateBroadcast(ctx context.Context, d VoiceDeps, userID int64) Result { diff --git a/Server/ws/voice_dm_access_test.go b/Server/ws/voice_dm_access_test.go index 5cac6357..58d7dbab 100644 --- a/Server/ws/voice_dm_access_test.go +++ b/Server/ws/voice_dm_access_test.go @@ -140,3 +140,96 @@ func TestVoiceTokenRefresh_DMParticipant_StillRefreshes(t *testing.T) { t.Error("a DM participant must still be able to refresh their voice token") } } + +// Group DMs need no separate voice authorization path: dm_participants holds +// one row per participant and the gate is a lookup on (user_id, channel_id). +// These two pin that the existing path genuinely covers the N-participant case +// — a third member gets in, and an outsider still does not. +func TestVoiceJoin_GroupDMParticipant_Joins(t *testing.T) { + hub, database := newVoiceHub(t) + alice := seedMemberUser(t, database, "grpvoice-alice") + bob := seedMemberUser(t, database, "grpvoice-bob") + carol := seedMemberUser(t, database, "grpvoice-carol") + chID := seedGroupDM(t, database, "Callers", alice.ID, bob.ID, carol.ID) + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, carol, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceJoinMsg(chID)) + + if !hasVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond)) { + t.Error("the third member of a group DM must receive a voice token") + } + + state, err := database.GetVoiceState(context.Background(), carol.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || state.ChannelID != chID { + t.Fatalf("group participant voice state = %+v, want channel %d", state, chID) + } +} + +// channelReadAudience used to resolve a DM's audience via the role scan (DMs +// carry no channel_overrides), so any connected user whose base role held +// READ_MESSAGES received the DM call's voice_state/voice_leave events — +// leaking who is in a private call and their mute/camera state to the whole +// server. The audience must be the DM's participants, not a role-wide scan. +func TestVoiceJoin_DMCall_VoiceStateNotLeakedToThirdConnectedUser(t *testing.T) { + hub, database := newVoiceHub(t) + alice := seedMemberUser(t, database, "dmleak-alice") + bob := seedMemberUser(t, database, "dmleak-bob") + mallory := seedMemberUser(t, database, "dmleak-mallory") // connected, has READ_MESSAGES, NOT a participant + dmID := seedDMChannel(t, database, alice.ID, bob.ID) + + aliceSend := make(chan []byte, 32) + bobSend := make(chan []byte, 32) + mallorySend := make(chan []byte, 32) + aliceClient := ws.NewTestClientWithUser(hub, alice, 0, aliceSend) + bobClient := ws.NewTestClientWithUser(hub, bob, 0, bobSend) + malloryClient := ws.NewTestClientWithUser(hub, mallory, 0, mallorySend) + hub.Register(aliceClient) + hub.Register(bobClient) + hub.Register(malloryClient) + waitRegistered(t, hub, malloryClient) + + hub.HandleMessageForTest(aliceClient, voiceJoinMsg(dmID)) + + bobMsgs := drainChanTimeout(bobSend, 300*time.Millisecond) + foundVoiceState := false + for _, m := range bobMsgs { + if extractType(t, m) == "voice_state" { + foundVoiceState = true + } + } + if !foundVoiceState { + t.Error("a DM participant must still receive voice_state for their own DM call") + } + + malloryMsgs := drainChanTimeout(mallorySend, 300*time.Millisecond) + for _, m := range malloryMsgs { + if extractType(t, m) == "voice_state" { + t.Fatal("voice_state for a DM call leaked to a connected non-participant") + } + } +} + +func TestVoiceJoin_GroupDMNonParticipant_Refused(t *testing.T) { + hub, database := newVoiceHub(t) + alice := seedMemberUser(t, database, "grpvoice-x-alice") + bob := seedMemberUser(t, database, "grpvoice-x-bob") + carol := seedMemberUser(t, database, "grpvoice-x-carol") + mallory := seedMemberUser(t, database, "grpvoice-x-mallory") + chID := seedGroupDM(t, database, "Callers", alice.ID, bob.ID, carol.ID) + + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, mallory, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceJoinMsg(chID)) + + assertNoVoiceToken(t, drainChanTimeout(send, 200*time.Millisecond)) +} diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go index c158effa..4942ef8d 100644 --- a/Server/ws/voice_handlers_test.go +++ b/Server/ws/voice_handlers_test.go @@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS voice_states ( speaking INTEGER NOT NULL DEFAULT 0, camera INTEGER NOT NULL DEFAULT 0, screenshare INTEGER NOT NULL DEFAULT 0, + server_muted INTEGER NOT NULL DEFAULT 0, + server_deafened 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/ws/voice_join.go b/Server/ws/voice_join.go index 73138ae5..88dd80c1 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -193,7 +193,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe var overrides map[int64]db.ChannelOverride var oErr error if !permissions.HasAdmin(role.Permissions) { - overrides, oErr = h.db.GetAllChannelPermissionsForRole(ctx, role.ID) + overrides, oErr = h.db.GetChannelOverridesFor(ctx, role.ID, c.userID) } if oErr == nil { po := permOverrides(overrides) diff --git a/Server/ws/voice_moderation.go b/Server/ws/voice_moderation.go new file mode 100644 index 00000000..21dd9697 --- /dev/null +++ b/Server/ws/voice_moderation.go @@ -0,0 +1,378 @@ +package ws + +import ( + "context" + "fmt" + "log/slog" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// Voice moderation handlers: server mute, server deafen, move, disconnect. +// +// All four share one authorization contract, enforced by voiceModTarget: +// MUTE_MEMBERS on the actor's role (Administrator bypasses), the actor must +// strictly outrank the target by role position (mirroring +// ModerationService.requireOutranks), and the target must currently be in a +// voice channel. Effects that reach past the acting connection — the SFU and +// the target's own socket — go through VoiceDeps.Mod. + +// registerVoiceModerationV2 registers the four moderator voice commands. +// Called from registerVoiceControlsV2 so the deps struct is built once. +func registerVoiceModerationV2(r *HandlerRegistry, deps VoiceDeps) { + r.RegisterV2(MsgTypeVoiceModMute, handleVoiceModMuteV2, deps) + r.RegisterV2(MsgTypeVoiceModDeafen, handleVoiceModDeafenV2, deps) + r.RegisterV2(MsgTypeVoiceModMove, handleVoiceModMoveV2, deps) + r.RegisterV2(MsgTypeVoiceModKick, handleVoiceModKickV2, deps) +} + +// voiceModRole loads a role through the permission cache when one is wired and +// falls back to the live DB otherwise. Every failure is a denial: an +// unresolvable role must never authorize a moderation action. +func voiceModRole(ctx context.Context, d VoiceDeps, userID int64) (*db.Role, bool) { + if d.PermSvc != nil { + role, err := d.PermSvc.GetRoleForUser(ctx, userID) + if err == nil && role != nil { + return role, true + } + return nil, false + } + if d.DB == nil { + return nil, false + } + role, err := d.DB.GetRoleForUser(ctx, userID) + if err != nil || role == nil { + return nil, false + } + return role, true +} + +// voiceModTarget runs the shared gate and returns the target's live voice +// state. Authorization is checked before the voice-state lookup so an actor +// without authority always sees FORBIDDEN and never learns who is in voice. +func voiceModTarget(ctx context.Context, d VoiceDeps, actorID, targetID int64) (*db.VoiceState, *Result) { + if actorID == targetID { + return nil, &Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "cannot moderate yourself"}} + } + + actorRole, ok := voiceModRole(ctx, d, actorID) + if !ok { + return nil, &Result{Error: ClientError{Code: ErrCodeForbidden, Message: "failed to load actor role"}} + } + if !permissions.HasServerPerm(actorRole.Permissions, permissions.MuteMembers) { + return nil, &Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing MUTE_MEMBERS permission"}} + } + targetRole, ok := voiceModRole(ctx, d, targetID) + if !ok { + return nil, &Result{Error: ClientError{Code: ErrCodeForbidden, Message: "failed to load target role"}} + } + // Administrator bypasses permission bits, never the hierarchy. + if actorRole.Position <= targetRole.Position { + return nil, &Result{Error: ClientError{ + Code: ErrCodeForbidden, + Message: "cannot moderate a user of equal or higher rank", + }} + } + + state, err := d.DB.GetVoiceState(ctx, targetID) + if err != nil { + slog.Error("ws voiceModTarget GetVoiceState", "err", err, "target_id", targetID) + return nil, &Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to read voice state"}} + } + if state == nil { + return nil, &Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not in a voice channel"}} + } + + // MUTE_MEMBERS authorizes moderating server voice channels, not a private + // DM call the actor happens not to be part of — voice_mod_kick and friends + // carry no channel id from the client, so without this a moderator could + // reach into any two users' DM call by targeting a user id alone. Refused + // with the exact same shape as "target not in voice" so the actor learns + // nothing about a DM call they are not in. + ch, err := d.DB.GetChannel(ctx, state.ChannelID) + if err != nil { + slog.Error("ws voiceModTarget GetChannel", "err", err, "channel_id", state.ChannelID) + return nil, &Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to read channel"}} + } + if ch != nil && ch.Type == "dm" { + participant, err := d.DB.IsDMParticipant(ctx, actorID, state.ChannelID) + if err != nil { + slog.Error("ws voiceModTarget IsDMParticipant", "err", err, "channel_id", state.ChannelID) + return nil, &Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to verify DM membership"}} + } + if !participant { + return nil, &Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not in a voice channel"}} + } + } + + return state, nil +} + +// voiceModRateLimited applies the shared per-action rate limit. Each of these +// commands fans a voice_state broadcast out to every client that can see the +// channel, so a moderator must not be able to drive them in a tight loop. +func voiceModRateLimited(d VoiceDeps, action string, userID int64) *Result { + if d.Limiter == nil { + return nil + } + if d.Limiter.Allow(auth.Key(action, userID), voiceModRateLimit, voiceModWindow) { + return nil + } + return &Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many voice moderation actions"}} +} + +// requireTargetInChannel refuses when the target has moved on since the +// moderator's client rendered the row that produced this command. +func requireTargetInChannel(state *db.VoiceState, channelID int64) *Result { + if state.ChannelID == channelID { + return nil + } + return &Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not in that voice channel"}} +} + +// handleVoiceModMuteV2 processes a voice_mod_mute command. The DB row is the +// authority for the UI; the SFU mute is what makes it more than cosmetic, so a +// LiveKit failure is logged but does not fail the action — the persisted +// server_muted still blocks the target's own unmute and is re-applied whenever +// the moderator retries. +func handleVoiceModMuteV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { + d := deps.(VoiceDeps) + c := cmd.(VoiceModMuteCmd) + + if r := voiceModRateLimited(d, "voice_mod_mute", info.UserID); r != nil { + return *r + } + state, r := voiceModTarget(ctx, d, info.UserID, c.TargetID()) + if r != nil { + return *r + } + if r := requireTargetInChannel(state, c.ChannelID()); r != nil { + return *r + } + + if err := d.DB.SetVoiceServerMute(ctx, c.TargetID(), c.Muted()); err != nil { + slog.Error("ws handleVoiceModMuteV2 SetVoiceServerMute", "err", err, "target_id", c.TargetID()) + return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update server mute"}} + } + if d.Mod != nil { + if err := d.Mod.MuteParticipant(ctx, state.ChannelID, c.TargetID(), state.JoinedAt, c.Muted()); err != nil { + slog.Warn("ws handleVoiceModMuteV2 MuteParticipant failed", + "err", err, "target_id", c.TargetID(), "channel_id", state.ChannelID) + } + } + + writeVoiceModAudit(ctx, d, info.UserID, "voice_mod_mute", c.TargetID(), + fmt.Sprintf("server mute %s in channel %d", onOff(c.Muted()), state.ChannelID)) + slog.Info("voice server mute", "actor_id", info.UserID, "target_id", c.TargetID(), + "channel_id", state.ChannelID, "muted", c.Muted()) + + return voiceStateBroadcast(ctx, d, c.TargetID()) +} + +// handleVoiceModDeafenV2 processes a voice_mod_deafen command. Deafen has no +// SFU equivalent (it is about what the target plays back), so it is enforced by +// the target's client honoring server_deafened plus the server refusing their +// own undeafen while it is set. +func handleVoiceModDeafenV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { + d := deps.(VoiceDeps) + c := cmd.(VoiceModDeafenCmd) + + if r := voiceModRateLimited(d, "voice_mod_deafen", info.UserID); r != nil { + return *r + } + state, r := voiceModTarget(ctx, d, info.UserID, c.TargetID()) + if r != nil { + return *r + } + if r := requireTargetInChannel(state, c.ChannelID()); r != nil { + return *r + } + + if err := d.DB.SetVoiceServerDeafen(ctx, c.TargetID(), c.Deafened()); err != nil { + slog.Error("ws handleVoiceModDeafenV2 SetVoiceServerDeafen", "err", err, "target_id", c.TargetID()) + return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update server deafen"}} + } + // A server deafen implies a server mute at the SFU: a deafened user must + // not keep talking into a room they cannot hear. Lifting the deafen must + // lift that implied mute too, or the target stays SFU-muted and refused + // their own unmute even after the deafen is gone. server_muted is a + // single bool with no way to tell "explicit" from "deafen-implied" apart, + // so an explicit-mute-then-deafen sequence has both lifted together by an + // undeafen — accepted as the simplest correct behavior given the schema. + if err := d.DB.SetVoiceServerMute(ctx, c.TargetID(), c.Deafened()); err != nil { + slog.Error("ws handleVoiceModDeafenV2 SetVoiceServerMute", "err", err, "target_id", c.TargetID()) + return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update server deafen"}} + } + if d.Mod != nil { + if err := d.Mod.MuteParticipant(ctx, state.ChannelID, c.TargetID(), state.JoinedAt, c.Deafened()); err != nil { + slog.Warn("ws handleVoiceModDeafenV2 MuteParticipant failed", + "err", err, "target_id", c.TargetID(), "channel_id", state.ChannelID) + } + } + + writeVoiceModAudit(ctx, d, info.UserID, "voice_mod_deafen", c.TargetID(), + fmt.Sprintf("server deafen %s in channel %d", onOff(c.Deafened()), state.ChannelID)) + slog.Info("voice server deafen", "actor_id", info.UserID, "target_id", c.TargetID(), + "channel_id", state.ChannelID, "deafened", c.Deafened()) + + return voiceStateBroadcast(ctx, d, c.TargetID()) +} + +// handleVoiceModMoveV2 processes a voice_mod_move command. +// +// The move is a server-driven leave followed by a client-driven re-join: the +// hub runs its voice-leave routine for the target (DB row, LiveKit participant, +// voice_leave broadcast) and then sends voice_moved, which the target's client +// answers with an ordinary voice_join for the destination. That keeps one +// implementation of the join sequence — capacity, token minting, key-holder +// election, existing-state fan-out — instead of a second, divergent copy here. +// The checks below are the pre-flight: they refuse a move the re-join would +// only bounce, so the target is never dropped from voice for nothing. +func handleVoiceModMoveV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { + d := deps.(VoiceDeps) + c := cmd.(VoiceModMoveCmd) + + if r := voiceModRateLimited(d, "voice_mod_move", info.UserID); r != nil { + return *r + } + state, r := voiceModTarget(ctx, d, info.UserID, c.TargetID()) + if r != nil { + return *r + } + if state.ChannelID == c.ToChannelID() { + return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "user is already in that voice channel"}} + } + + dest, err := d.DB.GetChannel(ctx, c.ToChannelID()) + if err != nil { + slog.Error("ws handleVoiceModMoveV2 GetChannel", "err", err, "channel_id", c.ToChannelID()) + return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to read destination channel"}} + } + if dest == nil { + return Result{Error: ClientError{Code: ErrCodeNotFound, Message: "channel not found"}} + } + if dest.Type != "voice" { + return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "destination is not a voice channel"}} + } + // The destination is gated on the TARGET's access, not the moderator's: + // a move must not become a way to place someone in a channel they could + // not join themselves. + if !hasChannelAccess(ctx, d.DB, d.Permissions, d.PermSvc, c.TargetID(), c.ToChannelID(), permissions.ConnectVoice) { + return Result{Error: ClientError{ + Code: ErrCodeForbidden, + Message: "user cannot connect to that voice channel", + }} + } + // Advisory capacity check with JoinVoiceChannelIfCapacity's semantics. The + // atomic one still runs on the re-join; this one keeps the common case from + // dropping the target into a channel that is already full. + if dest.VoiceMaxUsers > 0 { + count, cErr := d.DB.CountChannelVoiceUsers(ctx, c.ToChannelID()) + if cErr != nil { + slog.Error("ws handleVoiceModMoveV2 CountChannelVoiceUsers", "err", cErr, "channel_id", c.ToChannelID()) + return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to check channel capacity"}} + } + if count >= dest.VoiceMaxUsers { + return Result{Error: ClientError{Code: ErrCodeChannelFull, Message: "voice channel is full"}} + } + } + + if d.Mod == nil { + return Result{Error: ClientError{Code: ErrCodeInternal, Message: "voice moderation unavailable"}} + } + if !d.Mod.DisconnectFromVoice(ctx, c.TargetID()) { + // No live connection on this node — the voice_states row is a ghost the + // sweeper owns, and there is nobody to send voice_moved to. + return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not connected"}} + } + d.Mod.SendToUser(c.TargetID(), buildVoiceMoved(c.ToChannelID())) + + writeVoiceModAudit(ctx, d, info.UserID, "voice_mod_move", c.TargetID(), + fmt.Sprintf("moved from channel %d to channel %d", state.ChannelID, c.ToChannelID())) + slog.Info("voice moderator move", "actor_id", info.UserID, "target_id", c.TargetID(), + "from_channel_id", state.ChannelID, "to_channel_id", c.ToChannelID()) + + // handleVoiceLeave already broadcast voice_leave for the old channel; the + // re-join broadcasts voice_state for the new one. + return Result{} +} + +// handleVoiceModKickV2 processes a voice_mod_kick command: the target is +// removed from the LiveKit room, their voice_states row is deleted and +// voice_leave is broadcast (all by the hub's voice-leave routine), then they +// are told why. +func handleVoiceModKickV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { + d := deps.(VoiceDeps) + c := cmd.(VoiceModKickCmd) + + if r := voiceModRateLimited(d, "voice_mod_kick", info.UserID); r != nil { + return *r + } + state, r := voiceModTarget(ctx, d, info.UserID, c.TargetID()) + if r != nil { + return *r + } + + if d.Mod == nil { + return Result{Error: ClientError{Code: ErrCodeInternal, Message: "voice moderation unavailable"}} + } + if !d.Mod.DisconnectFromVoice(ctx, c.TargetID()) { + return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "user is not connected"}} + } + d.Mod.SendToUser(c.TargetID(), + buildVoiceDisconnected(state.ChannelID, "You were disconnected from voice by a moderator")) + + writeVoiceModAudit(ctx, d, info.UserID, "voice_mod_kick", c.TargetID(), + fmt.Sprintf("disconnected from channel %d", state.ChannelID)) + slog.Info("voice moderator disconnect", "actor_id", info.UserID, "target_id", c.TargetID(), + "channel_id", state.ChannelID) + + return Result{} +} + +// writeVoiceModAudit records a moderation action. The row must survive a +// connection that dies right after the effect landed, so the write is detached +// from the dispatching context. +func writeVoiceModAudit(ctx context.Context, d VoiceDeps, actorID int64, action string, targetID int64, detail string) { + if d.DB == nil { + return + } + db.WriteAudit(context.WithoutCancel(ctx), d.DB, actorID, action, "user", targetID, detail) +} + +// onOff renders a boolean for an audit detail string. +func onOff(v bool) string { + if v { + return "on" + } + return "off" +} + +// ── Hub-side effects ──────────────────────────────────────────────────────── + +// MuteParticipant mutes or unmutes the target's published audio at the SFU. +// Satisfies VoiceModerator; reads h.livekit at call time so SetLiveKit's late +// wiring is picked up (same reason as GenerateToken). +func (h *Hub) MuteParticipant(ctx context.Context, channelID, userID int64, voiceJoinToken string, muted bool) error { + if h.livekit == nil { + return fmt.Errorf("voice not configured") + } + return h.livekit.MuteParticipantAudio(ctx, channelID, userID, voiceJoinToken, muted) +} + +// DisconnectFromVoice runs the hub's voice-leave routine for another user's +// connection, which is what a moderator move or disconnect needs: DB row, +// LiveKit participant, topic unsubscribe, key-holder re-election and the +// voice_leave broadcast, in the one implementation that also serves the +// disconnect and channel-switch paths. Reports false when the user has no +// connection on this node. +func (h *Hub) DisconnectFromVoice(ctx context.Context, userID int64) bool { + c := h.GetClient(userID) + if c == nil { + return false + } + h.handleVoiceLeave(ctx, c) + return true +} diff --git a/Server/ws/voice_moderation_test.go b/Server/ws/voice_moderation_test.go new file mode 100644 index 00000000..9bb2fcb4 --- /dev/null +++ b/Server/ws/voice_moderation_test.go @@ -0,0 +1,603 @@ +package ws_test + +import ( + "context" + "encoding/json" + "slices" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" + "github.com/owncord/server/ws" +) + +// voiceModSchema is voiceSchema plus audit_log, so the moderation handlers' +// db.WriteAudit calls land in a real table instead of erroring out. +var voiceModSchema = append(append([]byte{}, voiceSchema...), []byte(` +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')) +); +`)...) + +// newVoiceModHub mirrors newVoiceHub but on the audit-capable schema. +func newVoiceModHub(t *testing.T) (*ws.Hub, *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: voiceModSchema}} + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter, nil) + lk, err := ws.NewLiveKitClient(&config.VoiceConfig{ + LiveKitAPIKey: "test-api-key-12345", + LiveKitAPISecret: "test-api-secret-67890abcdef", + LiveKitURL: "ws://localhost:7880", + }) + if err != nil { + t.Fatalf("NewLiveKitClient: %v", err) + } + hub.SetLiveKit(lk) + + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + return hub, database +} + +// seedVoiceUserWithRole inserts a user with an explicit role id from the +// default role table (1 Owner/pos 100, 2 Admin/pos 80, 3 Moderator/pos 60, +// 4 Member/pos 40). Admin holds MUTE_MEMBERS without ADMINISTRATOR; Moderator +// holds neither, which is what the denial cases below rely on. +func seedVoiceUserWithRole(t *testing.T, database *db.DB, username string, roleID int) *db.User { + t.Helper() + if _, err := database.CreateUser(context.Background(), username, "hash", roleID); err != nil { + t.Fatalf("seedVoiceUserWithRole CreateUser: %v", err) + } + user, err := database.GetUserByUsername(context.Background(), username) + if err != nil || user == nil { + t.Fatalf("seedVoiceUserWithRole GetUserByUsername: %v", err) + } + return user +} + +// joinVoice registers a client for user and puts them in chanID via voice_join, +// returning the client and its send channel drained of the join traffic. +func joinVoice(t *testing.T, hub *ws.Hub, user *db.User, chanID int64) (*ws.Client, chan []byte) { + t.Helper() + send := make(chan []byte, 32) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + drainChanTimeout(send, 30*time.Millisecond) + return c, send +} + +func voiceModMuteMsg(channelID, userID int64, muted bool) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_mod_mute", + "payload": map[string]any{"channel_id": channelID, "user_id": userID, "muted": muted}, + }) + return raw +} + +func voiceModDeafenMsg(channelID, userID int64, deafened bool) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_mod_deafen", + "payload": map[string]any{"channel_id": channelID, "user_id": userID, "deafened": deafened}, + }) + return raw +} + +func voiceModMoveMsg(userID, toChannelID int64) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_mod_move", + "payload": map[string]any{"user_id": userID, "to_channel_id": toChannelID}, + }) + return raw +} + +func voiceModKickMsg(userID int64) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "voice_mod_kick", + "payload": map[string]any{"user_id": userID}, + }) + return raw +} + +// auditActions returns the actions recorded in the audit log, newest first. +func auditActions(t *testing.T, database *db.DB) []string { + t.Helper() + entries, err := database.GetAuditLog(context.Background(), 50, 0) + if err != nil { + t.Fatalf("GetAuditLog: %v", err) + } + actions := make([]string, 0, len(entries)) + for _, e := range entries { + actions = append(actions, e.Action) + } + return actions +} + +// ─── authorization ──────────────────────────────────────────────────────────── + +func TestVoiceMod_Mute_WithoutMutePermission_Forbidden(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-perm") + actor := seedVoiceUserWithRole(t, database, "mod-noperm", 3) // Moderator: no MUTE_MEMBERS + target := seedVoiceUserWithRole(t, database, "target-perm", 4) // Member + + joinVoice(t, hub, target, chanID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true)) + + if code := receiveErrorCode(send, waitTimeout); code != "FORBIDDEN" { + t.Fatalf("error code = %q, want FORBIDDEN", code) + } + state, _ := database.GetVoiceState(context.Background(), target.ID) + if state == nil || state.ServerMuted { + t.Error("target must not be server muted after a refused action") + } +} + +func TestVoiceMod_Mute_TargetOutranksActor_Forbidden(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-rank") + actor := seedVoiceUserWithRole(t, database, "admin-rank", 2) // Admin, position 80 + target := seedVoiceUserWithRole(t, database, "owner-rank", 1) // Owner, position 100 + + joinVoice(t, hub, target, chanID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true)) + + if code := receiveErrorCode(send, waitTimeout); code != "FORBIDDEN" { + t.Fatalf("error code = %q, want FORBIDDEN", code) + } + state, _ := database.GetVoiceState(context.Background(), target.ID) + if state == nil || state.ServerMuted { + t.Error("higher-ranked target must not be server muted") + } +} + +func TestVoiceMod_Mute_TargetNotInVoice_VoiceError(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-absent") + actor := seedVoiceUserWithRole(t, database, "admin-absent", 2) + target := seedVoiceUserWithRole(t, database, "member-absent", 4) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true)) + + if code := receiveErrorCode(send, waitTimeout); code != "VOICE_ERROR" { + t.Fatalf("error code = %q, want VOICE_ERROR", code) + } +} + +func TestVoiceMod_Mute_WrongChannel_VoiceError(t *testing.T) { + hub, database := newVoiceModHub(t) + chanA := seedVoiceChan(t, database, "vc-wrong-a") + chanB := seedVoiceChan(t, database, "vc-wrong-b") + actor := seedVoiceUserWithRole(t, database, "admin-wrong", 2) + target := seedVoiceUserWithRole(t, database, "member-wrong", 4) + + joinVoice(t, hub, target, chanA) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanA, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMuteMsg(chanB, target.ID, true)) + + if code := receiveErrorCode(send, waitTimeout); code != "VOICE_ERROR" { + t.Fatalf("error code = %q, want VOICE_ERROR", code) + } +} + +func TestVoiceMod_Kick_Self_BadRequest(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-self") + actor := seedVoiceUserWithRole(t, database, "admin-self", 2) + + c, send := joinVoice(t, hub, actor, chanID) + hub.HandleMessageForTest(c, voiceModKickMsg(actor.ID)) + + if code := receiveErrorCode(send, waitTimeout); code != "BAD_REQUEST" { + t.Fatalf("error code = %q, want BAD_REQUEST", code) + } +} + +// voice_mod_kick and friends carry no channel id for the target, so +// MUTE_MEMBERS alone let a moderator reach into a private DM call they are +// not a participant of by targeting a user id. The refusal must look exactly +// like "target not in voice" — a VOICE_ERROR, not FORBIDDEN — so the actor +// cannot use the response to learn the target is in a DM call at all. +func TestVoiceMod_Mute_TargetInDMCallActorNotParticipant_VoiceError(t *testing.T) { + hub, database := newVoiceModHub(t) + alice := seedVoiceUserWithRole(t, database, "dm-alice", 4) // Member, DM participant + bob := seedVoiceUserWithRole(t, database, "dm-bob", 4) // Member, DM participant (target) + mallory := seedVoiceUserWithRole(t, database, "dm-mallory", 2) // Admin: has MUTE_MEMBERS, not a participant + dmID := seedDMChannel(t, database, alice.ID, bob.ID) + + joinVoice(t, hub, bob, dmID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, mallory, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMuteMsg(dmID, bob.ID, true)) + + if code := receiveErrorCode(send, waitTimeout); code != "VOICE_ERROR" { + t.Fatalf("error code = %q, want VOICE_ERROR (must not disclose the DM call via FORBIDDEN)", code) + } + state, _ := database.GetVoiceState(context.Background(), bob.ID) + if state == nil || state.ServerMuted { + t.Error("target in a DM call the actor is not part of must not be server muted") + } +} + +// A MUTE_MEMBERS holder who genuinely IS a participant of the DM call may +// still moderate it, same as any other voice channel. +func TestVoiceMod_Mute_TargetInDMCallActorIsParticipant_Allowed(t *testing.T) { + hub, database := newVoiceModHub(t) + admin := seedVoiceUserWithRole(t, database, "dm-admin", 2) // Admin: MUTE_MEMBERS, DM participant + member := seedVoiceUserWithRole(t, database, "dm-member", 4) // Member (target) + dmID := seedDMChannel(t, database, admin.ID, member.ID) + + joinVoice(t, hub, member, dmID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, admin, 0, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMuteMsg(dmID, member.ID, true)) + + state, err := database.GetVoiceState(context.Background(), member.ID) + if err != nil || state == nil || !state.ServerMuted { + t.Fatalf("expected target to be server muted, state=%+v err=%v", state, err) + } +} + +// ─── happy paths ────────────────────────────────────────────────────────────── + +func TestVoiceMod_Mute_SetsServerMutedAndBroadcasts(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-mute") + actor := seedVoiceUserWithRole(t, database, "admin-mute", 2) + target := seedVoiceUserWithRole(t, database, "member-mute", 4) + + _, targetSend := joinVoice(t, hub, target, chanID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true)) + + state, err := database.GetVoiceState(context.Background(), target.ID) + if err != nil || state == nil { + t.Fatalf("GetVoiceState: %v", err) + } + if !state.ServerMuted { + t.Error("ServerMuted = false, want true") + } + if !state.Muted { + t.Error("Muted = false, want true (server mute implies muted)") + } + + payload := receiveMsgOfType(targetSend, "voice_state", waitTimeout) + if payload == nil { + t.Fatal("no voice_state broadcast after voice_mod_mute") + } + if payload["server_muted"] != true { + t.Errorf("broadcast server_muted = %v, want true", payload["server_muted"]) + } + + if !slices.Contains(auditActions(t, database), "voice_mod_mute") { + t.Error("voice_mod_mute audit entry missing") + } +} + +func TestVoiceMod_Mute_Clear_LeavesSelfMuteAlone(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-unmute") + actor := seedVoiceUserWithRole(t, database, "admin-unmute", 2) + target := seedVoiceUserWithRole(t, database, "member-unmute", 4) + + joinVoice(t, hub, target, chanID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true)) + hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, false)) + + state, err := database.GetVoiceState(context.Background(), target.ID) + if err != nil || state == nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state.ServerMuted { + t.Error("ServerMuted = true after clear, want false") + } + if !state.Muted { + t.Error("Muted = false, want true: clearing a server mute must not unmute for the user") + } +} + +func TestVoiceMod_Deafen_SetsServerDeafenedAndMutes(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-deafen") + actor := seedVoiceUserWithRole(t, database, "admin-deafen", 2) + target := seedVoiceUserWithRole(t, database, "member-deafen", 4) + + _, targetSend := joinVoice(t, hub, target, chanID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModDeafenMsg(chanID, target.ID, true)) + + state, err := database.GetVoiceState(context.Background(), target.ID) + if err != nil || state == nil { + t.Fatalf("GetVoiceState: %v", err) + } + if !state.ServerDeafened || !state.Deafened { + t.Errorf("ServerDeafened=%v Deafened=%v, want both true", state.ServerDeafened, state.Deafened) + } + if !state.ServerMuted { + t.Error("ServerMuted = false, want true: a server deafen also silences the microphone") + } + + payload := receiveMsgOfType(targetSend, "voice_state", waitTimeout) + if payload == nil { + t.Fatal("no voice_state broadcast after voice_mod_deafen") + } + if payload["server_deafened"] != true { + t.Errorf("broadcast server_deafened = %v, want true", payload["server_deafened"]) + } + if !slices.Contains(auditActions(t, database), "voice_mod_deafen") { + t.Error("voice_mod_deafen audit entry missing") + } +} + +// Lifting a server deafen must also lift the mute it implied, or the target +// stays SFU-muted (and refused their own unmute) after the deafen is gone. +func TestVoiceMod_Deafen_ClearingRestoresSelfUnmute(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-undeafen") + actor := seedVoiceUserWithRole(t, database, "admin-undeafen", 2) + target := seedVoiceUserWithRole(t, database, "member-undeafen", 4) + + targetClient, targetSend := joinVoice(t, hub, target, chanID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + // Server-deafen the target: implies a server mute too. + hub.HandleMessageForTest(c, voiceModDeafenMsg(chanID, target.ID, true)) + drainChanTimeout(targetSend, 30*time.Millisecond) + + state, err := database.GetVoiceState(context.Background(), target.ID) + if err != nil || state == nil || !state.ServerDeafened || !state.ServerMuted { + t.Fatalf("precondition: expected server_deafened and server_muted both set, state=%+v err=%v", state, err) + } + + // Lift the deafen. + hub.HandleMessageForTest(c, voiceModDeafenMsg(chanID, target.ID, false)) + drainChanTimeout(targetSend, 30*time.Millisecond) + + state, err = database.GetVoiceState(context.Background(), target.ID) + if err != nil || state == nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state.ServerDeafened { + t.Error("ServerDeafened = true after clearing, want false") + } + if state.ServerMuted { + t.Error("ServerMuted = true after clearing the deafen that implied it, want false") + } + + // The target must now be able to self-unmute without SERVER_MUTED. + hub.HandleMessageForTest(targetClient, voiceMuteMsg(false)) + if code := receiveErrorCode(targetSend, 200*time.Millisecond); code != "" { + t.Fatalf("self-unmute refused with %q after deafen was cleared, want no error", code) + } +} + +func TestVoiceMod_Kick_RemovesFromVoiceAndNotifiesTarget(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-kick") + actor := seedVoiceUserWithRole(t, database, "admin-kick", 2) + target := seedVoiceUserWithRole(t, database, "member-kick", 4) + + _, targetSend := joinVoice(t, hub, target, chanID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModKickMsg(target.ID)) + + waitFor(t, waitTimeout, func() bool { + state, err := database.GetVoiceState(context.Background(), target.ID) + return err == nil && state == nil + }, "target's voice_states row to be deleted") + + if receiveMsgOfType(targetSend, "voice_disconnected", waitTimeout) == nil { + t.Error("target did not receive voice_disconnected") + } + if !slices.Contains(auditActions(t, database), "voice_mod_kick") { + t.Error("voice_mod_kick audit entry missing") + } +} + +func TestVoiceMod_Move_DisconnectsAndSendsVoiceMoved(t *testing.T) { + hub, database := newVoiceModHub(t) + fromID := seedVoiceChan(t, database, "vc-move-from") + toID := seedVoiceChan(t, database, "vc-move-to") + actor := seedVoiceUserWithRole(t, database, "admin-move", 2) + target := seedVoiceUserWithRole(t, database, "member-move", 4) + + _, targetSend := joinVoice(t, hub, target, fromID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, fromID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMoveMsg(target.ID, toID)) + + payload := receiveMsgOfType(targetSend, "voice_moved", waitTimeout) + if payload == nil { + t.Fatal("target did not receive voice_moved") + } + if int64(payload["to_channel_id"].(float64)) != toID { + t.Errorf("to_channel_id = %v, want %d", payload["to_channel_id"], toID) + } + waitFor(t, waitTimeout, func() bool { + state, err := database.GetVoiceState(context.Background(), target.ID) + return err == nil && state == nil + }, "target to be removed from the old channel pending re-join") + + if !slices.Contains(auditActions(t, database), "voice_mod_move") { + t.Error("voice_mod_move audit entry missing") + } +} + +func TestVoiceMod_Move_TextChannelDestination_BadRequest(t *testing.T) { + hub, database := newVoiceModHub(t) + fromID := seedVoiceChan(t, database, "vc-move-bad") + textID, err := database.CreateChannel(context.Background(), "general-move", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + actor := seedVoiceUserWithRole(t, database, "admin-move-bad", 2) + target := seedVoiceUserWithRole(t, database, "member-move-bad", 4) + + joinVoice(t, hub, target, fromID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, fromID, send) + hub.Register(c) + waitRegistered(t, hub, c) + + hub.HandleMessageForTest(c, voiceModMoveMsg(target.ID, textID)) + + if code := receiveErrorCode(send, waitTimeout); code != "BAD_REQUEST" { + t.Fatalf("error code = %q, want BAD_REQUEST", code) + } + state, _ := database.GetVoiceState(context.Background(), target.ID) + if state == nil { + t.Error("a refused move must leave the target in voice") + } +} + +// ─── self-service controls under a server mute ─────────────────────────────── + +func TestVoiceMute_SelfUnmuteWhileServerMuted_Refused(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-selfunmute") + actor := seedVoiceUserWithRole(t, database, "admin-selfunmute", 2) + target := seedVoiceUserWithRole(t, database, "member-selfunmute", 4) + + targetClient, targetSend := joinVoice(t, hub, target, chanID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true)) + drainChanTimeout(targetSend, 30*time.Millisecond) + + hub.HandleMessageForTest(targetClient, voiceMuteMsg(false)) + + if code := receiveErrorCode(targetSend, waitTimeout); code != "SERVER_MUTED" { + t.Fatalf("error code = %q, want SERVER_MUTED", code) + } + state, _ := database.GetVoiceState(context.Background(), target.ID) + if state == nil || !state.Muted { + t.Error("target must still be muted after a refused self-unmute") + } +} + +func TestVoiceDeafen_SelfUndeafenWhileServerDeafened_Refused(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-selfundeaf") + actor := seedVoiceUserWithRole(t, database, "admin-selfundeaf", 2) + target := seedVoiceUserWithRole(t, database, "member-selfundeaf", 4) + + targetClient, targetSend := joinVoice(t, hub, target, chanID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + hub.HandleMessageForTest(c, voiceModDeafenMsg(chanID, target.ID, true)) + drainChanTimeout(targetSend, 30*time.Millisecond) + + hub.HandleMessageForTest(targetClient, voiceDeafenMsg(false)) + + if code := receiveErrorCode(targetSend, waitTimeout); code != "SERVER_DEAFENED" { + t.Fatalf("error code = %q, want SERVER_DEAFENED", code) + } +} + +func TestVoiceMute_SelfMuteWhileServerMuted_Allowed(t *testing.T) { + hub, database := newVoiceModHub(t) + chanID := seedVoiceChan(t, database, "vc-selfmute-ok") + actor := seedVoiceUserWithRole(t, database, "admin-selfmute-ok", 2) + target := seedVoiceUserWithRole(t, database, "member-selfmute-ok", 4) + + targetClient, targetSend := joinVoice(t, hub, target, chanID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, actor, chanID, send) + hub.Register(c) + waitRegistered(t, hub, c) + hub.HandleMessageForTest(c, voiceModMuteMsg(chanID, target.ID, true)) + drainChanTimeout(targetSend, 30*time.Millisecond) + + hub.HandleMessageForTest(targetClient, voiceMuteMsg(true)) + + if code := receiveErrorCode(targetSend, 200*time.Millisecond); code != "" { + t.Fatalf("self-mute refused with %q, want no error", code) + } +} diff --git a/docs/api.md b/docs/api.md index 70a3fc81..24a80bda 100644 --- a/docs/api.md +++ b/docs/api.md @@ -87,6 +87,9 @@ Create a new account using an invite code. The first user is created via `/admin "id": 2, "username": "alex", "avatar": "", + "display_name": null, + "about": null, + "custom_status": null, "status": "offline", "role_id": 4, "totp_enabled": false, @@ -95,6 +98,8 @@ Create a new account using an invite code. The first user is created via `/admin } ``` +See [GET /api/v1/auth/me](#get-apiv1authme) for the full user-object field table. + #### Errors | Status | Code | Cause | @@ -134,7 +139,10 @@ If the account does not have TOTP enabled: "user": { "id": 1, "username": "alex", - "avatar": "uuid.png", + "avatar": "/api/v1/files/uuid", + "display_name": "Alex", + "about": null, + "custom_status": null, "status": "offline", "role_id": 4, "totp_enabled": false, @@ -143,6 +151,8 @@ If the account does not have TOTP enabled: } ``` +See [GET /api/v1/auth/me](#get-apiv1authme) for the full user-object field table. + If the account has TOTP enabled: ```json @@ -188,7 +198,10 @@ Complete a TOTP login challenge started by `POST /api/v1/auth/login`. "user": { "id": 1, "username": "alex", - "avatar": "uuid.png", + "avatar": "/api/v1/files/uuid", + "display_name": "Alex", + "about": null, + "custom_status": null, "status": "offline", "role_id": 4, "totp_enabled": true, @@ -197,6 +210,8 @@ Complete a TOTP login challenge started by `POST /api/v1/auth/login`. } ``` +See [GET /api/v1/auth/me](#get-apiv1authme) for the full user-object field table. + #### Errors | Status | Code | Cause | @@ -219,7 +234,10 @@ Get the current authenticated user's profile. { "id": 1, "username": "alex", - "avatar": "uuid.png", + "avatar": "/api/v1/files/uuid", + "display_name": "Alex", + "about": "A short bio.", + "custom_status": "building things", "status": "online", "role_id": 2, "totp_enabled": true, @@ -227,12 +245,18 @@ Get the current authenticated user's profile. } ``` +This is the canonical **user object**, also returned as `user` by register, +login and the TOTP challenge. + | Field | Type | Description | | ----- | ---- | ----------- | | `id` | int64 | User ID | -| `username` | string | Display name | -| `avatar` | string | Avatar filename (UUID) or empty string | -| `status` | string | One of: `online`, `idle`, `dnd`, `offline` | +| `username` | string | Unique handle; the name `@mentions` resolve against | +| `avatar` | string | Avatar URL (`/api/v1/files/{id}` after an upload, or an `https://` URL), or empty string | +| `display_name` | string\|null | Nickname rendered instead of `username`; null when unset | +| `about` | string\|null | Profile bio, max 300 characters; null when unset | +| `custom_status` | string\|null | Free-text status line, max 128 characters; null when unset. Set over WebSocket (`presence_update`), not over REST | +| `status` | string | One of: `online`, `idle`, `dnd`, `invisible`, `offline`. **This is the caller's own true status**, so `invisible` appears here; every payload describing this user to *anyone else* reports `offline` instead | | `role_id` | int64 | Numeric role ID (1=Owner, 2=Admin, 3=Moderator, 4=Member) | | `totp_enabled` | bool | Whether the user has a confirmed TOTP secret | | `created_at` | string | ISO 8601 timestamp | @@ -348,8 +372,9 @@ Disable TOTP for the authenticated user. ### PATCH /api/v1/users/me -Update the authenticated user's profile (username and/or avatar). -Broadcasts a `user_update` WebSocket message to all clients on success. +Update the authenticated user's profile. Broadcasts a `user_update` WebSocket +message to all clients on success, carrying the full profile snapshot (the +event replaces the client's copy rather than patching it). **Auth:** Required **Rate limit:** 10 requests/minute @@ -359,11 +384,22 @@ Broadcasts a `user_update` WebSocket message to all clients on success. ```json { "username": "newname", - "avatar": "upload-uuid.png" + "avatar": "https://example.com/pic.png", + "display_name": "New Name", + "about": "A short bio." } ``` -Both fields optional; `avatar` may be `null` to clear it. +| Field | Rules | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `username` | Required. The unique handle; `@mentions` resolve against it. | +| `avatar` | Optional. Must be an `https://` URL (max 512 chars) or `""` to clear. Upload a file instead with `POST /api/v1/users/me/avatar`. | +| `display_name` | Optional, 1–32 characters. Shown instead of `username` everywhere; `""` clears it and falls back to the username. Rejected if it contains control or invisible (bidi-override) characters. | +| `about` | Optional, max 300 characters. `""` clears it. | + +Omitting a field leaves it unchanged; sending `""` clears the nullable ones. +`display_name` and `about` are HTML-sanitized and trimmed server-side, and the +length caps count characters, not bytes. #### Response 200 OK @@ -371,6 +407,62 @@ Returns the updated user object (same shape as `GET /api/v1/auth/me`). --- +### POST /api/v1/users/me/avatar + +Upload an avatar image and point the authenticated user's avatar at it. +Broadcasts a `user_update` on success, exactly like the PATCH above. + +The bytes are stored as an ordinary attachment with no channel, and +`users.avatar` is set to `/api/v1/files/{id}`. That URL is what makes the +picture readable: `GET /api/v1/files/{id}` normally serves an unlinked +attachment only to its uploader, and additionally admits one that some user's +avatar currently points at — so an avatar is readable by every authenticated +user for exactly as long as it is in use, and stops being readable the moment +it is replaced. + +Not registered when the server has no working storage backend. + +**Auth:** Required +**Rate limit:** 5 uploads/minute per user + +#### Request + +`multipart/form-data` with a single `file` part. + +| Rule | Value | +| ---------- | --------------------------------------------------------------------------------------------------------------------- | +| Type | `image/png`, `image/jpeg` or `image/webp`, sniffed from the file's own bytes (the client's `Content-Type` is ignored) | +| Size | 1 MiB | +| Dimensions | 1024x1024, measured from the sniffed image | + +GIF is refused (an animated avatar renders in every message row), and so is +SVG — it is markup with script and external-fetch capability, and an avatar is +rendered inline by definition. The server does not re-encode or crop; the +client is expected to downscale and square-crop before uploading. + +#### Response 201 Created + +```json +{ + "id": "5f2c...", + "filename": "me.png", + "size": 20481, + "mime": "image/png", + "url": "/api/v1/files/5f2c...", + "width": 256, + "height": 256 +} +``` + +#### Errors + +| Status | Code | Cause | +| ------ | -------------- | -------------------------------------------------------------- | +| 400 | `BAD_REQUEST` | Missing `file` part, wrong type, too large, or too many pixels | +| 429 | `RATE_LIMITED` | Too many uploads | + +--- + ### PUT /api/v1/users/me/password Change the authenticated user's password. Verifies the old password, enforces @@ -461,7 +553,10 @@ List all channels the authenticated user has `READ_MESSAGES` permission for. DM "category": "Text Channels", "position": 0, "slow_mode": 0, - "archived": false + "archived": false, + "nsfw": false, + "voice_max_users": 0, + "voice_max_video": 0 } ] ``` @@ -476,6 +571,21 @@ List all channels the authenticated user has `READ_MESSAGES` permission for. DM | `position` | int | Sort order within category | | `slow_mode` | int | Slow-mode delay in seconds (0 = disabled) | | `archived` | bool | Whether the channel is archived | +| `nsfw` | bool | Age-restriction label. **Stored and shipped only** — the server applies no content behaviour to a flagged channel (see below) | +| `voice_max_users` | int | Voice capacity, 0 = unlimited. Enforced on join (`CHANNEL_FULL`) | +| `voice_max_video` | int | Simultaneous cameras/screen shares, 0 = unlimited. Enforced on publish (`VIDEO_LIMIT`) | + +#### The `nsfw` flag + +`nsfw` is metadata and nothing else. The server stores it, ships it in `ready` +and in the `channel_create` / `channel_update` broadcasts, and audits an +operator flipping it — and does **not** filter content, check anyone's age, or +restrict who may read or post in a flagged channel. Every consequence is the +client's: the desktop client shows a one-time-per-session "may contain +sensitive content" gate before rendering a flagged channel's messages +(remembered in `sessionStorage`, so a new session asks again) and marks the +channel in its sidebar. A client that ignores the field behaves exactly as it +did before the field existed. --- @@ -529,13 +639,23 @@ Paginated message history for a channel. "pinned": false, "edited_at": null, "deleted": false, - "timestamp": "2026-03-14T10:30:00Z" + "timestamp": "2026-03-14T10:30:00Z", + "mentions": [7], + "mentions_everyone": false } ], "has_more": true } ``` +`mentions` is the server-resolved list of mentioned user IDs (always present, +empty when the message mentions nobody) and `mentions_everyone` reports an +`@everyone`/`@here` that cleared the `MENTION_EVERYONE` permission. Both are +resolved at send time and re-resolved on edit; an `@word` that matches no +username, or an `@everyone` from a user without the bit, carries no mention +semantics and stays plain text. The same two fields appear on pinned-message +responses and on the WebSocket `chat_message`/`chat_edited` payloads. + #### Pagination Use cursor-based pagination by passing the `id` of the last message as the `before` parameter: @@ -548,6 +668,146 @@ When `has_more` is `false`, you have reached the beginning of the channel histor --- +### GET /api/v1/channels/{id}/messages/around/{messageId} + +The window of channel history centred on one message, for jumping to a message +that is not in the client's loaded page — a search hit, a pinned entry, a reply +reference, or an `owncord://message/{channelId}/{messageId}` permalink. + +**Auth:** Required +**Permission:** `READ_MESSAGES` on the channel (or DM participant membership) — the same gate as `GET /messages` + +#### Query Parameters + +| Param | Type | Default | Range | Description | +| ----- | ---- | ------- | ----- | ----------- | +| `limit` | int | 50 | 1-100 | Total window size, centre included | + +Half the window sits before the centre and the remainder after it: `limit=50` +returns up to 25 older messages, the centre, and up to 24 newer ones. Near the +start or end of a channel the window is simply shorter — it is not re-balanced +toward the other side. + +#### Response 200 OK + +```json +{ + "messages": [], + "has_more_before": true, + "has_more_after": true +} +``` + +`messages` holds the same message objects as `GET /messages` (user, attachments, +reactions with the `me` flag, `mentions`, `mentions_everyone`), but is ordered +**oldest-first**, not newest-first like the paginated history endpoint. + +`has_more_before` / `has_more_after` report whether the channel holds further +live history on each side of the returned window. A client that renders an +around-window is *detached* from the live tail while `has_more_after` is true: +newly broadcast messages belong below the window and are not part of it, so the +client should offer a "jump to present" affordance that refetches the normal +`GET /messages` tail. + +#### Errors + +| Status | Code | When | +|--------|------|------| +| 400 | `BAD_REQUEST` | `id` or `messageId` is not a positive integer, or `limit` is not a positive integer | +| 403 | `FORBIDDEN` | The channel exists but `READ_MESSAGES` is denied | +| 404 | `NOT_FOUND` | The channel does not exist, the caller is not a participant of the DM, or the message does not live in this channel | + +Soft-deleted messages are 404 here, not an empty window: history omits deleted +rows, so there is no row to centre on. Deleted messages are also excluded from +the window itself, exactly as in `GET /messages`. + +--- + +### POST /api/v1/channels/{id}/messages/purge + +Bulk soft-delete the newest messages in a channel. + +**Auth:** Required +**Permission:** `READ_MESSAGES` **and** `MANAGE_MESSAGES` on the channel (per-channel overrides apply) + +Not available in DM channels — a DM has no `MANAGE_MESSAGES` gate, so those +requests are rejected with 403. + +#### Request Body + +```json +{ + "limit": 50, + "before": 1042 +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `limit` | integer | Yes | How many messages to delete, 1--100. Values above 100 are clamped; 0 or negative is a 400. | +| `before` | integer | No | Only delete messages with an id below this one. Omit or `0` to start from the newest. | + +#### Response 200 OK + +```json +{ + "channel_id": 5, + "ids": [1042, 1041, 1040], + "count": 3 +} +``` + +`ids` is newest-first and may hold fewer than `limit` entries when the channel +has less history; already-deleted messages are skipped. Deletion is soft: the +rows stay as tombstones, exactly as with a single delete. A single +[`chat_bulk_deleted`](protocol.md#chat_bulk_deleted-server---client-broadcast) +WebSocket event is broadcast to the channel (not one `chat_deleted` per +message), and one `message_purge` audit entry is written. + +Rate limited to 5/sec per user. + +--- + +### GET /api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users + +List the users who reacted to a message with a specific emoji — the "who +reacted" tooltip behind a reaction pill. + +**Auth:** Required +**Permission:** `READ_MESSAGES` on the channel (DM: participant) + +The reactor list is a separate endpoint rather than `user_ids` inline on every +reaction summary, so message payloads stay small: a busy channel carries dozens +of pills per page and almost none of them are ever hovered. + +`{emoji}` is a path segment and must be percent-encoded (`👍` → `%F0%9F%91%8D`). +The message must belong to `{id}`; a message in another channel is a 404, so the +channel in the URL is always the one the permission check ran against. + +#### Response 200 OK + +```json +{ + "users": [ + { "id": 3, "username": "alice", "avatar": "" }, + { "id": 7, "username": "bob", "avatar": "/api/v1/files/abc123" } + ] +} +``` + +Ordered oldest reaction first and capped at **100** reactors — the list is for a +tooltip, not an audit. `users` is always an array (`[]` when nobody used that +emoji, which is also the answer for an emoji that does not exist). `avatar` is +`""` when the user has none. + +| Status | Error | When | +|--------|-------|------| +| 400 | `BAD_REQUEST` | Non-positive `id`/`messageId`, or an empty / over-32-rune / control-character emoji | +| 403 | `FORBIDDEN` | No `READ_MESSAGES` on the channel | +| 404 | `NOT_FOUND` | Channel or message not found, the message lives in another channel, or a DM the caller is not in | + +--- + ### GET /api/v1/channels/{id}/pins Get all pinned messages for a channel. @@ -614,7 +874,9 @@ Full-text search across messages in channels the user can read. Uses SQLite FTS5 "username": "alex" }, "content": "...matched text...", - "timestamp": "2026-03-14T10:30:00Z" + "timestamp": "2026-03-14T10:30:00Z", + "mentions": [7], + "mentions_everyone": false } ] } @@ -738,12 +1000,31 @@ List all open DM channels for the authenticated user, ordered by most recent act "dm_channels": [ { "channel_id": 100, + "name": "Lunch crew", + "is_group": true, "recipient": { "id": 2, "username": "jordan", - "avatar": "uuid.png", + "display_name": "Jo", + "avatar": "/api/v1/files/uuid", "status": "online" }, + "recipients": [ + { + "id": 2, + "username": "jordan", + "display_name": "Jo", + "avatar": "/api/v1/files/uuid", + "status": "online" + }, + { + "id": 3, + "username": "sam", + "display_name": "", + "avatar": "", + "status": "idle" + } + ], "last_message_id": 5042, "last_message": "Hey, how's it going?", "last_message_at": "2026-03-28T14:30:00Z", @@ -753,16 +1034,120 @@ List all open DM channels for the authenticated user, ordered by most recent act } ``` +| Field | Description | +| ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `recipient` | The other participant of a 1:1 DM. **Backward compatibility only** — for a group it carries the first of `recipients`. | +| `recipients` | Every participant except the caller. What group-aware clients read. | +| `name` | Optional group name; `""` for a 1:1 DM and for an unnamed group. | +| `is_group` | True for a group DM. Stored, not derived from the live participant count. | + +`status` is viewer-adjusted: an `invisible` participant reads as `offline`. + +--- + +### POST /api/v1/dms/group + +Create a group DM between the caller and 2–8 other users (3–10 total). + +Unlike `POST /api/v1/dms` this **always creates**: the same set of people may +reasonably want more than one group, so there is no "the group for these users" +to look up. + +Blocks are enforced in both directions, per recipient — a user may neither pull +someone they have blocked into a room with them nor use a group to reach +someone who has blocked them. The check is creation-time only; see +`docs/protocol.md` § DM Authorization for why sending into a group is not +block-checked. + +**Auth:** Required + +#### Request + +```json +{ + "recipient_ids": [2, 3], + "name": "Lunch crew" +} +``` + +| Field | Type | Required | Description | +| --------------- | ------ | -------- | ------------------------------------------------------------------------------- | +| `recipient_ids` | int[] | Yes | 2–8 other users. De-duplicated; the caller is dropped if named. | +| `name` | string | No | Group name, ≤ 100 characters. HTML-stripped. Omit or `""` for an unnamed group. | + +#### Response 201 Created + +The same DM summary shape `GET /api/v1/dms` returns, from the creator's seat. +Every participant — the creator included — also receives a `dm_channel_open`. + +#### Errors + +| Status | Code | Reason | +| ------ | ------------- | --------------------------------------------------------------------- | +| 400 | `BAD_REQUEST` | Fewer than 2 or more than 8 recipients, or a name over 100 characters | +| 403 | `FORBIDDEN` | A recipient is blocked by, or has blocked, the caller | +| 404 | `NOT_FOUND` | A recipient does not exist | + +--- + +### PATCH /api/v1/dms/{channelId} + +Set or clear a group DM's name. + +Any participant may rename it. That is Discord's rule and the only one that +works here: a group DM has no owner column and no roles, so "who may rename" has +exactly one answer that does not require inventing an ownership model. A 1:1 DM +refuses — its name is who is in it. + +**Auth:** Required (participant) + +#### Request + +```json +{ "name": "Lunch crew" } +``` + +An empty name clears it, and the group falls back to listing its members. + +#### Response 200 OK + +The DM summary shape, from the caller's seat. Every participant also receives a +`dm_channel_open` carrying the new name. + +#### Errors + +| Status | Code | Reason | +| ------ | ------------- | ----------------------------------------------------------- | +| 400 | `BAD_REQUEST` | The channel is a 1:1 DM, or the name exceeds 100 characters | +| 404 | `NOT_FOUND` | Not a participant of this DM | + --- ### DELETE /api/v1/dms/{channelId} -Close a DM channel for the authenticated user (hides it from their sidebar). The channel and messages remain in the database. If the other user sends a new message, the channel is automatically re-opened. +Remove a DM from the caller's sidebar. What that means depends on the kind of DM, +and the route is shared because the _gesture_ is shared: -**Auth:** Required +- **1:1 DM** — a hide. The channel and messages remain, the caller remains a + participant, and a new message from either side re-opens it. +- **Group DM** — a **leave**. The caller comes out of `dm_participants`, stops + receiving the group's messages, and cannot return unaided. When the last + participant leaves, the channel row is deleted (a DM nobody is in is reachable + by nobody, and its messages cascade off the channel). + +The caller receives `dm_channel_close`; after a group leave the remaining +participants receive a fresh `dm_channel_open` with the new membership. + +**Auth:** Required (participant) #### Response 204 No Content +#### Errors + +| Status | Code | Reason | +| ------ | ----------- | ---------------------------- | +| 404 | `NOT_FOUND` | Not a participant of this DM | + --- ## User Blocks @@ -912,6 +1297,126 @@ execute under the app origin (HTML, SVG, XML, PDF) are served with --- +## Custom Emoji + +Server-wide custom emoji, usable as `:shortcode:` in message content and as +reaction strings. + +**Permission model.** Reading the set is open to any authenticated member — +an emoji nobody can render is not an emoji, and the set is server-wide with no +per-channel scope to leak. Adding and removing require **MANAGE_SERVER**. + +That is a deliberate reuse rather than a new permission bit: a bit is a +schema-visible, forever decision, and "who may change server-wide branding" is +exactly what MANAGE_SERVER already answers for the server name, icon and +settings. There is no `MANAGE_EMOJI`. + +### GET /api/v1/emoji + +List every custom emoji, ordered by shortcode. + +**Auth:** Required + +#### Response 200 OK + +```json +[ + { "id": 3, "shortcode": "wave", "url": "/api/v1/emoji/3/image" }, + { "id": 7, "shortcode": "party_blob", "url": "/api/v1/emoji/7/image" } +] +``` + +`url` is server-relative and authenticated — see GET /api/v1/emoji/{id}/image. + +--- + +### POST /api/v1/emoji + +Upload one custom emoji as multipart form data. + +**Auth:** Required — **MANAGE_SERVER** +**Rate limit:** 10 requests/minute per user +**Body size limit:** 1 MiB (the image itself is capped at 512 KiB) +**Content-Type:** `multipart/form-data` + +| Field | Type | Notes | +| ----------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `shortcode` | string | `[a-z0-9_]{2,32}`; surrounding colons are stripped and the value is lowercased before validation, so `:WAVE:` and `wave` are the same shortcode | +| `file` | file | PNG, JPEG, GIF or WebP | + +Validation, in the order it is applied — the permission check runs before the +multipart body is read, so a member without the bit never causes a spool to +disk: + +1. MANAGE_SERVER, then the rate limit, then the shortcode format. +2. At most **512 KiB** of image bytes. +3. The MIME type is **sniffed from the file's own bytes**, never taken from the + client's part header. Only `image/png`, `image/jpeg`, `image/gif` and + `image/webp` are accepted. SVG is refused outright: it is markup with script + and external-fetch capability, and an emoji is by definition rendered inline. +4. Dimensions are re-read from the sniffed image (WebP headers are parsed + directly, since the standard library has no WebP decoder) and must be at + most **128 x 128**. +5. Shortcodes are unique case-insensitively; a collision is `409 CONFLICT`. +6. A server holds at most 200 emoji. + +On success the full set is broadcast as `emoji_update` (see protocol.md), so +every connected client converges without a reconnect. + +#### Response 201 Created + +```json +{ "id": 3, "shortcode": "wave", "url": "/api/v1/emoji/3/image" } +``` + +#### Errors + +| Status | Code | Cause | +| ------ | -------------- | ----------------------------------------------- | +| 400 | `BAD_REQUEST` | bad shortcode, wrong format, too large, too big | +| 403 | `FORBIDDEN` | caller lacks MANAGE_SERVER | +| 409 | `CONFLICT` | an emoji with that shortcode already exists | +| 429 | `RATE_LIMITED` | upload rate limit exceeded | + +--- + +### GET /api/v1/emoji/{id}/image + +Serve one emoji's image bytes. + +**Auth:** Required (Bearer token) +**Caching:** `Cache-Control: private, max-age=86400, immutable` + +Authenticated rather than public so an emoji cannot be used as an +unauthenticated tracking pixel hosted on someone else's server. There is no +per-channel ACL to apply — emoji are server-wide by construction, so +authentication is the whole check. An emoji's bytes never change for a given +id (a replacement is a new row), which is what lets the response be cached +hard. Unknown ids answer 404. + +--- + +### DELETE /api/v1/emoji/{id} + +Delete one custom emoji and unlink its stored file. + +**Auth:** Required — **MANAGE_SERVER** + +Messages and reactions that used the shortcode fall back to rendering the +literal `:shortcode:` text. Broadcasts `emoji_update` on success. + +#### Response 204 No Content + +#### Errors + +| Status | Code | Cause | +| ------ | ------------- | ---------------------------- | +| 400 | `BAD_REQUEST` | id is not a positive integer | +| 403 | `FORBIDDEN` | caller lacks MANAGE_SERVER | +| 404 | `NOT_FOUND` | no emoji with that id | + +--- + ## Health Check ### GET /health @@ -971,10 +1476,343 @@ Runtime server metrics. Restricted to admin-allowed CIDRs. --- +## Admin API Authorization + +The admin panel API lives under `/admin/api` (not `/api/v1`) and takes the same +`Authorization: Bearer {token}` header — a login session or an API token, which +inherits its owning user's role. + +Authorization is two-layered: + +1. **Perimeter.** The request is rejected with `403 FORBIDDEN` unless the + principal's role holds at least one bit of `permissions.AdminPerimeter` + (`ADMINISTRATOR`, `MANAGE_CHANNELS`, `MANAGE_ROLES`, `MANAGE_SERVER`, + `VIEW_AUDIT_LOG`, `KICK_MEMBERS`, `BAN_MEMBERS`, `MUTE_MEMBERS`). Banned + users are rejected here even while their session is still valid. +2. **Per-route bit.** Route groups then require the specific permission below. + `ADMINISTRATOR` bypasses every one of them; owner-only routes gate on role + *position* (`>= 100`) instead of on a bit, so not even `ADMINISTRATOR` + substitutes for being the owner. + +| Route | Requires | +| ----- | -------- | +| `GET /admin/api/me` | perimeter only | +| `GET /admin/api/stats` | perimeter only | +| `GET /admin/api/users` | perimeter only | +| `PATCH /admin/api/users/{id}` | perimeter; `BAN_MEMBERS` for `banned`, `MANAGE_ROLES` for `role_id` (checked in the service) | +| `DELETE /admin/api/users/{id}/sessions` | `KICK_MEMBERS` | +| `GET/POST/PATCH/DELETE /admin/api/channels…` (incl. `/permissions` and `/user-permissions`) | `MANAGE_CHANNELS` | +| `GET/POST/PATCH/DELETE /admin/api/roles…` (incl. `/roles/reorder`) | `MANAGE_ROLES` | +| `GET /admin/api/audit-log` | `VIEW_AUDIT_LOG` | +| `GET/PATCH /admin/api/settings` | `MANAGE_SERVER` | +| `POST /admin/api/logs/ticket`, `GET /admin/api/logs/stream` | `ADMINISTRATOR` | +| `/api/v1/admin/plugins…` | `ADMINISTRATOR` | +| `/admin/api/tokens…`, `/admin/api/backup(s)…`, `/admin/api/updates…` | Owner role (position 100) | + +Moderation routes additionally enforce the **role hierarchy**: the actor must +strictly outrank the target (`actor.position > target.position`), and a role +assignment may only grant a role positioned strictly below the actor's own — +so an admin cannot promote anyone to Owner, and a moderator cannot demote an +admin. Violations return `403 FORBIDDEN`. + +### GET /admin/api/me + +Describes the calling principal so a panel can hide what the role cannot use. +Every route still re-checks its bit server-side. + +#### Response 200 OK + +```json +{ + "id": 7, + "username": "mod", + "role_id": 3, + "role_name": "Moderator", + "role_position": 60, + "permissions": 1048575, + "is_owner": false +} +``` + +--- + +## Role Management + +Create, edit, delete and reorder roles. The whole group requires +`MANAGE_ROLES`; `RoleService` then enforces the hierarchy rules below, so a +principal that clears the bit still cannot escalate through it. + +**Rules, all measured against the *actor's* role position:** + +- You may only create, edit, delete or reorder roles positioned **strictly + below** your own. Equal rank is refused too, so a role cannot rewrite itself. + Nothing sits above position 100, which makes the seeded Owner role + immutable and undeletable for everyone, owner included. +- You may never **grant** a permission bit your own role lacks. Removing one is + allowed — de-escalation is always safe. `ADMINISTRATOR` bypasses this check + entirely (it is what lets the owner hand out anything). +- The default role (`is_default = 1`) cannot be deleted: every member falls + back to it. +- Deleting a role moves its members onto the default role in one `UPDATE`, + drops the role's `channel_overrides` rows, invalidates the moved members' + cached permissions, and broadcasts a `member_update` per member. +- Names are unique **case-insensitively** (migration `023`), matching the + case-insensitive lookup the desktop client does. Max 32 characters. +- Colors are `#rgb` or `#rrggbb`, normalized to uppercase. `""` clears the + color. Anything else is `400`. +- Unknown permission bits are masked off rather than rejected. +- Every mutation writes an audit row (`role_create`, `role_update`, + `role_delete`, `role_reorder`) and broadcasts `roles_update` (see + `docs/protocol.md`) carrying the full new list. + +### GET /admin/api/roles + +Roles ordered by position descending, each with its member count. + +#### Response 200 OK + +```json +[ + { "id": 1, "name": "Owner", "color": "#E74C3C", "permissions": 2147483647, "position": 100, "is_default": false, "member_count": 1 }, + { "id": 4, "name": "Member", "color": null, "permissions": 1635, "position": 40, "is_default": true, "member_count": 12 } +] +``` + +### POST /admin/api/roles + +#### Request + +```json +{ + "name": "Helper", + "color": "#5865F2", + "permissions": 3, + "position": 50 +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | 1–32 characters, unique case-insensitively | +| `color` | string | No | `#rgb`/`#rrggbb`, or `""` for none | +| `permissions` | integer | No | Bitfield; defaults to `0` | +| `position` | integer | No | Defaults to one below the actor's own position | + +#### Response 201 Created + +The created role (`id`, `name`, `color`, `permissions`, `position`, +`is_default` — always `false`; the default role is seeded, never created). + +#### Errors + +| Status | Code | When | +|--------|------|------| +| 400 | `BAD_REQUEST` | Missing/blank/over-long name, duplicate name, bad color, negative position | +| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, position at or above your own, or a permission bit you lack | + +### PATCH /admin/api/roles/{id} + +Partial update — every field is optional and an omitted one is left alone. +Same body and same errors as `POST`, plus `404 NOT_FOUND` for a missing role. +Editing a role at or above your own position is `403`. + +A permission change additionally invalidates the cached permissions of that +role's members and re-syncs their channel visibility (the server sends targeted +`channel_create`/`channel_delete`), because a role's mask is the base every +channel's effective permission derives from. + +#### Response 200 OK + +The updated role. + +### DELETE /admin/api/roles/{id} + +#### Response 204 No Content + +#### Errors + +| Status | Code | When | +|--------|------|------| +| 400 | `BAD_REQUEST` | The role is the default role, or is the seeded Owner role | +| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, or the role is at or above your own position | +| 404 | `NOT_FOUND` | No such role | + +### PATCH /admin/api/roles/reorder + +#### Request + +```json +{ "role_ids": [2, 9, 3, 4] } +``` + +`role_ids` is highest-rank-first and must name **exactly** the set of roles +strictly below your own position — a partial list is refused rather than +silently leaving the omitted roles at positions that now collide. Positions are +normalized to `N…1`, so they stay unique, stay below the actor, and never +collide with the untouched roles above. + +#### Response 200 OK + +The full role list after the reorder, position descending. + +#### Errors + +| Status | Code | When | +|--------|------|------| +| 400 | `BAD_REQUEST` | Wrong number of ids, or a duplicate id | +| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, or an id that is unknown or not below your rank | + +--- + +## Channel Management (admin) + +`POST /admin/api/channels` takes `{name, type, category, topic, position}`; +`PATCH /admin/api/channels/{id}` takes `{name, topic, category, slow_mode, +position, archived, nsfw, voice_max_users, voice_max_video}` and seeds every +omitted field from the current row, so a partial body is safe. + +The numeric fields are bounds-checked before anything is written, and an +out-of-range value is refused with `400 INVALID_INPUT` rather than clamped — +a caller that sent `-1` meant something, and storing `0` would hide it. A +refused body writes nothing at all: + +| Field | Range | Meaning | +|-------|-------|---------| +| `slow_mode` | 0…21600 | Cooldown in seconds; 0 = off (6-hour ceiling, as Discord) | +| `voice_max_users` | 0…99 | Voice capacity; 0 = unlimited | +| `voice_max_video` | 0…99 | Simultaneous cameras/screen shares; 0 = unlimited | + +`nsfw` is a bool and is stored, broadcast and audited only — the server applies +no content behaviour to a flagged channel (see `GET /api/v1/channels`). The +audit detail names the transition: `updated #foo (marked NSFW)` / +`(unmarked NSFW)`, and plain `updated #foo` when the flag did not move. + +The voice limits are stored on a channel of any type but are only meaningful on +a voice one; the desktop client offers them for voice channels alone and omits +the keys entirely elsewhere, so a text-channel edit cannot wipe limits the row +happens to hold. + +`type` must be `text`, `voice` or `announcement` (`400 INVALID_INPUT` +otherwise). **`category` constrains nothing.** Categories are free text and a +channel of any type may live under any of them — a voice channel under +"Gaming", a text channel under "Voice Channels". Grouping is a display concern: +the desktop client groups by whatever category a channel carries and falls back +to a synthetic "Voice" group only for voice channels with no category at all. +(Before phase 5 the server refused any non-voice channel under a category +literally named "Voice Channels", and any voice channel outside it.) + +`PATCH` accepts `category`, so moving a channel between categories is an edit +rather than a delete-and-recreate. An empty string makes it uncategorized. + +--- + +## Channel Permission Overrides + +Two override layers per channel, both gated on `MANAGE_CHANNELS` and both +audit-logged. They resolve in Discord's order: + +``` +base role permissions -> role override -> user override +``` + +The later, narrower layer wins: a **user** deny beats a **role** allow, a user +allow beats a role deny, and within one layer allow beats deny. `ADMINISTRATOR` +bypasses both layers entirely. See `docs/schema.md` ("Permission Checking +Logic") for the formula and `permissions.EffectiveChannelPerms` for the single +implementation. + +Denying `READ_MESSAGES` hides the channel outright — from the WS `ready` +payload, from `GET /api/v1/channels`, from reconnect replay and from live +broadcasts. Every write below invalidates the affected permission cache entries +and then re-syncs connected clients with targeted `channel_create` / +`channel_delete` messages, so sidebars converge without a reconnect. + +DM channels have no override surface: `400 INVALID_INPUT`. + +### GET /admin/api/channels/{id}/permissions + +Both layers for one channel. `roles` lists **every** role (zero masks when it +carries no override) so the panel can render a complete grid; `users` lists +**only** members who actually have an override row. + +#### Response 200 OK + +```json +{ + "channel_id": 4, + "roles": [ + { "role_id": 1, "role_name": "Owner", "position": 100, "permissions": 2147483647, "allow": 0, "deny": 0 }, + { "role_id": 4, "role_name": "Member", "position": 40, "permissions": 1635, "allow": 0, "deny": 514 } + ], + "users": [ + { "user_id": 12, "username": "alice", "role_id": 4, "allow": 2, "deny": 0 } + ] +} +``` + +### PUT /admin/api/channels/{id}/permissions/{roleId} + +### PUT /admin/api/channels/{id}/user-permissions/{userId} + +Write one override row. Same body for both layers: + +```json +{ "allow": 2, "deny": 1 } +``` + +| Field | Type | Description | +|-------|------|-------------| +| `allow` | integer | Bits granted in this channel | +| `deny` | integer | Bits refused in this channel | + +Bits outside `permissions.AllPerms` are masked off rather than rejected, so an +unknown bit can never be persisted. A row with both masks `0` is meaningless — +the admin panel sends `DELETE` for that case instead. + +#### Response 200 OK + +The stored row: `{role_id, role_name, position, permissions, allow, deny}` for +the role layer, `{user_id, username, role_id, allow, deny}` for the user layer. + +#### Cache and fan-out + +- Role layer: `InvalidateAll` (any member of that role is affected), then + `RefreshChannelVisibility`. +- User layer: `InvalidateUser(userId)` only — a per-user override cannot change + anyone else's verdict, and dropping the whole cache for one member would cost + every connected client a repopulate — then `RefreshChannelVisibility`, which + resolves visibility per user through the full order. + +#### Audit + +`channel_perms_update` / `channel_user_perms_update`, target `channel`. + +#### Errors + +| Status | Code | When | +|--------|------|------| +| 400 | `BAD_REQUEST` | Unparseable id or body | +| 400 | `INVALID_INPUT` | The channel is a DM | +| 403 | `FORBIDDEN` | Missing `MANAGE_CHANNELS` | +| 404 | `NOT_FOUND` | Unknown channel, role or user | + +### DELETE /admin/api/channels/{id}/permissions/{roleId} + +### DELETE /admin/api/channels/{id}/user-permissions/{userId} + +Clear the override row, returning the target to the layer above it. `204 No +Content`; deleting a row that does not exist is a no-op, not a `404`. Same +cache/fan-out behavior as the writes; audits as `channel_perms_clear` / +`channel_user_perms_clear`. + +--- + ## Plugin Administration Manage WASM plugins. These endpoints sit behind **both** the admin IP -restriction (allowed CIDRs) **and** admin bearer-token authentication. +restriction (allowed CIDRs) **and** admin bearer-token authentication, and +require the `ADMINISTRATOR` bit specifically (the widened admin perimeter does +not open them). Plugin execution additionally requires a server built with `-tags wazero` and `plugins.enabled: true` in config. diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 423a449b..15bc60f8 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -31,6 +31,8 @@ erDiagram messages ||--o{ attachments : "message_id" messages ||--o{ reactions : "message_id" users ||--o{ reactions : "user_id" + messages ||--o{ message_mentions : "message_id" + users ||--o{ message_mentions : "mentioned_user_id" users ||--o{ read_states : "user_id" channels ||--o{ read_states : "channel_id" @@ -98,7 +100,7 @@ erDiagram | Domain | Tables | Notes | |--------|--------|-------| | Identity & access | `roles`, `users`, `sessions`, `channel_overrides`, `user_blocks`, `invites`, `login_attempts`, `rate_lockouts` | Sessions store only SHA-256 token hashes. Permissions are a bitfield on `roles.permissions`; channel overrides use Discord semantics `(role &^ deny) \| allow`. `rate_lockouts` (011) persists rate-limiter lockouts across restarts. | -| Messaging | `channels`, `messages`, `attachments`, `reactions`, `read_states`, `emoji` | `channels.type` is constrained to `text \| voice \| announcement \| dm` by INSERT/UPDATE triggers (migration 013, extended by 016 to allow `announcement`). Announcement channels read like text but require `MANAGE_MESSAGES` to post. `attachments.uploader_id` (010) backs upload-ownership checks. | +| Messaging | `channels`, `messages`, `attachments`, `reactions`, `read_states`, `message_mentions`, `emoji` | `message_mentions` (022) stores server-resolved `@username` mentions per message; `messages.mentions_everyone` flags an authorized `@everyone`/`@here`, and `read_states.mention_count` is the per-user unread badge those two drive. `channels.type` is constrained to `text \| voice \| announcement \| dm` by INSERT/UPDATE triggers (migration 013, extended by 016 to allow `announcement`). Announcement channels read like text but require `MANAGE_MESSAGES` to post. `attachments.uploader_id` (010) backs upload-ownership checks. | | Direct messages | `dm_participants`, `dm_open_state` | DMs are `channels` rows with `type='dm'`; these tables track membership and per-user open/closed UI state (009). | | Voice | `voice_states` | One row per user (`user_id` is the PK) — a user occupies at most one voice channel. | | Real-time replay | `events` | Cold tier of the 3-tier reconnect replay ([websocket.md](websocket.md)); written by the async `EventPersister`, pruned by retention. Hub seq counter is seeded from `MAX(events.seq)` at startup so seqs stay monotonic across restarts. | diff --git a/docs/architecture/ux/messaging.md b/docs/architecture/ux/messaging.md index ef990fe0..149ae571 100644 --- a/docs/architecture/ux/messaging.md +++ b/docs/architecture/ux/messaging.md @@ -155,6 +155,16 @@ so surrounding context and reply references stay intact. > (`messages.store.ts:282`); there is no local optimistic toggle. Target adds the > optimistic toggle for immediacy, consistent with §3. +**Who reacted (✓ implemented 2026-08):** hovering (or focusing) a reaction pill +for 300 ms fetches the reactor list and shows a tooltip reading +*"alice, bob, carol and 4 others reacted with 👍"*. The debounce mirrors +`lib/streamPreview.ts` so a pointer crossing a row of pills fires no requests. +The list comes from `GET /channels/{id}/messages/{messageId}/reactions/{emoji}/users` +(oldest first, capped at 100 server-side) and is cached per message+emoji in +`message-list/reaction-tooltip.ts`; a `reaction_update` for that message evicts +every one of its lists, since the event names only the emoji that changed. +Usernames are inserted as text nodes — never markup. + --- ## 6. Attachments @@ -175,6 +185,26 @@ Upload goes through `POST /uploads` (multipart). **✓ Implemented (2026-07):** calls `onUnauthorized` (clearAuth → connect page with "Your session expired — sign in again.") and throws `ApiClientError(401)`. +**Inline players (✓ implemented 2026-08):** a received attachment renders by MIME +family, not as a download chip for everything but images: + +| MIME | Rendering | +|------|-----------| +| `image/*` except `image/svg+xml` | Inline `` (existing) | +| `video/mp4`, `video/webm`, `video/ogg` | Inline `