mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Fixes all 109 golangci-lint findings (106 contextcheck, 1 gocritic,
2 gosec) that accumulated after D2 wired dbgen (whose queries take ctx)
under ctx-less db.DB wrappers while CI lint was quota-dead. No nolint
comments added; every finding fixed by genuinely threading context.
- db: all 138 hand-written db.DB methods take ctx first; the dbCtx()
Background shim is deleted; raw Query/QueryRow/Exec/Begin use their
Context variants; the four redundant ctx-less passthroughs removed.
db.Auditor/WriteAudit gain ctx.
- Seams: permissions.Checker (DB iface, HasChannelPerm,
RequireChannelAccess) and the service.Store interface mirror the new
signatures (ws.EventStore and plugin.PluginStore already did).
- Callers: api/admin handlers use r.Context(); ws per-message paths use
the connection ctx via DispatchV2; hub loops and startup wiring use
context.Background(); service methods thread ctx where they have one
and Background where no ctx exists. Public service surface reached by
ctx-holding chains (PermissionService.HasChannelPerm/GetRoleForUser/
RequireChannelAccess, message/dm/block/invite/profile methods) is now
ctx-first.
- Detached (context.WithoutCancel) where cancellation would break an
invariant, found by a 3-lens adversarial review of the diff:
* voice-leave background retries (a dead webhook/connection ctx killed
retry 2 before it ran, leaving ghost capacity-holding voice rows)
* rollbackVoiceJoin's compensating delete (its trigger IS the cancel)
* post-2FA-change DeleteOtherSessions and logout DeleteSession (the
security tail of a committed change must not die with the request)
* all api/ws audit writes (a banned user could suppress their own
login_blocked_banned row by aborting the request mid-bcrypt)
* admin backup VACUUM INTO (an interrupt left a truncated .db that
the backup list presented as restorable)
* post-commit message/edit refetches (a committed message must still
fan out when the sender disconnects)
* hub settings-cache refresh (one dead connection could pin stale
values for the 30s TTL)
- gocritic rangeValCopy fixed (index iteration); gosec G306 excluded in
config with justification (generated source must stay world-readable)
instead of flipping genprotocol output to 0o600.
Verified: gofmt/vet, all four build-tag variants, full suite, deadlock
pass, full -race pass, golangci-lint 0 issues uncapped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
236 lines
7.4 KiB
Go
236 lines
7.4 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/owncord/server/db"
|
|
)
|
|
|
|
// ─── Category-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",
|
|
}
|
|
|
|
// 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 == "" {
|
|
return ""
|
|
}
|
|
allowed := allowedChannelTypes(category)
|
|
for _, t := range allowed {
|
|
if t == 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"
|
|
}
|
|
|
|
// ─── Channel Handlers ────────────────────────────────────────────────────────
|
|
|
|
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
|
|
}
|
|
writeJSON(w, http.StatusOK, channels)
|
|
}
|
|
}
|
|
|
|
// 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 := validateCategoryType(req.Type, req.Category); 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)
|
|
}
|
|
}
|
|
|
|
// updateChannelRequest is the JSON body for PATCH /admin/api/channels/{id}.
|
|
type updateChannelRequest struct {
|
|
Name string `json:"name"`
|
|
Topic string `json:"topic"`
|
|
SlowMode int `json:"slow_mode"`
|
|
Position int `json:"position"`
|
|
Archived bool `json:"archived"`
|
|
}
|
|
|
|
func handlePatchChannel(database *db.DB, hub HubBroadcaster) 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 channel id")
|
|
return
|
|
}
|
|
|
|
existing, err := database.GetChannel(r.Context(), id)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
|
|
return
|
|
}
|
|
if existing == nil {
|
|
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
|
|
return
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
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 {
|
|
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)
|
|
db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_update", "channel", id,
|
|
fmt.Sprintf("updated #%s", req.Name))
|
|
|
|
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)
|
|
}
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
}
|
|
|
|
func handleDeleteChannel(database *db.DB, hub HubBroadcaster) 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 channel id")
|
|
return
|
|
}
|
|
|
|
existing, err := database.GetChannel(r.Context(), id)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
|
|
return
|
|
}
|
|
if existing == nil {
|
|
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
|
|
return
|
|
}
|
|
|
|
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)
|
|
offset := queryInt(r, "offset", 0, 0)
|
|
|
|
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)
|
|
}
|
|
}
|