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>
167 lines
4.7 KiB
Go
167 lines
4.7 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/owncord/server/db"
|
|
)
|
|
|
|
// ─── Settings Handlers ──────────────────────────────────────────────────────
|
|
|
|
func handleGetSettings(database *db.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
settings, err := database.GetAllSettings(r.Context())
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
}
|
|
|
|
func handlePatchSettings(database *db.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var updates map[string]string
|
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Validate all keys against the whitelist before writing anything so
|
|
// the operation is atomic from the caller's perspective.
|
|
for key := range updates {
|
|
if _, ok := allowedSettingKeys[key]; !ok {
|
|
writeErr(w, http.StatusBadRequest, "BAD_REQUEST",
|
|
fmt.Sprintf("unknown setting key: %q", key))
|
|
return
|
|
}
|
|
}
|
|
|
|
normalizedUpdates, err := normalizeSettingUpdates(updates)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
|
return
|
|
}
|
|
|
|
if err := validateRequire2FAUpdate(r.Context(), database, normalizedUpdates); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
|
return
|
|
}
|
|
|
|
actor := actorFromContext(r)
|
|
|
|
// Apply all settings atomically so a mid-loop failure doesn't leave
|
|
// partial updates.
|
|
tx, err := database.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to start transaction")
|
|
return
|
|
}
|
|
for key, value := range normalizedUpdates {
|
|
if _, txErr := tx.ExecContext(r.Context(),
|
|
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
key, value,
|
|
); txErr != nil {
|
|
_ = tx.Rollback()
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update setting: "+key)
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to commit settings")
|
|
return
|
|
}
|
|
for key := range normalizedUpdates {
|
|
slog.Info("setting changed", "actor_id", actor, "key", key)
|
|
db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "setting_change", "setting", 0,
|
|
fmt.Sprintf("%s updated", key))
|
|
}
|
|
|
|
settings, err := database.GetAllSettings(r.Context())
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch settings")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
}
|
|
|
|
func normalizeSettingUpdates(updates map[string]string) (map[string]string, error) {
|
|
normalized := make(map[string]string, len(updates))
|
|
for key, value := range updates {
|
|
normalized[key] = value
|
|
switch key {
|
|
case "require_2fa", "registration_open":
|
|
parsed, err := parseBooleanSettingValue(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
if parsed {
|
|
normalized[key] = "1"
|
|
} else {
|
|
normalized[key] = "0"
|
|
}
|
|
}
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func validateRequire2FAUpdate(ctx context.Context, database *db.DB, updates map[string]string) error {
|
|
targetRequire2FA, err := targetBoolSetting(ctx, database, updates, "require_2fa")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !targetRequire2FA {
|
|
return nil
|
|
}
|
|
|
|
registrationOpen, err := targetBoolSetting(ctx, database, updates, "registration_open")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if registrationOpen {
|
|
return fmt.Errorf("require_2fa cannot be enabled while registration is open")
|
|
}
|
|
|
|
count, err := database.CountUsersWithoutTOTP(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to validate 2FA enrollment")
|
|
}
|
|
if count > 0 {
|
|
return fmt.Errorf("require_2fa cannot be enabled until all users have 2FA enabled")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func targetBoolSetting(ctx context.Context, database *db.DB, updates map[string]string, key string) (bool, error) {
|
|
if value, ok := updates[key]; ok {
|
|
return parseBooleanSettingValue(value)
|
|
}
|
|
value, err := database.GetSetting(ctx, key)
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return parseBooleanSettingValue(value)
|
|
}
|
|
|
|
func parseBooleanSettingValue(value string) (bool, error) {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "1", "true":
|
|
return true, nil
|
|
case "0", "false":
|
|
return false, nil
|
|
default:
|
|
return false, fmt.Errorf("invalid boolean value %q", value)
|
|
}
|
|
}
|