Files
OwnCord/Server/admin/handlers_channels.go
T
J3vbandClaude Opus 5 ea0430c5b0 fix: batch of correctness fixes across server and client (#1375)
* fix(client): 1 defect(s) (OC-0201)

* fix(service): 1 defect(s) (OC-0202)

HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.

* fix(client): 2 defect(s) (OC-0203, OC-0224)

* fix(server): 1 defect(s) (OC-0204)

* fix(ws): 2 defect(s) (OC-0205, OC-0211)

* fix(admin): 2 defect(s) (OC-0209, OC-0212)

* fix(client): 1 defect(s) (OC-0210)

* fix(db): 1 defect(s) (OC-0213)

* fix(ws): 1 defect(s) (OC-0214)

Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.

* fix(admin): 1 defect(s) (OC-0215)

PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.

* fix(db): 1 defect(s) (OC-0216)

LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.

* fix(emoji): 1 defect(s) (OC-0217)

* fix(client): 1 defect(s) (OC-0218)

The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.

Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.

Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.

Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* fix(ws): 1 defect(s) (OC-0219)

* fix(client): 1 defect(s) (OC-0221)

UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().

* fix(dm): 1 defect(s) (OC-0222)

* fix(client): 1 defect(s) (OC-0223)

* fix(voice): 1 defect(s) (OC-0225)

The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().

* fix(admin): 1 defect(s) (OC-0226)

handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.

* fix(admin): 1 defect(s) (OC-0227)

PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.

* fix(identity): 1 defect(s) (OC-0228)

* fix(admin): run deferred cleanup before the update restart exits

The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.

applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.

Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* test(ws): pin the live presence path against the invisible custom-status leak

OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.

This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* fix(ws): 1 defect(s) (OC-0206)

* test(ws): silence a contextcheck false positive in the reconnect race test

RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.

golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 16:30:05 +02:00

348 lines
13 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"`
}
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
}
ch, err := database.GetChannel(r.Context(), 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(context.WithoutCancel(r.Context()), 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)"
}
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
}
actor := actorFromContext(r)
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%s", req.Name, nsfwAuditSuffix(existing.NSFW, req.NSFW)))
updated, err := database.GetChannel(r.Context(), 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)
}
if err := database.AdminDeleteChannel(r.Context(), id); err != nil {
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(context.WithoutCancel(r.Context()), 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)
}
}