aboutsummaryrefslogtreecommitdiffstats
path: root/internal/store/files_test.go
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-14 23:51:55 +0300
committergrm <grm@eyesin.space>2026-09-14 23:51:55 +0300
commit778cb72c8a0902bd0b8159ebd3bb7eff93f28c83 (patch)
treede21228c247e735591e88acbbf3bf4c83f2142e0 /internal/store/files_test.go
parent5f9fc2a8667a9438b6336d75026f9a455fc9c4ce (diff)
downloadblogspace-778cb72c8a0902bd0b8159ebd3bb7eff93f28c83.tar.gz
blogspace-778cb72c8a0902bd0b8159ebd3bb7eff93f28c83.tar.bz2
blogspace-778cb72c8a0902bd0b8159ebd3bb7eff93f28c83.zip
Turn the image library into a file library, with a per-blog upload limit
Bloggers want to attach PDFs, archives, audio and other files to posts, not only images. The Images tab becomes Files: any type is accepted, listed by kind with search, paging, rename and multi-file upload, and the editor's paste/drop/"Insert file" takes anything (images are shown, everything else becomes a link). The default limit goes from 5 to 10 MB and the superadmin can override it per blog from /admin/. Files stay in Postgres so one pg_dump is still the whole blog. The bytea column is STORAGE EXTERNAL and /media streams it in substring() slices, so serving never holds a whole file in memory whatever limit a blog gets. Serving any type on the root domain, which carries the session cookie, needs a policy: uploads are typed by sniffing (the extension may only refine a generic sniff to an allowlisted type) and only images, PDF, plain text, audio and video render inline; HTML, SVG, XML, scripts, archives and binaries always go out as application/octet-stream with Content-Disposition: attachment. The body cap moves out of requireAuth into guardPOST, which runs after withBlog has resolved the blog and so knows its limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/store/files_test.go')
-rw-r--r--internal/store/files_test.go69
1 files changed, 69 insertions, 0 deletions
diff --git a/internal/store/files_test.go b/internal/store/files_test.go
new file mode 100644
index 0000000..dfe7d55
--- /dev/null
+++ b/internal/store/files_test.go
@@ -0,0 +1,69 @@
+package store
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+// A reader over an in-memory slice, counting the fetches a query would cost.
+func testReader(data []byte, chunk int64) (*chunkReader, *int) {
+ calls := 0
+ return newChunkReader(int64(len(data)), chunk, func(off, n int64) ([]byte, error) {
+ calls++
+ return data[off : off+n], nil
+ }), &calls
+}
+
+func TestChunkReader(t *testing.T) {
+ data := make([]byte, 3<<20+7)
+ for i := range data {
+ data[i] = byte(i * 31)
+ }
+ r, calls := testReader(data, 1000)
+ got, err := io.ReadAll(r)
+ if err != nil || !bytes.Equal(got, data) {
+ t.Fatalf("ReadAll: err %v, %d bytes", err, len(got))
+ }
+ if want := (len(data) + 999) / 1000; *calls != want {
+ t.Errorf("%d fetches, want %d", *calls, want)
+ }
+ if n, _ := r.Seek(0, io.SeekEnd); n != int64(len(data)) {
+ t.Errorf("SeekEnd = %d", n)
+ }
+ if _, err := r.Seek(-1, io.SeekStart); err == nil {
+ t.Error("negative seek should fail")
+ }
+ r.Seek(1500, io.SeekStart)
+ buf := make([]byte, 1200)
+ if _, err := io.ReadFull(r, buf); err != nil || !bytes.Equal(buf, data[1500:2700]) {
+ t.Errorf("read after seek: %v", err)
+ }
+ if _, err := r.Read(buf); r.off != 2700 && err != nil {
+ t.Errorf("continuing read: %v", err)
+ }
+}
+
+func TestChunkReaderServeContent(t *testing.T) {
+ data := bytes.Repeat([]byte("0123456789"), 500)
+ r, calls := testReader(data, 512)
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/media/x", nil)
+ req.Header.Set("Range", "bytes=1500-2499")
+ http.ServeContent(w, req, "x.bin", time.Now(), r)
+ if w.Code != http.StatusPartialContent || !bytes.Equal(w.Body.Bytes(), data[1500:2500]) || w.Header().Get("Content-Range") != "bytes 1500-2499/5000" {
+ t.Errorf("range: code %d, %d bytes, %s", w.Code, w.Body.Len(), w.Header().Get("Content-Range"))
+ }
+ if *calls != 2 {
+ t.Errorf("a 1000-byte range cost %d fetches, want 2", *calls)
+ }
+ r, calls = testReader(data, 512)
+ w = httptest.NewRecorder()
+ http.ServeContent(w, httptest.NewRequest("HEAD", "/media/x", nil), "x.bin", time.Now(), r)
+ if w.Code != http.StatusOK || w.Header().Get("Content-Length") != "5000" || *calls != 0 {
+ t.Errorf("HEAD: code %d, length %s, %d fetches", w.Code, w.Header().Get("Content-Length"), *calls)
+ }
+}