mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
`Server/go.mod` declared `github.com/owncord/server` while the public repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is no `owncord` GitHub org and no vanity-import host serving go-import metadata for it — so every import line in the tree named a location that does not exist. It compiles because a main module's own path is never fetched, which is exactly why it went unnoticed. The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) — is wrong here, and provably so. Six of the 722 occurrences are not imports at all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern), `telemetry/metrics.go:17-19` (three OTel instrumentation-scope names), `invariants/syncutil_locks.go:73` (a diagnostic message), and `invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go fixture). An import rewriter touches none of them, and the compiler cannot see any of them either. Done as one scripted substitution over `git ls-files`, anchored on the full `github.com/owncord/server` string. The anchor matters: `owncord-server` is a different identifier — the OTel `service.name` (`config/config.go`, `telemetry/telemetry_otel.go`) and the GHCR image name (`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern would have moved it. It is untouched: 10 occurrences across 9 files, before and after. 350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files, plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`, `docs/architecture/server.md:5`, and the ledger pair (`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of `FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in `Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard` rule keys on the module path, so import grouping is not configured anywhere). The plan's blast-radius estimate missed one thing, and it is the one that would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase letter, so in the 36 files where a module-local import shares a contiguous group with a third-party one, the module's imports must move above `github.com/go-chi/...`. `gofmt -l` was clean before the substitution and listed exactly 36 files after it; `gofmt -w` on those 36 restores it to clean. `gofmt` is an enforced gate — the `formatters` block in `Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails Lint. Verified: both directions, and the line accounting is exact. Every added line in this diff contains the new module path (728) and every removed line contains the old one (728); the count of changed lines containing neither is **zero**, so the gofmt re-sort moved module-path lines only and touched no third-party import. The residual check (`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns exactly two hits, both deliberately out of scope: the RL-13 row in `docs/audit-2026-08-23-repository-layout.md` and the measurement row in this phase's own plan. The compiler-invisible half was proven by reverting *only* `api/main_test.go:20` to the old path on the otherwise-renamed tree: `go build ./...` and `go vet ./api/` both still pass — they see nothing wrong — while `go test ./api/` FAILS, because the runtime function name now carries the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and none was needed). All four build-tag variants compile; `go vet ./...`, `go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass; `go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...` passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel) runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally against Go 1.26 because the packaged binary cannot load a 1.26 config — reports **0 issues**. `go run ./cmd/genprotocol` leaves `git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts` clean, so the rename does not reach the generated protocol constants. `npx prettier --check .` and `node .superpowers/render-ledger.mjs --check` pass. Not included: `docs/audit-2026-08-23-repository-layout.md` and `docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they are the audit row and the measurement that motivated this change, and rewriting them would erase the record of what was measured. They are why the residual check needs a two-path allowance rather than being empty; that allowance is stated above rather than hidden in a pathspec. `telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package that does not exist; the substitution carried the dead path forward verbatim as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because correcting a real observability bug inside a mechanical rename would hide it in a 350-file diff. It needs its own item. No `go.work`, no second module, and no vanity-import host was set up — the new path resolves against the real repository, but nothing imports this module as a library, so `go get` reachability was not exercised either way. Refs RL-13, L-12
554 lines
17 KiB
Go
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/J3vb/OwnCord/Server/auth"
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
"github.com/J3vb/OwnCord/Server/permissions"
|
|
"github.com/J3vb/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
|
|
}
|
|
}
|
|
}
|
|
}
|