Files
OwnCord/Server/api/router.go
T
J3vbandClaude Opus 5 39551de4a6 refactor(server): work off the complexity backlog — 62 findings to 0 (#1389)
* refactor(ws): split handleVoiceJoin into cohesive join-stage helpers

handleVoiceJoin was 130 statements / cyclomatic 59 / nestif 11, breaking all
three complexity budgets at once. Split along the stage boundaries the doc
comment already described: precheck, leave-current, persist, restore
moderator flags, grant token, complete. The publish-permission derivation
becomes its own helper because it is the one branch-heavy block inside the
token grant.

Pure move: every statement is preserved verbatim. The only edits are bare
`return`s becoming the typed returns of their new helper, `c.userID` becoming
the `userID` parameter inside voiceJoinPublishPerms, and voiceJoinComplete
re-reading `ch.VoiceMaxUsers` instead of receiving it — `ch` is never mutated,
so the value is identical.

Verified by normalising both revisions of the region to sorted, comment- and
whitespace-stripped statements and diffing: the only deltas are the ones
listed above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: collapse the three duplicated sibling pairs

dupl flagged three pairs of adjacent near-identical functions. Each pair is
now one parameterised implementation plus two thin, still-greppable wrappers.

- ws/voice_controls.go: handleVoiceMuteV2 / handleVoiceDeafenV2 share
  voiceSelfToggleV2; handleVoiceCameraV2 / handleVoiceScreenshareV2 share
  voiceStreamToggleV2. Camera and screenshare drawing from one
  voice_max_video budget (OC-0023) was a bug caused by exactly this
  duplication drifting, so one body is the point, not a side effect.
- db/mention_queries.go: ListMentionTargetsByRoles / ListMentionTargetsByUserIDs
  share listMentionTargets. The matched column is a closed named type
  (mentionTargetColumn) rather than a bare string, so the value interpolated
  into the SELECT cannot become caller-supplied.

Behaviour is unchanged: every rate-limit key, error code, error string, slog
message and slog key is preserved verbatim, including the two "failed to
update <kind> state" messages, which are now assembled the same way
enableVideoSlot already assembled them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(api): extract readEmojiUpload from handleCreateEmoji

handleCreateEmoji was 101 lines against a 100-line budget. The upload-bytes
stage — pull the file out of the parsed form, cap its size, sniff its MIME
type and sniff its dimensions — is the one self-contained block in it, and it
already wrote its own refusals, so it moves out whole as readEmojiUpload.

The permission-before-parse ordering the doc comment calls out is unchanged;
so is every error string. file.Close() now runs when the helper returns
rather than when the handler does, which is strictly earlier and unobservable:
the bytes are already copied into raw and nothing else touches the handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: extract one cohesive block from three single-budget offenders

Each of these was over exactly one budget, so each gets exactly one extraction
rather than a restructure:

- api/totp_handler.go handleVerifyTOTP (102 lines / 100): the block that
  resolves the user behind the partial-auth challenge and decrypts their TOTP
  secret becomes totpChallengeSecret. The ban-inside-the-partial-window check
  moves with it.
- service/message_reactions.go handleReaction (cyclop 21 / 20): the whole
  authorisation chain — channel lookup, archived gate, DM participant and
  block checks, non-DM permission check — becomes reactionAudience, which
  also returns the DM fan-out audience it already resolved. Check order is
  unchanged and load-bearing.
- db/admin_queries.go BackupToSafe (cyclop 21 / 20): the character allowlist
  loop and the SQL-comment rejection become validateBackupPathChars. That
  loop alone was most of the branch count.

No error string, no check and no ordering changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(plugin): split InstallFromZip into staged install helpers

104 statements / cyclomatic 44 / nestif 12. Split along the stages the code
already had: installZipExtract (the per-entry write loop, with
installZipEntryDest holding the mode/symlink/zip-slip guard chain and
installZipWriteEntry the size-capped copy), installZipStagedManifest,
installZipPromote, and installZipReactivate for the :399 nested block.

Every zip-slip, symlink, entry-mode and uncompressed-size check is preserved
in the same order relative to the writes it guards. The 19 inline
`cleanup(); return` sites collapse to 4 in the orchestrator, one per stage,
because each helper now returns an error instead of unwinding itself — the
staging directory is still removed on exactly the same set of failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(api): split newWAFMiddleware into engine build and per-phase helpers

184 lines / cyclomatic 38, and the request-body block at :382 was the worst
nested site in the tree at nestif 17.

Engine construction moves out of the closure (wafInlineEngine, wafCRSEngine —
the Coraza directive string is lifted verbatim), and each request phase
becomes its own helper: wafInlineRequestHeaders, wafCRSRequestHeaders
(including the Host/Transfer-Encoding re-add for CRS 920280), wafFeedCRSBody
and wafInspectRequestBody, which is the old :382 block.

The three `handleWAFInterruption(w, it); return` sites inside the body block
become one: the helper now returns the interruption and the orchestrator
handles it. No statement runs between the two points on either side, so the
verdict is honoured identically — in particular a CRS body interruption still
returns without replacing r.Body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(service): split SendMessage and lift EditMessage's access check

SendMessage was 79 statements / cyclomatic 35 with an 11-deep nested
attachment block at :101; EditMessage was one point over cyclop.

SendMessage becomes sendMessagePrecheck (permission and DM-block gates,
content sanitisation), sendMessageLinkAttachments (the :101 block: attachment
ownership, claim and link) and sendMessageDMSideEffects. EditMessage gets
editMessageCheckAccess and nothing else — one budget over earns one
extraction.

The sanitizeContent fixpoint and the attachment ownership check are unchanged,
as is the order of every gate. The DM side effects run behind
`isDM && !s.sendMessageDMSideEffects(...)`, so a non-DM never enters them;
inside, only the GetDMParticipantIDs failure returns false, matching the one
error the original early-returned on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(admin): split handlePatchUser into per-field apply helpers

106 lines / cyclomatic 29, with the ban block at :154 nested 9 deep.

Each optional field of the partial edit becomes its own helper —
patchUserPrecheck, patchUserAuthorizeRole, patchUserApplyBan (the :154 block,
including the session disconnect and the broadcast) and patchUserApplyRole.
Each returns a bool meaning "keep going"; none of them writes a success
response, so the single response site in the orchestrator is unchanged.

Field application order, the permission-cache invalidation on a role change
and the disconnect-and-broadcast on a ban are all preserved, as are the three
fail-closed `mod == nil` guards, which now sit at the top of their own helper
and still fire on exactly the same conditions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(admin): split handleSetup into first-run setup stages

143 lines / cyclomatic 30, with the optional-wizard block at :219 sitting
exactly on the nestif threshold.

Split into the stages the endpoint already had: request gating (rate limit and
origin check, which run before any auth exists on a fresh server), owner
account creation, and the wizard application that was the :219 block.

Every gate in front of the handler is a security control on an unauthenticated
endpoint; none moved relative to the work it protects. setup_wizard.go is
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: split run() into named bootstrap and shutdown steps

131 statements / cyclomatic 57, with the executable-path fallback at :126
nested 9 deep.

The five anonymous `defer func(){...}()` blocks become named functions —
telemetryStop, runClosePlugins, runStopEventPersistence, runStopAuditWriter,
maintenanceStop — and the bootstrap stages move out likewise.

Every defer is still registered in run() itself, at the same point in the
sequence, so the LIFO teardown order is unchanged; that order is documented
in the surrounding comments and is load-bearing (the audit-writer stop must
follow database.Close's registration, the event-persistence stop must precede
it). runStopEventPersistence is now registered unconditionally with a nil
persister meaning "disabled", where the old code registered its defer inside
the enabled branch — a no-op occupying that slot cannot change the relative
order of the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(ws): split handleReconnect into resume stages

77 statements / cyclomatic 41, plus the replay block at :199 and, in
handleFreshConnect, the voice-state restore at :622.

handleReconnect becomes reconnectPrecheck, reconnectSelectReplay (with
reconnectVetColdTail for the cold-tier gap check), reconnectRegister and
reconnectWriteReplay. handleFreshConnect's stale-voice cleanup moves to its
own helper, where the `if h.livekit != nil` wrapper becomes a guard clause —
that block was the tail of its scope, so returning early and falling off the
end are the same.

The parts that carry the invariants are moved verbatim: reconnectRegister
still takes h.seqMu, still calls registerNow inside that same critical
section (BUG-123 / OC-0206), still unlocks on every exit, and still emits the
"full" tier counter and telemetry on each of its three re-check failures.
handleReconnect's two-boolean contract is unchanged — the collapsed
`return false, false` sites are all fall-through-to-full-ready, and the
single `return true, false` is still the handshake-write-failure path whose
teardown already ran (OC-0051).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(server): fold in the adversarial review of the complexity refactors

Eleven skeptic passes over the refactor commits on this branch found no
blocker and no major — behaviour is preserved throughout. They did find
comment and accuracy defects worth correcting:

- db/mention_queries.go: the mentionTargetColumn rationale claimed the named
  type made the interpolated column "only ever one of the two constants". A
  Go named type is not closed, so that is a convention the type makes visible,
  not one it enforces. Reworded, gosec justification included.
- ws/voice_controls.go: the dupl collapse generalised away three specifics —
  that a server deafen is the moderator's to lift (now on the serverDeafen
  field), the concrete voice_states.camera / voice_states.screenshare column
  names, and the half of the OC-0023 rationale about neither stream kind
  hiding from the other's count. All three restored.
- ws/voice_join.go: `maxUsers := ch.VoiceMaxUsers` had been hoisted to the top
  of voiceJoinComplete, moving a read across the tail supersession guard. The
  read is inert, but it was the one statement in that commit whose position
  relative to a security guard changed; it now sits at its use, as before.
- ws/*_test.go: three test comments cited voice_join.go line numbers that the
  split invalidated. They now cite the helper by name instead.
- service/message_reactions.go: reactionAudience's doc claimed to enforce
  "every gate on reacting"; it enforces the channel-scoped ones, and the doc
  now says which gates stay with the caller.
- api/emoji_handler.go: the readEmojiUpload call reused the outer `ok` from
  the auth check by assignment; it gets its own readOK.
- admin/setup_handler.go: a moved comment kept a "the response above" deictic
  that no longer had a response above it.

No behaviour change. Build, vet, full tests and -race on five packages green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(ws): clear the remaining complexity budgets across the hub

Eight files, thirteen findings. Each function is split at the stages it
already had; no branch is reordered, merged or inverted.

- handlers.go handleMessage (cyclop 28, 88 stmts): session re-check, frame
  decode and result application become handleMessageSessionRecheck,
  handleMessageDecode and handleMessageApply. The V2 constructor lookup ->
  DispatchV2 -> Result resolution order is untouched.
- serve_ready.go buildReady (cyclop 26, 61 stmts): the per-section fetches
  split out, readyChannelPayloads among them. Every visibility predicate is
  preserved verbatim — this is the payload that decides what a client may see.
- serve_pumps.go writePump (cyclop 31): writePumpWrite, writePumpDeliver,
  writePumpDrainChannel and writePumpDrainAndClose. Every channel receive
  stays in the same select statement, so scheduling is unchanged.
- hub_sweep.go sweepStaleVoiceStates (cyclop 22, 56 stmts): the staleness
  predicate, the hub-lock ordering and the position of the race hook are all
  as they were — handleVoiceJoin's BUG-088 ordering depends on them.
- hub_broadcast.go channelReadAudienceImpl and RefreshChannelVisibility
  (cyclop 22 each, 57 stmts): channelReadAudienceDM and
  refreshChannelVisibilityCanSend. The audience predicate is the OC-0090
  group-DM leak surface, so it is extracted, never simplified.
- livekit_webhook.go (nestif 13 and 14): webhookJoinedEnforceVoiceState,
  webhookLeftCleanupClient and webhookLeftFinishLeave. DB delete still
  precedes broadcast on every path.
- livekit_download.go EnsureLiveKitBinary (52 stmts): one extraction,
  ensureLiveKitStageBinary, keeping every archive path check intact.
- voice_moderation.go (nestif 8): voiceModDeafenRollback. The persisted
  server_muted flag remains the authority.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(api): clear the remaining complexity budgets across the HTTP layer

- router.go NewRouter (cyclop 28, 84 stmts): split by wiring concern into
  routerTOTPKey, routerHealthDeps, routerMiddleware, routerUploadRoutes,
  routerPluginWiring, routerVoiceRoutes and routerMetricsRoutes. Middleware
  ORDER is a security property (auth before handler, WAF before body parse,
  rate limit before work) and is unchanged; the returned cleanup func still
  closes over and releases everything it did before.
- auth_handler.go handleRegister (133 lines) and handleLogin (cyclop 21,
  152 lines): registerPolicyGate, registerReadRequest, loginReadRequest and
  loginAuthenticate. The always-compare posture, every rate-limit key, every
  counter reset and the ban-check-versus-password-compare order are all
  preserved — including loginUserFailureThreshold staying unscaled by
  scaledAuthLimit, which is deliberate and commented.
- upload_handler.go handleServeFile (cyclop 31, 128 lines): serveFileResolve
  and serveFileAuthorize. Every header this sets — Content-Disposition
  included, which is what stops a stored file being served as active content —
  is still set with the same value in the same circumstances.
- profile_handler.go handleUploadAvatar (120 lines): avatarUploadReadImage,
  mirroring readEmojiUpload in shape but with the avatar caps and MIME set.
  The two deliberately do not share a helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: clear the last complexity budgets in db and admin

- db/account.go DeleteAccount (cyclop 28, 55 stmts): grouped by subsystem into
  deleteAccountAdminGuard, deleteAccountDMChannels and
  deleteAccountCloseDMChannels, each taking the same transaction. The
  transaction boundary, the delete ORDER (which foreign keys depend on) and
  the rollback path are unchanged.
- admin/logstream.go handleLogStream (cyclop 24): logStreamAuthorize. Flush
  cadence, heartbeat and disconnect detection untouched.
- admin/setup_wizard.go validateWizard (cyclop 23): grouped by section into
  wizardValidateIdentity, wizardValidateNetwork and wizardValidateMedia. Every
  message and bound is unchanged — this is the first input-validation boundary
  on a fresh server, before any auth exists.

With this the tree is at zero: golangci-lint run reports 0 issues against the
budgets set in #1384 (funlen 100/50, cyclop 20, nestif 8, dupl 150), with no
//nolint and no exclusion added anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:39:45 +02:00

722 lines
29 KiB
Go

// Package api provides the HTTP router and handlers for the OwnCord server.
package api
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/owncord/server/admin"
"github.com/owncord/server/auth"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
"github.com/owncord/server/diskutil"
"github.com/owncord/server/permissions"
"github.com/owncord/server/plugin"
"github.com/owncord/server/service"
"github.com/owncord/server/stackutil"
"github.com/owncord/server/storage"
"github.com/owncord/server/syncutil"
"github.com/owncord/server/telemetry"
"github.com/owncord/server/updater"
"github.com/owncord/server/ws"
)
// NewRouter builds and returns the fully configured HTTP handler, the
// WebSocket hub (so the caller can call hub.GracefulStop on shutdown), and a
// cleanup function that stops background goroutines (e.g. rate-limiter cleanup).
//
// pluginRegistry may be nil — in that case the plugin admin endpoints respond
// with 503 on lifecycle calls and an empty list on read.
func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.RingBuffer, pluginRegistry *plugin.Registry) (http.Handler, *ws.Hub, func()) {
// Install the auth rate multiplier before any route mounts read it.
setAuthRateScale(cfg.Security.AuthRateLimitMultiplier)
// Load (or auto-generate) the AES-256 key for TOTP secret encryption
// (M1). Done first, before any other setup, so a fatal failure here
// (below) doesn't leave background goroutines or partially-mounted
// routes behind.
totpKey := routerTOTPKey(cfg)
r := chi.NewRouter()
routerMiddleware(r, cfg)
// Health check — unauthenticated, no versioning prefix.
// The hub-backed callbacks are set after hub creation below (late-bound
// closures, same pattern the old online-user counter used). One shared
// handler instance backs both /health mounts so they share the check cache.
var getOnlineUsers func() int
var hubAlive func() bool
healthHandler := handleHealth(routerHealthDeps(cfg, database, &getOnlineUsers, &hubAlive))
r.Get("/health", healthHandler)
// Shared rate limiter for auth endpoints. Lockouts are persisted to the
// database so they survive server restarts (M2 security hardening).
limiter := auth.NewPersistentRateLimiter(database)
// Start background cleanup of stale rate-limiter entries to prevent
// unbounded memory growth. The goroutine exits when stopCh is closed.
limiterStopCh := make(chan struct{})
go limiter.StartCleanup(rateLimiterCleanupInterval, rateLimiterCleanupMaxWindow, limiterStopCh)
// Versioned API routes.
r.Route("/api/v1", func(r chi.Router) {
r.Get("/health", healthHandler)
r.Get("/info", handleInfo(cfg))
})
// Service layer — centralizes business logic for REST and WS handlers.
// *db.DB satisfies service.Store directly (the store abstraction was
// removed in D3).
svc := service.New(database, limiter)
// Auth routes are mounted after hub creation (below) so self-service
// account deletion can broadcast member_ban like the admin ban path does.
// Invite management routes (require MANAGE_INVITES permission).
MountInviteRoutes(r, database, svc)
// Channel and message REST routes are mounted after hub creation (below)
// so the hub can fan a bulk delete out as one chat_bulk_deleted event.
// GIF proxy — keeps the Klipy API key server-side. Mounted unconditionally;
// with no key configured the endpoints answer 503 GIF_DISABLED so the
// client can hide the picker rather than discover a 404.
MountGIFRoutes(r, database, limiter, cfg)
if cfg.GIF.APIKey == "" {
slog.Info("gif.api_key not set — GIF picker disabled (clients will hide it)")
}
// DM REST routes are mounted after hub creation (below) so the hub can
// be passed as a DMBroadcaster for real-time close events.
// File upload and serving routes.
store, storeErr := routerUploadRoutes(r, database, limiter, cfg, svc.Permissions)
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here.
hub := ws.NewHub(database, limiter, svc)
// Replay budget knobs must land before hub.Run starts (below).
hub.ConfigureReplay(cfg.EventPersistence.ReplayRingSize, cfg.EventPersistence.ReplayColdLimit)
getOnlineUsers = func() int { return hub.ClientCount() }
hubAlive = func() bool { return hub.DispatchAlive() }
// Auth routes: register, login, logout, me. Mounted with the hub as the
// AuthBroadcaster so DELETE /api/v1/auth/account (self-service account
// deletion) fans out member_ban and force-disconnects the deleted user's
// own socket, exactly like the admin ban path does for the same
// anonymise-and-ban DB state.
MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey, hub)
routerPluginWiring(hub, pluginRegistry)
// Voice: LiveKit client, optional companion process, webhook and proxy routes.
routerVoiceRoutes(r, cfg, limiter, hub)
// Profile routes: update profile, change password, session management.
// Mounted after hub creation so the hub can broadcast user_update events.
// A storage failure leaves store unusable, so the avatar-upload route is
// simply not registered; the rest of the profile surface is unaffected.
// Built as a FileStore interface value from scratch — assigning the typed
// nil pointer would produce a non-nil interface and defeat the mount-time
// nil check.
var profileStore FileStore
if storeErr == nil {
profileStore = store
}
MountProfileRoutes(r, database, svc, profileStore, limiter, cfg.Server.TrustedProxies, hub)
// DM (direct message) REST routes — mounted after hub creation so the
// hub can send real-time dm_channel_close events to WebSocket clients.
MountDMRoutes(r, database, svc, hub)
// Channel and message REST routes — mounted after hub creation so a
// message purge can broadcast chat_bulk_deleted to the channel.
MountChannelRoutes(r, database, svc, limiter, cfg.Server.TrustedProxies, hub)
// Custom emoji REST routes — mounted after hub creation so an upload or a
// delete can fan the new set out as an emoji_update. Requires the same file
// storage the attachment routes use; without it the emoji endpoints are not
// mounted at all (a 404 the client reads as "this server has no emoji").
if storeErr == nil {
MountEmojiRoutes(r, database, svc, store, limiter, hub)
}
// H-8: Connectivity diagnostics restricted to admin users only.
// Exposes Go runtime version and LiveKit node IP which aid targeted attacks.
r.With(AuthMiddleware(database),
RequirePermission(permissions.Administrator),
RateLimitMiddleware(limiter, "diag:", 5, time.Minute, cfg.Server.TrustedProxies)).
Get("/api/v1/diagnostics/connectivity",
handleDiagnosticsConnectivity(cfg, ver, hub))
go hub.Run()
r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins, cfg.Server.MaxWSConnections))
routerMetricsRoutes(r, cfg, database, svc, hub)
// Admin panel: static files + REST API (Phase 6).
// Restrict /admin to configured CIDRs (default: private networks only).
u := updater.NewUpdater(ver, cfg.GitHub.Token, cfg.GitHub.Owner, cfg.GitHub.Repo)
adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation, svc.Roles,
admin.SetupOptions{ConfigPath: config.DefaultPath, RunningCfg: cfg})
r.Group(func(r chi.Router) {
r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies))
r.Mount("/admin", adminHandler)
// Phase C Step 9 — plugin admin REST surface. The IP gate above is
// only the outer perimeter; plugin lifecycle endpoints additionally
// require a valid admin Bearer token via admin.RequireAdminAuth so a
// LAN attacker on the allowed CIDR cannot install/enable plugins
// without a session. The handler is wired with the live registry
// constructed in main.go (nil when plugin support is disabled, in
// which case lifecycle calls return 503 and list returns []).
r.Group(func(r chi.Router) {
r.Use(admin.RequireAdminAuth(database))
r.Mount("/api/v1/admin/plugins", NewPluginAdminHandler(pluginRegistry, database))
})
})
// Client auto-update endpoint (unauthenticated). Per-IP rate limited to
// bound abuse; the signature fetch is cached inside the updater (DoS fix).
// Dedicated key prefix (mirroring "livekit_proxy:"): the empty-prefix
// middleware would share per-IP buckets with verify-totp, password change,
// and the other sensitive endpoints, so a client's 30/min auto-poll could
// 429 its user's own 2FA or password change.
MountClientUpdateRoute(
r.With(rateLimitMiddlewareWithPrefix(limiter, "client_update:", clientUpdateRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies)),
u,
)
// Issue 15: Warn if AllowedOrigins contains wildcard.
if slices.Contains(cfg.Server.AllowedOrigins, "*") {
slog.Warn("AllowedOrigins contains wildcard '*' — consider restricting to specific origins for production use")
}
cleanup := func() {
close(limiterStopCh)
}
return r, hub, cleanup
}
// routerTOTPKey loads (or auto-generates) the AES-256 key NewRouter hands to the
// auth routes for TOTP secret encryption (M1).
func routerTOTPKey(cfg *config.Config) []byte {
totpKey, totpKeyErr := auth.LoadOrGenerateTOTPKey(cfg.Server.DataDir)
if totpKeyErr != nil {
if cfg.Server.DataDir != "" {
// A configured data directory means this is a real deployment —
// main.go creates cfg.Server.DataDir before calling NewRouter, so
// by this point LoadOrGenerateTOTPKey only fails for a malformed
// OWNCORD_TOTP_KEY or a corrupt/truncated totp.key file, never for
// a missing directory. (The zero-value "" DataDir used by handler
// tests that never touch TOTP crypto is exempted below so the
// existing test suite keeps passing.)
//
// Continuing here would leave totpKey nil: every AES call in
// auth.EncryptTOTPSecret/DecryptTOTPSecret then hits
// aes.NewCipher(nil) and 500s, so every 2FA-enabled account
// (including the owner) would be locked out of login and unable
// to re-enroll, forever, while /health kept reporting OK. Refuse
// to start instead.
panic(fmt.Sprintf("api: failed to load TOTP encryption key: %v", totpKeyErr))
}
slog.Error("failed to load TOTP encryption key", "error", totpKeyErr)
// Fall through — only reachable when DataDir is unset; TOTP handlers
// cannot encrypt/decrypt until a data directory is configured.
}
return totpKey
}
// routerHealthDeps builds the liveness probes behind the shared /health
// handler. getOnlineUsers and hubAlive are taken as pointers because NewRouter
// only assigns them once the hub exists, after this handler is already mounted;
// the closures read whatever the variables hold at request time.
func routerHealthDeps(cfg *config.Config, database *db.DB, getOnlineUsers *func() int, hubAlive *func() bool) healthDeps {
return healthDeps{
onlineUsers: func() int {
if *getOnlineUsers != nil {
return (*getOnlineUsers)()
}
return 0
},
dbPing: func(ctx context.Context) error {
if database == nil {
return nil
}
// Reader pool, not the writer: a scheduled backup's VACUUM INTO
// holds the sole writer connection for its whole duration, and
// the server keeps serving reads throughout — /health must not
// call that outage (see db.PingRead).
return database.PingRead(ctx)
},
dispatchAlive: func() bool {
if *hubAlive != nil {
return (*hubAlive)()
}
return true
},
freeDiskBytes: func() (uint64, error) {
return diskutil.FreeBytes(cfg.Server.DataDir)
},
}
}
// routerMiddleware installs NewRouter's global middleware stack. The order is a
// security property (request-id binding before the logger reads it, security
// headers and the body cap before any handler runs) — keep it exactly as
// written.
func routerMiddleware(r chi.Router, cfg *config.Config) {
// Middleware stack.
r.Use(boundRequestID) // must precede RequestID — it reads the header verbatim
r.Use(middleware.RequestID)
r.Use(setRequestIDHeader) // echo request ID into response header
// NOTE: middleware.RealIP is intentionally omitted — trusting X-Real-IP from
// any source allows IP spoofing for rate-limit bypass. IP header trust is now
// handled explicitly in clientIPWithProxies using the trusted_proxies config.
r.Use(recoverer) // slog-routing panic recovery (replaces chi's stderr-only Recoverer)
r.Use(requestLogger) // structured request/response logging
// Phase B Step 8 — OpenTelemetry HTTP tracing. No-op when telemetry is
// disabled or the otel build tag is not set, so this is safe to mount
// unconditionally.
r.Use(telemetry.HTTPMiddleware())
r.Use(SecurityHeadersWithTLS(cfg.TLS.Mode))
r.Use(MaxBodySizeUnless(defaultMaxBodySize, bodyCapExemptPrefixes...))
// Coraza WAF — opt-in via config.
if cfg.Server.WAFEnabled {
r.Use(NewWAFMiddlewareCRS(cfg.Server.WAFParanoiaLevel, cfg.Server.WAFCRSMode))
}
}
// routerUploadRoutes mounts the file upload and serving routes and returns the
// shared file storage (and its construction error) for the profile-avatar and
// emoji mounts, which reuse the same store.
func routerUploadRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, cfg *config.Config, permSvc *service.PermissionService) (*storage.Storage, error) {
// L12: verify config upload size fits within the HTTP body limit.
if int64(cfg.Upload.MaxSizeMB)<<20 > uploadMaxBodySize {
slog.Warn("upload.max_size_mb exceeds HTTP body limit, capping",
"configured_mb", cfg.Upload.MaxSizeMB,
"http_limit_bytes", uploadMaxBodySize)
}
store, storeErr := storage.New(cfg.Upload.StorageDir, cfg.Upload.MaxSizeMB)
if storeErr != nil {
slog.Error("failed to create file storage", "error", storeErr)
} else {
MountUploadRoutes(r, database, store, limiter, cfg.Server.AllowedOrigins, permSvc)
}
return store, storeErr
}
// routerPluginWiring wires the plugin registry and its event sink into the hub.
func routerPluginWiring(hub *ws.Hub, pluginRegistry *plugin.Registry) {
// Phase C Step 9 — wire plugin registry and event sink into the hub.
// nil pluginRegistry means plugins are disabled; the hub no-ops cleanly.
if pluginRegistry != nil {
hub.SetPluginRegistry(pluginRegistry)
sink := pluginRegistry.Sink()
sink.SetBroadcaster(hub.BroadcastToChannel)
hub.SetPluginEventSink(sink)
}
}
// routerVoiceRoutes creates the LiveKit client, optionally starts the companion
// LiveKit process, and mounts the webhook, LiveKit health and signaling-proxy
// routes. Voice is disabled — and none of those routes are mounted — when the
// client fails to build.
func routerVoiceRoutes(r chi.Router, cfg *config.Config, limiter *auth.RateLimiter, hub *ws.Hub) {
// Create LiveKit client if voice config is present; voice is disabled on failure.
lk, lkErr := ws.NewLiveKitClient(&cfg.Voice)
if lkErr != nil {
slog.Warn("failed to create LiveKit client, voice disabled", "error", lkErr)
} else {
hub.SetLiveKit(lk)
// Optionally start a companion LiveKit process — either from a
// configured binary or via checksum-verified auto-download (the
// download happens in the background inside Start).
if cfg.Voice.LiveKitBinaryPath != "" || cfg.Voice.AutoDownloadLiveKit {
proc := ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir)
// Register the process with the hub BEFORE calling Start(), and
// keep it registered even if Start() fails (OC-0019). The only
// consumer of h.lkProcess is the voice_join guard
// (`h.lkProcess != nil && !h.lkProcess.IsRunning()`), which reads
// a nil process as "LiveKit is externally managed, don't check".
// That is the wrong reading here: OwnCord was told to manage
// LiveKit and failed to launch it, so joins must fail closed via
// IsRunning() == false, not be waved through with no SFU
// running. IsRunning() is false for a proc whose Start() never
// got as far as spawning cmd, and Hub.Stop's lkProcess.Stop() is
// safe to call on a never-started proc.
hub.SetLiveKitProcess(proc)
if startErr := proc.Start(); startErr != nil {
slog.Error("failed to start LiveKit process", "error", startErr)
}
}
}
// Warn if LiveKit is externally managed and webhook may be blocked by admin CIDRs.
if lkErr == nil && cfg.Voice.LiveKitBinaryPath == "" && !cfg.Voice.AutoDownloadLiveKit {
lkHost := ""
if u, parseErr := url.Parse(cfg.Voice.LiveKitURL); parseErr == nil {
lkHost = u.Hostname()
}
if lkHost != "" && lkHost != "localhost" && lkHost != "127.0.0.1" && lkHost != "::1" {
slog.Warn("LiveKit is externally managed but webhook endpoint is admin-IP-restricted — "+
"add the LiveKit server's IP to livekit_webhook_allowed_cidrs or webhooks will be silently dropped",
"livekit_host", lkHost)
}
}
// LiveKit webhook endpoint (no auth middleware — uses LiveKit JWT
// verification). The IP gate is defence-in-depth on top of that signature
// check, with its own allowlist key (livekit_webhook_allowed_cidrs) so an
// externally-hosted LiveKit can be admitted WITHOUT widening the admin
// panel's perimeter to the SFU's network. Falls back to
// admin_allowed_cidrs when unset.
if lkErr == nil {
webhookCIDRs := cfg.Server.LiveKitWebhookCIDRs()
r.With(AdminIPRestrict(webhookCIDRs, cfg.Server.TrustedProxies)).
Post("/api/v1/livekit/webhook",
ws.MountWebhookRoute(hub, cfg.Voice.LiveKitAPIKey, cfg.Voice.LiveKitAPISecret))
// LiveKit health check — same perimeter as the webhook.
r.With(AdminIPRestrict(webhookCIDRs, cfg.Server.TrustedProxies)).
Get("/api/v1/livekit/health", handleLiveKitHealth(hub))
// Reverse proxy LiveKit signaling through OwnCord's HTTPS server.
// This avoids mixed-content blocks (secure page → insecure WS).
// Client connects to wss://server:8443/livekit/* → ws://localhost:7880/*
//
// NOTE: AuthMiddleware is intentionally omitted. The LiveKit JS SDK's
// signal requests don't carry OwnCord session tokens — authentication
// is handled by the LiveKit JWT (access_token query param) which the
// LiveKit server validates. Users can only obtain a valid JWT through
// the authenticated voice_join WS flow. Rate limiting prevents abuse.
r.With(rateLimitMiddlewareWithPrefix(limiter, "livekit_proxy:", livekitProxyRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies)).
Handle("/livekit/*", http.StripPrefix("/livekit", NewLiveKitProxy(cfg.Voice.LiveKitURL, cfg.Server.AllowedOrigins)))
}
}
// routerMetricsRoutes mounts the JSON metrics endpoint and, when an OTel
// Prometheus exporter is wired, the Prometheus handler beside it.
func routerMetricsRoutes(r chi.Router, cfg *config.Config, database *db.DB, svc *service.Services, hub *ws.Hub) {
// Metrics endpoint — IP-restricted by metrics_allowed_cidrs (falls back to
// admin_allowed_cidrs) so a central scraper can be admitted without
// widening /admin. The shape is documented in docs/deployment.md — keep
// the two in sync.
r.With(AdminIPRestrict(cfg.Server.MetricsCIDRs(), cfg.Server.TrustedProxies)).
Get("/api/v1/metrics", handleMetrics(MetricsSources{
ConnectedUsers: hub.ClientCount,
VoiceSessions: hub.VoiceSessionCount,
BroadcastDrops: hub.BroadcastDropCount,
LiveKitHealth: hub.LiveKitHealthCheck,
ReconnectTiers: hub.ReconnectTierStats,
Backpressure: hub.BackpressureStats,
ConnRejects: hub.ConnRejectCount,
PersisterStats: hub.EventPersisterStats,
DBStats: func() sql.DBStats { return database.SQLDb().Stats() },
PermCache: svc.Permissions.CacheStats,
DiskFree: func() (uint64, error) { return diskutil.FreeBytes(cfg.Server.DataDir) },
}))
// Phase B Step 8 — OpenTelemetry Prometheus exporter. Mounted alongside
// the legacy JSON endpoint when a Prometheus exporter is wired (otel
// build, exporter == "prometheus"). Returns 404 in the default no-op build
// because telemetry.PrometheusHandler() returns nil.
if promH := telemetry.PrometheusHandler(); promH != nil {
r.With(AdminIPRestrict(cfg.Server.MetricsCIDRs(), cfg.Server.TrustedProxies)).
Mount("/metrics", promH)
}
}
// serverStartTime records when the process started; used for uptime in /health.
var serverStartTime = time.Now()
// healthResponse is the JSON shape returned by GET /health.
type healthResponse struct {
Status string `json:"status"` // "ok" | "degraded"
Uptime int64 `json:"uptime"`
OnlineUsers int `json:"online_users"`
// Reason names the degraded subsystem ("hub", "database", "disk") and
// nothing more — this endpoint is unauthenticated, so no error details.
Reason string `json:"reason,omitempty"`
}
// healthDeps are the liveness probes behind GET /health. Any nil field is
// skipped (treated as healthy) so partial wirings and tests stay simple.
type healthDeps struct {
onlineUsers func() int
dbPing func(context.Context) error
dispatchAlive func() bool
freeDiskBytes func() (uint64, error)
}
const (
// healthCacheTTL bounds how often the real checks run: the endpoint is
// unauthenticated AND rate-limit-exempt, so an uncached DB ping per
// request would be a free amplification lever.
healthCacheTTL = 5 * time.Second
// healthDBPingTimeout bounds the SELECT 1 so a wedged writer degrades the
// health report instead of hanging it.
healthDBPingTimeout = 1 * time.Second
// healthMinFreeDiskBytes is the free-space floor under which health
// reports degraded. SQLite WAL growth, uploads, and backups all share the
// data volume, so running dry corrupts more than one thing at once.
healthMinFreeDiskBytes = 256 << 20 // 256 MiB
)
// infoResponse is the JSON shape returned by GET /api/v1/info.
type infoResponse struct {
Name string `json:"name"`
}
func handleHealth(deps healthDeps) http.HandlerFunc {
// C-2: Version removed from unauthenticated health endpoint to prevent
// server fingerprinting. Version is available on the authenticated
// diagnostics endpoint instead.
var mu syncutil.Mutex
var cachedAt time.Time
var cachedStatus, cachedReason string
return func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
if time.Since(cachedAt) >= healthCacheTTL {
// WithoutCancel: the result is cached and served to every caller
// for the next healthCacheTTL, so it must not inherit THIS
// request's cancellation — a probe that disconnects mid-check
// would otherwise poison the shared cache with a false
// "degraded/database" verdict. The DB ping carries its own 1s
// timeout, so the checks stay bounded regardless.
cachedStatus, cachedReason = runHealthChecks(context.WithoutCancel(r.Context()), deps)
cachedAt = time.Now()
}
status, reason := cachedStatus, cachedReason
mu.Unlock()
online := 0
if deps.onlineUsers != nil {
online = deps.onlineUsers()
}
code := http.StatusOK
if status != "ok" {
code = http.StatusServiceUnavailable
}
writeJSON(w, code, healthResponse{
Status: status,
Uptime: int64(time.Since(serverStartTime).Seconds()),
OnlineUsers: online,
Reason: reason,
})
}
}
// runHealthChecks probes the hub dispatch loop, the database, and free disk,
// returning ("ok", "") or ("degraded", <subsystem>). First failure wins, in
// blast-radius order. Probe errors that mean "unknown" (unsupported platform,
// missing dir in tests) count as healthy — only a positive negative degrades.
func runHealthChecks(ctx context.Context, deps healthDeps) (status, reason string) {
if deps.dispatchAlive != nil && !deps.dispatchAlive() {
return "degraded", "hub"
}
if deps.dbPing != nil {
pingCtx, cancel := context.WithTimeout(ctx, healthDBPingTimeout)
err := deps.dbPing(pingCtx)
cancel()
if err != nil {
return "degraded", "database"
}
}
if deps.freeDiskBytes != nil {
if free, err := deps.freeDiskBytes(); err == nil && free < healthMinFreeDiskBytes {
return "degraded", "disk"
}
}
return "ok", ""
}
func handleInfo(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// C-2: Version removed from unauthenticated info endpoint.
writeJSON(w, http.StatusOK, infoResponse{
Name: cfg.Server.Name,
})
}
}
// livekitHealthResponse is the JSON shape returned by GET /api/v1/livekit/health.
type livekitHealthResponse struct {
Status string `json:"status"`
LiveKitReachable bool `json:"livekit_reachable"`
Error string `json:"error,omitempty"`
}
func handleLiveKitHealth(hub *ws.Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ok, err := hub.LiveKitHealthCheck(r.Context())
if ok {
writeJSON(w, http.StatusOK, livekitHealthResponse{
Status: "ok",
LiveKitReachable: true,
})
return
}
errMsg := "unknown"
if err != nil {
errMsg = err.Error()
}
writeJSON(w, http.StatusServiceUnavailable, livekitHealthResponse{
Status: "degraded",
LiveKitReachable: false,
Error: errMsg,
})
}
}
// boundRequestID drops a client-supplied X-Request-Id that is over
// maxRequestIDLen bytes or is not plain printable ASCII, so the
// middleware.RequestID mounted straight after it generates a server-side id
// instead. Without this, chi adopts the header verbatim and the value is
// retained by the admin ring buffer (2000 entries) and echoed back in the
// response header — a one-shot burst of ~1 MiB ids pins hundreds of MB of heap.
//
// The value is dropped rather than truncated: a truncated id is not the
// client's id, so it correlates with nothing while still parking
// attacker-chosen bytes in the log. The request is served either way, and the
// server-generated id is still returned in the X-Request-Id response header.
func boundRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if id := r.Header.Get(middleware.RequestIDHeader); id != "" && !validRequestID(id) {
r.Header.Del(middleware.RequestIDHeader)
}
next.ServeHTTP(w, r)
})
}
// validRequestID reports whether id is short enough and printable enough to
// carry through logs and the response header.
func validRequestID(id string) bool {
if len(id) > maxRequestIDLen {
return false
}
for i := range len(id) {
if id[i] < '!' || id[i] > '~' {
return false
}
}
return true
}
// truncateForLog bounds a client-controlled string before it becomes a log
// attribute, so it cannot inflate the retained ring-buffer entries.
func truncateForLog(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "...(truncated)"
}
// setRequestIDHeader copies the request ID from context into the response header.
func setRequestIDHeader(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := middleware.GetReqID(r.Context())
if requestID != "" {
w.Header().Set("X-Request-Id", requestID)
}
next.ServeHTTP(w, r)
})
}
// recoverer recovers from panics in HTTP handlers and logs them through slog —
// so they reach the admin log stream and are structured — unlike chi's default
// middleware.Recoverer, which writes an unstructured stack to stderr only. The
// stack is captured via stackutil so it never embeds argument values (which on
// auth/upload paths can carry tokens or passwords).
func recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture correlation IDs before dispatch so the recovery closure makes
// no context calls (which trip contextcheck inside a defer), while the
// panic log still carries req_id/trace_id.
reqID := middleware.GetReqID(r.Context())
traceID := telemetry.TraceIDFromContext(r.Context())
defer func() {
if rec := recover(); rec != nil {
// Preserve chi's behaviour of not swallowing the abort sentinel.
if rec == http.ErrAbortHandler {
panic(rec)
}
attrs := []any{
"method", r.Method,
"path", truncateForLog(r.URL.Path, maxLoggedPathLen),
"panic", rec,
"stack", stackutil.Capture(),
}
if reqID != "" {
attrs = append(attrs, "req_id", reqID)
}
if traceID != "" {
attrs = append(attrs, "trace_id", traceID)
}
slog.Error("http handler panic recovered", attrs...)
w.WriteHeader(http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// requestLogger logs every HTTP request with method, path, status, and duration.
// Health checks are logged at Debug level to avoid noise.
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
elapsed := time.Since(start)
status := ww.Status()
// Health checks at Debug level; errors at Warn; everything else at Info.
path := r.URL.Path
reqID := middleware.GetReqID(r.Context())
attrs := []any{
"method", r.Method,
"path", truncateForLog(path, maxLoggedPathLen),
"status", status,
"duration_ms", elapsed.Milliseconds(),
"bytes", ww.BytesWritten(),
"client_ip", clientIP(r),
}
if reqID != "" {
attrs = append(attrs, "req_id", reqID)
}
switch {
case path == "/health" || path == "/api/v1/health":
slog.Debug("http request", attrs...)
case status >= 500:
slog.Error("http request", attrs...)
case status >= 400:
slog.Warn("http request", attrs...)
default:
slog.Info("http request", attrs...)
}
})
}
// writeJSON encodes v as JSON and writes it to w with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("writeJSON: failed to encode response", "error", err)
}
}