Files
minstrel/internal/server/hygiene.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -04:00

155 lines
5.4 KiB
Go

package server
import (
"io"
"mime"
"net/http"
"strings"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
// maxRequestBody caps every request body. The largest legitimate one is a
// playlist save of a few thousand track ids, well under 1 MiB; 4 MiB leaves
// room without letting one request hold arbitrary memory.
const maxRequestBody = 4 << 20
// bodyReadTimeout bounds how long a client may take to send a request body.
const bodyReadTimeout = 30 * time.Second
// limitRequestBody caps the body size and the time allowed to deliver it.
//
// Why not http.Server.ReadTimeout: that deadline stays armed for the whole
// request, and once a handler has consumed the body, net/http's background
// read hits it and cancels the request context. That would cut off audio
// streams and the SSE event stream at the timeout. Here the deadline is set
// only on requests that carry a body, and cleared the moment the body has
// been read, so long-running responses are never affected.
func limitRequestBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body == nil || r.Body == http.NoBody {
next.ServeHTTP(w, r)
return
}
rc := http.NewResponseController(w)
// Best-effort: a ResponseWriter that can't set deadlines (a test
// recorder) still gets the size limit.
armed := rc.SetReadDeadline(time.Now().Add(bodyReadTimeout)) == nil
body := http.MaxBytesReader(w, r.Body, maxRequestBody)
if armed {
body = &deadlineClearingBody{ReadCloser: body, rc: rc}
}
r.Body = body
next.ServeHTTP(w, r)
})
}
// deadlineClearingBody lifts the read deadline once the body is exhausted.
// A deadline change applies to pending reads too, so this also releases the
// background read net/http starts at end-of-body.
type deadlineClearingBody struct {
io.ReadCloser
rc *http.ResponseController
cleared bool
}
func (b *deadlineClearingBody) Read(p []byte) (int, error) {
n, err := b.ReadCloser.Read(p)
if err != nil && !b.cleared {
b.cleared = true
_ = b.rc.SetReadDeadline(time.Time{})
}
return n, err
}
func (b *deadlineClearingBody) Close() error {
if !b.cleared {
b.cleared = true
_ = b.rc.SetReadDeadline(time.Time{})
}
return b.ReadCloser.Close()
}
// requireJSONForCookieWrites refuses a state-changing /api request that is
// authenticated by the session cookie unless its body is JSON.
//
// SameSite=Strict already keeps the cookie off cross-site requests. This is
// the backstop for the case SameSite cannot see: a sibling app on the same
// registrable domain (another *.fabledsword.com service) counts as same-site.
// An HTML form or a no-preflight fetch can only send form-encoded, multipart
// or text/plain bodies, so demanding application/json closes that path.
// Bearer-token requests and /rest (query-string auth) carry nothing a browser
// attaches on its own, so they are not checked. The Android app does send
// the cookie, but every body it sends is JSON (Retrofit's kotlinx converter)
// and its bodiless writes carry no Content-Type, so it passes unchanged.
func requireJSONForCookieWrites(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
if c, err := r.Cookie(auth.SessionCookieName); err != nil || c.Value == "" {
next.ServeHTTP(w, r)
return
}
ct := r.Header.Get("Content-Type")
if ct == "" && (r.ContentLength == 0 || r.Body == nil || r.Body == http.NoBody) {
// No body, no content type: nothing a form could have sent.
next.ServeHTTP(w, r)
return
}
if mt, _, err := mime.ParseMediaType(ct); err != nil || mt != "application/json" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnsupportedMediaType)
_, _ = io.WriteString(w, `{"error":{"code":"unsupported_media_type","message":"request body must be application/json"}}`)
return
}
next.ServeHTTP(w, r)
})
}
// securityHeaders sets the response headers every response should carry.
// Each is set only when the handler hasn't, so a route with a reason to
// differ keeps its own value. The document's Content-Security-Policy is set
// by the SPA handler, which knows the inline-script hashes.
//
// HSTS goes out only when the request reached us over HTTPS as the trusted
// proxy reports it: sent over plain HTTP it is ignored by browsers at best,
// and the app never forces HTTPS.
func securityHeaders(hops func() int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
setDefault(h, "X-Content-Type-Options", "nosniff")
setDefault(h, "Referrer-Policy", "strict-origin-when-cross-origin")
setDefault(h, "Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()")
// frame-ancestors in the document's CSP covers modern browsers;
// this covers the rest, and every non-HTML response.
setDefault(h, "X-Frame-Options", "DENY")
if auth.IsHTTPS(r, hopsOrZero(hops)) {
setDefault(h, "Strict-Transport-Security", "max-age=31536000")
}
next.ServeHTTP(w, r)
})
}
}
func setDefault(h http.Header, key, value string) {
if h.Get(key) == "" {
h.Set(key, value)
}
}
func hopsOrZero(hops func() int) int {
if hops == nil {
return 0
}
return hops()
}