mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(ws): 1 defect(s) (OC-0001) * fix(db): 1 defect(s) (OC-0002) sanitizeFTSQuery filtered only characters, so FTS5's bareword boolean keywords (AND, OR, NOT) reached MATCH as operators; a query in an invalid operator position raised "fts5: syntax error" instead of returning results. Drop those bareword tokens after sanitizing. * fix(dm): 1 defect(s) (OC-0004) * fix(ws): 1 defect(s) (OC-0005) * fix(voice): 1 defect(s) (OC-0006) Count the shared voice_max_video budget in streams rather than rows: a single user publishing both camera and screenshare consumed one slot while producing two live streams, letting a channel over-admit up to 2N streams against an N-stream cap. * fix(client): 2 defect(s) (OC-0007, OC-0009) OC-0007: mark the active channel loading before invalidating its message window on a full-ready resync, so MessageList shows the spinner instead of the empty-channel state for the duration of the refetch. OC-0009: fan USER_UPDATE renames out to voiceStore.voiceUsers, which keeps its own frozen username copy, so the voice roster no longer shows a stale name for the rest of the call. * fix(admin): 1 defect(s) (OC-0010) * fix(identity): 1 defect(s) (OC-0011) * fix(ws): 1 defect(s) (OC-0003) The public half of an invisible user's presence (PresenceOthersEvent, and BroadcastPresence's own mapped payload) went out via broadcastExcludeLow on the low-priority queue - the ephemeral, unsequenced, drop-on-overflow transport built for typing indicators - while every other source of the same user's presence shares the normal-priority queue. That split one user's presence across two per-client FIFOs with different durability and different drain order (writePump drains normal strictly before low), so a frame could land out of order against a later connect/disconnect presence frame, or be silently dropped with no replay recovery. Adds Hub.BroadcastToAllExcept, which routes through the same h.broadcast channel and seqMu-serialized deliverBroadcast as BroadcastToAll, carrying an excludeUserID that deliverBroadcast applies via pubsub.Publish(TopicGlobal, msg, excludeUserID). * fix(ws): 1 defect(s) (OC-0008) --------- Co-authored-by: Claude <noreply@anthropic.com>
368 lines
14 KiB
Go
368 lines
14 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|