mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
8a1aaf61a8
* fix(server): a failed flush reaches the handler through chi's wrappers (BUG-3827) chi's WrapResponseWriter (logger, metrics, MCP audit) and its compressor implement Flush but not FlushError, so http.ResponseController.Flush called Flush and returned nil. A flush to a client that had stopped reading "succeeded", and sseStream's Dead/Err never saw it; slots were released only because the request context ended. flushErrorWriter wraps each of the four chi writers. Its FlushError runs chi's own Flush (status bookkeeping, the compressor's encoder) and then flushes the writer beneath through a ResponseController, which returns net/http's sticky write error. It unwraps to the chi writer, so write deadlines still reach the connection. Pins: a stalled flush through the outer chain (identity and gzip), the activity stream seeing its failed keepalive flush (test-only seam Server.sseStreamFailedForTesting), and MCPAuditLog with its audit writer started (it is a passthrough without one). With FlushError renamed away, all four fail. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(server): the flush-error wrapper keeps exactly the chi writer's interfaces (BUG-3827 codex r1) The r0 wrapper hid io.ReaderFrom (io.Copy's sendfile path) and http.Pusher, and added Hijacker and Flusher where chi had none. withFlushError now leaves a non-Flusher alone and returns a variant with exactly the chi writer's Hijacker / ReaderFrom / Pusher set; a shape chi never produces is left unwrapped. TestBUG3827_WrapperKeepsTheChiWritersInterfaces pins all seven NewWrapResponseWriter shapes plus the compressor; against the r0 wrapper 7 of 8 fail. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(server): each chain leg proves its encoder, and deflate gets a leg (BUG-3827 codex r2) openStalledStream returns the response head; every leg of the stalled-flush chain test asserts its Content-Encoding, and a deflate leg joins identity and gzip. With compression bypassed, gzip and deflate fail on that assertion. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
74 lines
2.4 KiB
Go
74 lines
2.4 KiB
Go
package server
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"time"
|
|
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
// redactedQueryKeys are query-string keys whose values must never appear
|
|
// in request logs. The server's POST endpoints reject these via header-
|
|
// only contracts, but a misuse via GET (e.g. an operator pasting
|
|
// /setup?token=<x> instead of using the fragment form, or a bug in a
|
|
// future feature that accepts the same key) would otherwise persist
|
|
// the secret in `logs/server.log` AND in any log-aggregation pipeline.
|
|
//
|
|
// Add new entries here whenever a new sensitive query key is
|
|
// introduced. The list is intentionally short — most secrets should
|
|
// move to the request body or a header instead.
|
|
var redactedQueryKeys = regexp.MustCompile(`(?i)(\b(?:token|password|secret|api[_-]?key)=)[^&]*`)
|
|
|
|
// redactQueryString replaces sensitive query-key values with REDACTED
|
|
// while leaving the rest of the query string intact for diagnostics.
|
|
// Operates on the raw query string to avoid the alphabetization that
|
|
// url.Values.Encode() would impose (which would break log diffing
|
|
// against the actual request).
|
|
func redactQueryString(raw string) string {
|
|
if raw == "" {
|
|
return raw
|
|
}
|
|
return redactedQueryKeys.ReplaceAllString(raw, "${1}REDACTED")
|
|
}
|
|
|
|
// StructuredLogger is a chi-compatible request logger that writes structured
|
|
// log entries via slog. It replaces chi's default Logger middleware.
|
|
func StructuredLogger(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
|
|
next.ServeHTTP(withFlushError(ww), r) // BUG-3827: chi drops flush errors
|
|
|
|
duration := time.Since(start)
|
|
status := ww.Status()
|
|
|
|
level := slog.LevelInfo
|
|
if status >= 500 {
|
|
level = slog.LevelError
|
|
} else if status >= 400 {
|
|
level = slog.LevelWarn
|
|
}
|
|
|
|
attrs := []slog.Attr{
|
|
slog.String("method", r.Method),
|
|
slog.String("path", r.URL.Path),
|
|
slog.Int("status", status),
|
|
slog.Duration("duration", duration),
|
|
slog.Int("bytes", ww.BytesWritten()),
|
|
}
|
|
|
|
if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" {
|
|
attrs = append(attrs, slog.String("request_id", reqID))
|
|
}
|
|
|
|
if r.URL.RawQuery != "" {
|
|
attrs = append(attrs, slog.String("query", redactQueryString(r.URL.RawQuery)))
|
|
}
|
|
|
|
slog.LogAttrs(r.Context(), level, "http request", attrs...)
|
|
})
|
|
}
|