Files
OwnCord/Server/admin/handlers_channels.go
T
J3vbandClaude Fable 5 d880b64d64 test: audit 2026-08-19 — fix stale tests, close coverage gaps (#1397)
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes

* test(server): api/constants — test-audit 2026-08-19 fixes

* test(server): api/middleware — test-audit 2026-08-19 fixes

* test(server): api/waf — test-audit 2026-08-19 fixes

* test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes

* test(server): db/session/expiry/test — test-audit 2026-08-19 fixes

* test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes

* test(server): updater/download — test-audit 2026-08-19 fixes

* test(server): ws/handlers_command — test-audit 2026-08-19 fixes

* test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes

* test(server): ws/hub/events — test-audit 2026-08-19 fixes

* test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes

* test(server): ws/voice/controls — test-audit 2026-08-19 fixes

* test(server): ws/voice/join — test-audit 2026-08-19 fixes

* test(server): ws/voice/moderation — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes

* test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes

* test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes

* test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes

* test(client): keep tests/browser out of the jsdom suite and run it in CI

* test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* docs: test audit 2026-08-19 — findings, fixes, measured baselines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(graph): refresh the knowledge graph after the 2026-08-19 test audit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:29:31 +02:00

412 lines
16 KiB
Go

package admin
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"slices"
"strings"
"github.com/owncord/server/db"
)
// ─── Channel Type Validation ─────────────────────────────────────────────────
// 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"}
// 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 ""
}
return "type must be one of text, voice, announcement"
}
// ─── Channel Handlers ────────────────────────────────────────────────────────
// getAdminChannel loads the channel targeted by an admin channel mutation and
// writes the error response when it is missing — or when it is a DM. DMs and
// group DMs share the channels table and id space with guild channels, but
// they belong to their participants, not to MANAGE_CHANNELS holders: listing,
// renaming or deleting one from the admin surface would leak or destroy a
// private conversation (A-2026-08-02). A DM id answers 404 rather than 403 so
// the surface does not confirm which ids are private conversations. Returns
// nil when a response has already been written.
func getAdminChannel(database *db.DB, w http.ResponseWriter, r *http.Request) *db.Channel {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
return nil
}
ch, err := database.GetChannel(r.Context(), id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
return nil
}
if ch == nil || ch.Type == "dm" {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
return nil
}
return ch
}
func handleListChannels(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
channels, err := database.ListChannels(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channels")
return
}
// The admin surface manages guild channels. DM rows live in the same
// table but are private conversations — enumerating them here exposed
// ids and user-chosen group names to any MANAGE_CHANNELS holder
// (A-2026-08-02). Filtered in Go because the sqlc ListChannels query is
// shared with the ready path, which applies its own visibility rules.
guildChannels := make([]db.Channel, 0, len(channels))
for i := range channels {
if channels[i].Type != "dm" {
guildChannels = append(guildChannels, channels[i])
}
}
writeJSON(w, http.StatusOK, guildChannels)
}
}
// createChannelRequest is the JSON body for POST /admin/api/channels.
type createChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Topic string `json:"topic"`
Position int `json:"position"`
}
// createChannelPostCommitHook, when non-nil, runs synchronously right after
// handleCreateChannel's AdminCreateChannel commit, before the post-commit
// re-read and hub fan-out — the create-side twin of
// patchChannelPostCommitHook, so tests can land a caller cancellation in that
// exact window (OC-0158) instead of relying on wall-clock timing.
var createChannelPostCommitHook func()
func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req createChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
if strings.TrimSpace(req.Name) == "" {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "name is required")
return
}
if req.Type == "" {
req.Type = "text"
}
if msg := validateChannelType(req.Type); msg != "" {
writeErr(w, http.StatusBadRequest, "INVALID_INPUT", msg)
return
}
id, err := database.AdminCreateChannel(r.Context(), req.Name, req.Type, req.Category, req.Topic, req.Position)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create channel")
return
}
// From here on the row has already committed. If the admin's browser
// goes away in this window (tab close, navigation, network blip),
// r.Context() cancels, and a GetChannel re-read that still used it
// would fail with context.Canceled — 500ing while leaving a durably
// created channel unbroadcast, so no connected client learns about it
// until it reconnects (OC-0158). Run the rest of the handler on an
// uncancellable tail, matching handlePatchChannel and
// handleDeleteChannel.
tail := context.WithoutCancel(r.Context())
if createChannelPostCommitHook != nil {
createChannelPostCommitHook()
}
ch, err := database.GetChannel(tail, id)
if err != nil || ch == nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch created channel")
return
}
actor := actorFromContext(r)
slog.Info("channel created", "actor_id", actor, "channel", req.Name, "type", req.Type)
db.WriteAudit(tail, database, actor, "channel_create", "channel", id,
fmt.Sprintf("created #%s (%s)", req.Name, req.Type))
if hub != nil {
hub.BroadcastChannelCreate(ch)
}
writeJSON(w, http.StatusCreated, ch)
}
}
// 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 strings.TrimSpace(r.Name) == "":
return "name is required"
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)"
}
// patchChannelPostCommitHook, when non-nil, runs synchronously right after
// handlePatchChannel's AdminUpdateChannel commit, before the post-commit
// re-read and hub fan-out. It exists so tests can deterministically simulate
// a caller cancellation (browser tab close, network blip) landing in that
// exact window — the race OC-0158 is about — instead of relying on
// wall-clock timing to hit it.
var patchChannelPostCommitHook func()
func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
existing := getAdminChannel(database, w, r)
if existing == nil {
return
}
id := existing.ID
// Start from existing values so a partial body is safe.
req := updateChannelRequest{
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 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
}
// From here on the update has already committed. If the admin's
// browser goes away in this window (tab close, navigation, network
// blip), r.Context() cancels, and a GetChannel re-read that still
// used it would fail with context.Canceled — 500ing while leaving
// the commit (including a fresh archived=1) unbroadcast, its voice
// eviction and visibility fan-out skipped, and every connected
// client still showing the stale state until it reconnects
// (OC-0158). Run the rest of the handler on an uncancellable tail,
// matching handleDeleteChannel's delCtx (OC-0010).
tail := context.WithoutCancel(r.Context())
if patchChannelPostCommitHook != nil {
patchChannelPostCommitHook()
}
actor := actorFromContext(r)
slog.Info("channel updated", "actor_id", actor, "channel_id", id, "name", req.Name, "nsfw", req.NSFW)
db.WriteAudit(tail, database, actor, "channel_update", "channel", id,
fmt.Sprintf("updated #%s%s", req.Name, nsfwAuditSuffix(existing.NSFW, req.NSFW)))
updated, err := database.GetChannel(tail, id)
if err != nil || updated == nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated channel")
return
}
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 {
// Archiving hides a voice channel the same way deleting it
// does — nobody can see it or reach it afterward — so live
// participants must be evicted the same way handleDeleteChannel
// evicts them, or they keep their DB row, VoiceTopic
// subscription and LiveKit session in a room nothing shows.
// Order matches handleDeleteChannel: evict before the
// visibility change so a voice_leave lands on clients that
// still have the channel subscribed.
if !existing.Archived && updated.Archived {
hub.CleanupVoiceForChannel(id)
}
hub.RefreshChannelVisibility(updated)
}
}
writeJSON(w, http.StatusOK, updated)
}
}
func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
existing := getAdminChannel(database, w, r)
if existing == nil {
return
}
id := existing.ID
// Mark the channel archived BEFORE evicting participants, mirroring the
// archive path (handlePatchChannel): CleanupVoiceForChannel snapshots
// voice participants ONCE, up front, so a voice_join racing this delete
// could otherwise read the still-live channel row, pass the archived
// gate (ws/voice_join.go), and insert a voice_states row after the
// snapshot but before AdminDeleteChannel's cascade — leaving that
// joiner's hub-side voice state and LiveKit session orphaned with no
// DB row left for any sweep to find (OC-0035). Persisting archived=1
// first makes voice_join's existing archived check refuse that join
// outright, the same way it already refuses one racing an archive.
if !existing.Archived {
if err := database.AdminUpdateChannel(r.Context(), id, db.ChannelUpdate{
Name: existing.Name,
Topic: existing.Topic,
Category: existing.Category,
SlowMode: existing.SlowMode,
Position: existing.Position,
Archived: true,
NSFW: existing.NSFW,
VoiceMaxUsers: existing.VoiceMaxUsers,
VoiceMaxVideo: existing.VoiceMaxVideo,
}); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel")
return
}
}
// Evict voice participants BEFORE deleting the row: the voice_states
// FK cascade wipes the rows the cleanup reads, and the stale sweeper
// cannot recover participants of a channel that no longer exists.
if hub != nil {
hub.CleanupVoiceForChannel(id)
}
// From here on the archive (and, if it ran, the voice eviction)
// already committed. If the admin's browser goes away in this window
// (tab close, navigation, network blip), r.Context() cancels, and an
// AdminDeleteChannel that still used it would fail with
// context.Canceled — 500ing while leaving the channel silently
// archived, unbroadcast and unaudited (OC-0010). Run the rest of the
// delete on an uncancellable tail, matching the repo's convention
// for other durable side effects (totp_handler.go, service/user.go).
delCtx := context.WithoutCancel(r.Context())
if err := database.AdminDeleteChannel(delCtx, id); err != nil {
// A genuine failure here (not caller cancellation, which delCtx
// already absorbs) still leaves the archive committed — tell
// connected clients about the state that did change instead of
// leaving them stuck seeing a live channel that now 403s on
// every read and write.
if hub != nil && !existing.Archived {
if archived, gErr := database.GetChannel(delCtx, id); gErr == nil && archived != nil {
hub.BroadcastChannelUpdate(archived)
hub.RefreshChannelVisibility(archived)
}
}
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel")
return
}
actor := actorFromContext(r)
slog.Warn("channel deleted", "actor_id", actor, "channel_id", id, "name", existing.Name)
db.WriteAudit(delCtx, database, actor, "channel_delete", "channel", id,
fmt.Sprintf("deleted #%s", existing.Name))
if hub != nil {
hub.BroadcastChannelDelete(id)
}
w.WriteHeader(http.StatusNoContent)
}
}
func handleGetAuditLog(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 50, 1, 500)
offset := queryInt(r, "offset", 0, 0, math.MaxInt32)
entries, err := database.GetAuditLog(r.Context(), limit, offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get audit log")
return
}
writeJSON(w, http.StatusOK, entries)
}
}