Files
OwnCord/Server/admin/logstream.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

554 lines
17 KiB
Go

package admin
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"runtime"
"strings"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
"github.com/owncord/server/syncutil"
)
// ─── Ticket Store for SSE Log Stream ────────────────────────────────────────
// ticketEntry holds a single-use ticket with a creation timestamp for TTL.
type ticketEntry struct {
createdAt time.Time
tokenHash string
}
// ticketStore manages short-lived, single-use tickets for SSE authentication.
type ticketStore struct {
mu syncutil.Mutex
tickets map[string]ticketEntry
}
var logTickets = &ticketStore{
tickets: make(map[string]ticketEntry),
}
const ticketTTL = 30 * time.Second
// issue creates a new single-use ticket and returns its hex string.
func (ts *ticketStore) issue(tokenHash string) (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generating ticket: %w", err)
}
ticket := hex.EncodeToString(b)
ts.mu.Lock()
defer ts.mu.Unlock()
// Opportunistic cleanup of expired tickets.
now := time.Now()
for k, v := range ts.tickets {
if now.Sub(v.createdAt) > ticketTTL {
delete(ts.tickets, k)
}
}
ts.tickets[ticket] = ticketEntry{createdAt: now, tokenHash: tokenHash}
return ticket, nil
}
// redeem validates and consumes a ticket.
func (ts *ticketStore) redeem(ticket string) (ticketEntry, bool) {
ts.mu.Lock()
defer ts.mu.Unlock()
entry, ok := ts.tickets[ticket]
if !ok {
return ticketEntry{}, false
}
delete(ts.tickets, ticket) // single-use: delete immediately
if time.Since(entry.createdAt) > ticketTTL {
return ticketEntry{}, false
}
return entry, true
}
// handleLogTicket issues a short-lived, single-use ticket for the SSE log stream.
// POST /admin/api/logs/ticket — requires normal admin auth (header). The ticket
// is bound to the hash of whichever bearer credential authenticated the request
// (login session or API token), so both principal kinds can stream logs.
func handleLogTicket(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
hash, ok := r.Context().Value(adminTokenHashKey).(string)
if !ok || hash == "" {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session")
return
}
ticket, err := logTickets.issue(hash)
if err != nil {
slog.Error("failed to issue log stream ticket", "err", err)
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate ticket")
return
}
writeJSON(w, http.StatusOK, map[string]string{"ticket": ticket})
}
}
// LogEntry holds a single structured log record for the ring buffer.
type LogEntry struct {
Timestamp string `json:"ts"`
Level string `json:"level"`
Message string `json:"msg"`
Source string `json:"source"`
Attrs string `json:"attrs,omitempty"`
}
// RingBuffer is a bounded, thread-safe circular buffer of log entries
// with fan-out to SSE subscriber channels.
//
// It is a true ring (fixed backing array + write position), modelled on
// ws.EventRingBuffer: overwriting the oldest entry is a single slot store,
// not a fresh capacity-sized allocation + copy per write.
type RingBuffer struct {
mu syncutil.Mutex
entries []LogEntry // fixed backing array, len == capacity
pos int // next write position
count int // entries stored (up to len(entries))
subscribers map[*chan LogEntry]struct{}
}
// NewRingBuffer creates a ring buffer with the given capacity (must be > 0).
func NewRingBuffer(capacity int) *RingBuffer {
return &RingBuffer{
entries: make([]LogEntry, capacity),
subscribers: make(map[*chan LogEntry]struct{}),
}
}
// Write appends an entry, overwriting the oldest if full, and fans out
// to all subscribers (non-blocking to avoid slow clients blocking logging).
func (rb *RingBuffer) Write(entry LogEntry) {
rb.mu.Lock()
defer rb.mu.Unlock()
rb.entries[rb.pos] = entry
rb.pos = (rb.pos + 1) % len(rb.entries)
if rb.count < len(rb.entries) {
rb.count++
}
for chp := range rb.subscribers {
select {
case *chp <- entry:
default:
// Slow subscriber — drop to avoid blocking.
}
}
}
// Snapshot returns a copy of all current entries, oldest first, for backfill.
func (rb *RingBuffer) Snapshot() []LogEntry {
rb.mu.Lock()
defer rb.mu.Unlock()
return rb.snapshotLocked()
}
// snapshotLocked is Snapshot's body, callable by callers that already hold
// rb.mu (SnapshotAndSubscribe needs the copy and the subscription to happen
// under the same critical section).
func (rb *RingBuffer) snapshotLocked() []LogEntry {
out := make([]LogEntry, rb.count)
if rb.count < len(rb.entries) {
// Not yet wrapped: entries [0, count) are already in order.
copy(out, rb.entries[:rb.count])
return out
}
// Wrapped: oldest entry sits at pos.
n := copy(out, rb.entries[rb.pos:])
copy(out[n:], rb.entries[:rb.pos])
return out
}
// Subscribe creates a buffered channel for a new SSE client.
// Returns the channel and an unsubscribe function.
func (rb *RingBuffer) Subscribe() (<-chan LogEntry, func()) {
rb.mu.Lock()
ch, unsub := rb.subscribeLocked()
rb.mu.Unlock()
return ch, unsub
}
// subscribeLocked is Subscribe's body, callable by callers that already hold
// rb.mu.
func (rb *RingBuffer) subscribeLocked() (<-chan LogEntry, func()) {
ch := make(chan LogEntry, 64)
chp := &ch
rb.subscribers[chp] = struct{}{}
return ch, func() {
rb.mu.Lock()
delete(rb.subscribers, chp)
rb.mu.Unlock()
}
}
// SnapshotAndSubscribe atomically copies the current backfill entries and
// registers a new subscriber channel under a single lock acquisition.
//
// Doing this as two separate calls (Snapshot() then Subscribe()) leaves a
// window between them where Write's fan-out — which only reaches entries
// already in rb.subscribers — cannot deliver to a caller that has not
// subscribed yet, while the caller's snapshot was already taken and will
// never include it either. Any entry written in that window is lost from
// both the backfill and the live feed. handleLogStream's window is not
// instantaneous: a token-resolution DB round-trip runs per backfilled entry
// before the (formerly) separate Subscribe() call.
func (rb *RingBuffer) SnapshotAndSubscribe() ([]LogEntry, <-chan LogEntry, func()) {
rb.mu.Lock()
defer rb.mu.Unlock()
out := rb.snapshotLocked()
ch, unsub := rb.subscribeLocked()
return out, ch, unsub
}
// multiHandler is an slog.Handler that tees records to two handlers:
// the original stdout handler and a ring buffer handler.
type multiHandler struct {
stdout slog.Handler
ring *ringHandler
}
// ringHandler converts slog.Records into LogEntries and writes them
// to the RingBuffer.
type ringHandler struct {
buf *RingBuffer
level slog.Leveler
attrs []slog.Attr
groups []string
}
// NewMultiHandler creates a handler that sends records to both stdout
// and the ring buffer. The ring buffer captures all levels from minLevel;
// pass a *slog.LevelVar to retune the threshold at runtime. Enabled reports
// false below both thresholds, so gated Debug calls cost nothing.
func NewMultiHandler(stdout slog.Handler, buf *RingBuffer, minLevel slog.Leveler) slog.Handler {
return &multiHandler{
stdout: stdout,
ring: &ringHandler{
buf: buf,
level: minLevel,
},
}
}
func (h *multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.stdout.Enabled(ctx, level) || h.ring.Enabled(level)
}
func (h *multiHandler) Handle(ctx context.Context, r slog.Record) error {
if h.stdout.Enabled(ctx, r.Level) {
_ = h.stdout.Handle(ctx, r)
}
if h.ring.Enabled(r.Level) {
h.ring.Handle(r)
}
return nil
}
func (h *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &multiHandler{
stdout: h.stdout.WithAttrs(attrs),
ring: h.ring.withAttrs(attrs),
}
}
func (h *multiHandler) WithGroup(name string) slog.Handler {
return &multiHandler{
stdout: h.stdout.WithGroup(name),
ring: h.ring.withGroup(name),
}
}
func (rh *ringHandler) Enabled(level slog.Level) bool {
return level >= rh.level.Level()
}
// logAttrValue converts an slog.Value to the value stored in the ring
// buffer's JSON attrs, matching what slog's own JSONHandler does for stdout
// (see appendJSONValue in log/slog/json_handler.go):
//
// - Resolve() first, so an slog.LogValuer (db.User, db.Session,
// config.Config and its secret-bearing sections — see
// Server/db/logvalue.go and Server/config/logvalue.go) is redacted before
// it reaches JSON. Record.Attrs does not resolve on its own, so without
// this the ring buffer bypasses that redaction even though stdout honors it.
// - error values marshal to "{}" (errors.errorString / fmt.wrapError have
// only unexported fields), so an error not otherwise handled by
// json.Marshal is rendered as its Error() string instead.
// - a resolved group (LogValue returning slog.GroupValue, as every type
// above does) is walked into a map instead of json.Marshal-ed as-is: a
// bare []slog.Attr marshals to "{}" per element, since slog.Value's
// fields are unexported. Resolve() only resolves the outer LogValuer, not
// nested ones (see its doc comment), so nested group members are resolved
// by this same recursive call.
func logAttrValue(v slog.Value) any {
v = v.Resolve()
if v.Kind() == slog.KindGroup {
group := v.Group()
m := make(map[string]any, len(group))
for _, a := range group {
m[a.Key] = logAttrValue(a.Value)
}
return m
}
a := v.Any()
if err, ok := a.(error); ok {
if _, isJSONMarshaler := a.(json.Marshaler); !isJSONMarshaler {
return err.Error()
}
}
return a
}
func (rh *ringHandler) Handle(r slog.Record) {
// Build source from file path.
source := categorizeSource(r)
// Collect attributes as a JSON object.
attrs := make(map[string]any)
// Add pre-set attrs from WithAttrs.
for _, a := range rh.attrs {
attrs[a.Key] = logAttrValue(a.Value)
}
// Add record attrs.
r.Attrs(func(a slog.Attr) bool {
key := a.Key
if len(rh.groups) > 0 {
key = strings.Join(rh.groups, ".") + "." + key
}
attrs[key] = logAttrValue(a.Value)
return true
})
var attrsJSON string
if len(attrs) > 0 {
if b, err := json.Marshal(attrs); err == nil {
attrsJSON = string(b)
}
}
rh.buf.Write(LogEntry{
Timestamp: r.Time.Format(time.RFC3339Nano),
Level: r.Level.String(),
Message: r.Message,
Source: source,
Attrs: attrsJSON,
})
}
func (rh *ringHandler) withAttrs(attrs []slog.Attr) *ringHandler {
combined := make([]slog.Attr, len(rh.attrs)+len(attrs))
copy(combined, rh.attrs)
copy(combined[len(rh.attrs):], attrs)
return &ringHandler{
buf: rh.buf,
level: rh.level,
attrs: combined,
groups: rh.groups,
}
}
func (rh *ringHandler) withGroup(name string) *ringHandler {
groups := make([]string, len(rh.groups)+1)
copy(groups, rh.groups)
groups[len(rh.groups)] = name
return &ringHandler{
buf: rh.buf,
level: rh.level,
attrs: rh.attrs,
groups: groups,
}
}
// categorizeSource extracts a human-readable source category from the log record.
func categorizeSource(r slog.Record) string {
if r.PC == 0 {
return "server"
}
// Use runtime frame to get the source file path.
frames := runtime.CallersFrames([]uintptr{r.PC})
frame, _ := frames.Next()
file := frame.File
switch {
case strings.Contains(file, "/ws/"):
return "websocket"
case strings.Contains(file, "/api/"):
return "http"
case strings.Contains(file, "/admin/"):
return "admin"
case strings.Contains(file, "/auth/"):
return "auth"
case strings.Contains(file, "/db/"):
return "database"
case strings.Contains(file, "/storage/"):
return "storage"
case strings.Contains(file, "/updater/"):
return "updater"
case strings.Contains(file, "/config/"):
return "config"
default:
return "server"
}
}
// logStreamAuthorize runs the log stream's authentication prologue: it redeems
// the single-use ticket, resolves the principal behind it, and returns the
// re-check closure the stream must call before every write. It writes the error
// response itself and reports false when the caller must stop.
func logStreamAuthorize(w http.ResponseWriter, r *http.Request, database *db.DB) (func() bool, bool) {
// Authenticate via single-use ticket.
ticket := r.URL.Query().Get("ticket")
entry, ok := logTickets.redeem(ticket)
if ticket == "" || !ok {
errResp, _ := json.Marshal(map[string]string{
"error": "UNAUTHORIZED",
"message": "invalid or expired ticket",
})
http.Error(w, string(errResp), http.StatusUnauthorized)
return nil, false
}
// Stream lifetime == request lifetime, so all principal re-checks below
// use the stream request's context. The ticket's hash is resolved the
// same way adminAuthMiddleware resolves a bearer credential — login
// session first, then API token — so revoking either kind mid-stream
// cuts the stream.
ctx := r.Context()
user, role, _, err := auth.ResolveTokenHash(ctx, database, entry.tokenHash)
if err != nil || user == nil || role == nil {
errResp, _ := json.Marshal(map[string]string{
"error": "UNAUTHORIZED",
"message": "invalid or expired session",
})
http.Error(w, string(errResp), http.StatusUnauthorized)
return nil, false
}
principalStillAuthorized := func() bool {
current, currentRole, _, resolveErr := auth.ResolveTokenHash(ctx, database, entry.tokenHash)
if resolveErr != nil || current == nil || currentRole == nil {
return false
}
// A ban mid-stream must cut the stream, same as adminAuthMiddleware
// rejects a banned user on the request path.
if auth.IsEffectivelyBanned(current) {
return false
}
return permissions.HasAdmin(currentRole.Permissions)
}
if !principalStillAuthorized() {
errResp, _ := json.Marshal(map[string]string{
"error": "FORBIDDEN",
"message": "administrator permission required",
})
http.Error(w, string(errResp), http.StatusForbidden)
return nil, false
}
return principalStillAuthorized, true
}
// handleLogStream serves an SSE endpoint that streams log entries in real-time.
// Auth is via query param ?ticket= — a short-lived single-use ticket obtained
// from POST /admin/api/logs/ticket (which requires normal admin auth).
func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
principalStillAuthorized, ok := logStreamAuthorize(w, r, database)
if !ok {
return
}
ctx := r.Context()
// Check that we can flush (required for SSE).
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
// Set SSE headers.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
// This handler streams through the ordinary ResponseWriter (no
// Hijack), so it is otherwise subject to http.Server.WriteTimeout:
// net/http sets the connection's write deadline exactly once, when
// request headers are read, and nothing about writing more data
// later extends it. Without clearing it here, every write past that
// deadline (including the keepalive ticks below) silently times out
// — the caller discards write errors, per SSE convention, since a
// client that vanishes is detected via ctx.Done() instead — so the
// stream goes silently dead and the client eventually sees the
// connection close, then reconnects and replays the full backfill.
// SetWriteDeadline(zero) clears the deadline on HTTP/1 and cancels
// the per-stream deadline timer on HTTP/2; ErrNotSupported means the
// ResponseWriter doesn't sit over a real connection (e.g. in tests),
// which is fine to ignore.
if err := http.NewResponseController(w).SetWriteDeadline(time.Time{}); err != nil && !errors.Is(err, http.ErrNotSupported) {
slog.Warn("log stream: failed to clear write deadline; stream may be cut by WriteTimeout", "err", err)
}
// Snapshot the backfill and subscribe to new entries atomically: the
// per-entry principalStillAuthorized() check below is a DB round-trip,
// so the backfill loop is slow enough that a Snapshot()-then-Subscribe()
// gap would silently drop any entry written in between (v059).
backfill, ch, unsub := ringBuf.SnapshotAndSubscribe()
defer unsub()
// Send backfill.
for _, entry := range backfill {
if !principalStillAuthorized() {
return
}
if data, err := json.Marshal(entry); err == nil {
_, _ = fmt.Fprintf(w, "data: %s\n\n", data)
}
}
flusher.Flush()
// Keepalive ticker against intermediary/proxy idle timeouts (the
// connection's own WriteTimeout was already neutralized above).
keepalive := time.NewTicker(15 * time.Second)
defer keepalive.Stop()
for {
select {
case entry := <-ch:
if !principalStillAuthorized() {
return
}
if data, err := json.Marshal(entry); err == nil {
_, _ = fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
case <-keepalive.C:
if !principalStillAuthorized() {
return
}
_, _ = fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush()
case <-ctx.Done():
return
}
}
}
}