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>
155 lines
5.4 KiB
Go
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()
|
|
}
|