aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md2
-rw-r--r--internal/web/handlers_media.go13
-rw-r--r--internal/web/web_test.go13
3 files changed, 28 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 1cc12fd..6d7b355 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -294,6 +294,8 @@ internal/web/ server.go (host router, middleware, render helpers)
is. The bytes are streamed by `BlogStore.FileReader` (a `chunkReader` over
`substring()`, 512 KiB per query, Range requests included), which is why
the per-blog limit is capped at 1024 MB (`maxUploadMB`: int4 offsets).
+ Only a single range is honoured (`singleRangeOnly` drops multi-range
+ headers): ServeContent's multipart answer would cost a query per range.
Ids are immutable, so a renamed file keeps its old download name in
browsers that cached it. Markdown keeps the relative `/media/…` form
because post bodies render on the blog host; `fileMarkdown` writes
diff --git a/internal/web/handlers_media.go b/internal/web/handlers_media.go
index 7c4b739..99fdb64 100644
--- a/internal/web/handlers_media.go
+++ b/internal/web/handlers_media.go
@@ -4,6 +4,7 @@ import (
"errors"
"io/fs"
"net/http"
+ "strings"
"github.com/google/uuid"
"github.com/gramanas/blogspace/internal/store"
@@ -38,9 +39,21 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("ETag", etag)
w.Header().Set("X-Content-Type-Options", "nosniff")
+ singleRangeOnly(r)
http.ServeContent(w, r, f.Filename, f.CreatedAt, blogStore(r).FileReader(r.Context(), f))
}
+// singleRangeOnly drops a multi-range request so the file is served whole.
+// ServeContent honours any number of ranges and chunkReader caches one slice,
+// so a header alternating between two chunks would cost a substring() query
+// per range — tens of thousands per request. Browsers and download managers
+// only ever ask for one range.
+func singleRangeOnly(r *http.Request) {
+ if strings.Contains(r.Header.Get("Range"), ",") {
+ r.Header.Del("Range")
+ }
+}
+
// handleFavicon answers the browsers that ask for /favicon.ico regardless of
// the <link rel="icon"> tags: the blog's own icon if it set one, else the
// Blogspace default. Without this the request would render the 404 page.
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index fc38536..1d4db4c 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -907,3 +907,16 @@ func TestImagePickKeepsOlderChoice(t *testing.T) {
}
}
}
+
+func TestSingleRangeOnly(t *testing.T) {
+ for in, want := range map[string]string{"bytes=0-9": "bytes=0-9", "bytes=0-0,1-1": "", "": ""} {
+ req := httptest.NewRequest("GET", "/media/x", nil)
+ if in != "" {
+ req.Header.Set("Range", in)
+ }
+ singleRangeOnly(req)
+ if got := req.Header.Get("Range"); got != want {
+ t.Errorf("Range %q: kept %q, want %q", in, got, want)
+ }
+ }
+}