release / govulncheck (push) Successful in 29s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m26s
release / android (push) Successful in 5m27s
release / Build signed APK (push) Successful in 5m44s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A new server (no accounts, no library) offers "Restore a backup" beside "Create account". The file goes up as the request body to POST /api/auth/restore with the setup token from the log in X-Setup-Token, since the backup's admins become this server's. It loads in one transaction, then a library scan starts and finds each file at its path. The same load runs offline as `minstrel restore FILE`, which migrates the schema first. That path alone gets a 1 GiB, 15-minute body allowance instead of 4 MiB and 30s, and skips the JSON-only check for cookie writes: a stale cookie from the old server would otherwise refuse the upload, and the custom header already rules out a cross-site form. The library sync answers 410 cursor_ahead to a cursor past the last one the server has handed out, read from the change sequence, so an app synced with the old server after the backup was made starts over instead of skipping the ids the restored server reuses. Restore now leaves each sequence as having issued the restored maximum, so that reading holds; a compacted log still counts its handed-out cursors. backup.Read keeps the underlying error in its chain, so an oversized upload answers 413 rather than "not a backup". The hosting docs say how to put the music back first and what is entered again afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
6.3 KiB
Go
177 lines
6.3 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
|
|
|
|
// restorePath takes a whole backup file, so it gets room for one: the file is
|
|
// the database alone (the music travels separately), which is tens of MB for
|
|
// a large library.
|
|
const (
|
|
restorePath = "/api/auth/restore"
|
|
maxRestoreBody = 1 << 30
|
|
restoreReadTimeout = 15 * time.Minute
|
|
)
|
|
|
|
// bodyLimits is the size and time a request may take to send its body.
|
|
func bodyLimits(r *http.Request) (int64, time.Duration) {
|
|
if r.Method == http.MethodPost && r.URL.Path == restorePath {
|
|
return maxRestoreBody, restoreReadTimeout
|
|
}
|
|
return maxRequestBody, bodyReadTimeout
|
|
}
|
|
|
|
// 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.
|
|
size, wait := bodyLimits(r)
|
|
armed := rc.SetReadDeadline(time.Now().Add(wait)) == nil
|
|
body := http.MaxBytesReader(w, r.Body, size)
|
|
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.
|
|
//
|
|
// The restore upload is a backup file, not JSON. It is safe without the check:
|
|
// it acts only with the setup token in a request header, which a form cannot
|
|
// send and a cross-site fetch cannot send without a preflight.
|
|
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/") || r.URL.Path == restorePath {
|
|
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()
|
|
}
|