aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/filetype_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/web/filetype_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/web/filetype_test.go')
-rw-r--r--internal/web/filetype_test.go192
1 files changed, 192 insertions, 0 deletions
diff --git a/internal/web/filetype_test.go b/internal/web/filetype_test.go
new file mode 100644
index 0000000..1f1d89e
--- /dev/null
+++ b/internal/web/filetype_test.go
@@ -0,0 +1,192 @@
+package web
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/gramanas/blogspace/internal/auth"
+ "github.com/gramanas/blogspace/internal/config"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+func TestFileType(t *testing.T) {
+ png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 16))
+ cases := []struct {
+ head string
+ name string
+ ct, kind string
+ }{
+ {string(png), "photo.png", "image/png", "image"},
+ {string(png), "evil.html", "image/png", "image"}, // bytes win over the name
+ {"\x00\x00\x01\x00\x01\x00", "icon.ico", "image/x-icon", "image"},
+ {"%PDF-1.4 ...", "paper.pdf", "application/pdf", "document"},
+ {"PK\x03\x04junk", "site.zip", "application/zip", "archive"},
+ {"\x1f\x8b\x08junk", "site.tar.gz", "application/x-gzip", "archive"},
+ {"ID3\x03\x00\x00\x00", "song.mp3", "audio/mpeg", "audio"},
+ {"OggS\x00\x02", "song.ogg", "audio/ogg", "audio"},
+ {"OggS\x00\x02", "clip.ogv", "video/ogg", "video"},
+ {"\x1a\x45\xdf\xa3junk", "clip.webm", "video/webm", "video"},
+ {"hello world", "notes.txt", "text/plain", "document"},
+ {"hello world", "server.log", "text/plain", "document"}, // text/* names only confirm the sniff
+ {"hello world", "notes.md", "text/plain", "document"},
+ {"hello world", "README", "text/plain", "document"}, // no extension: the bytes are all we have
+ {"\x00\x01\x02\x03\xff\xfe", "font.ttf", "application/octet-stream", "other"},
+ {"hello world", "data.csv", "text/csv", "document"},
+ {"hello world", "evil.html", "application/octet-stream", "other"},
+ {"<svg xmlns='http://www.w3.org/2000/svg'><script>1</script></svg>", "evil.svg", "application/octet-stream", "other"},
+ {"<?xml version='1.0'?><svg/>", "pic.svg", "application/octet-stream", "other"},
+ {"<!DOCTYPE html><html>", "page.html", "application/octet-stream", "other"},
+ {"alert(1)", "x.js", "application/octet-stream", "other"},
+ {"\x00\x01\x02\x03\xff\xfe", "song.mp3", "audio/mpeg", "audio"},
+ {"\x00\x01\x02\x03\xff\xfe", "tool.exe", "application/octet-stream", "other"},
+ {"\x00\x01\x02\x03\xff\xfe", "book.epub", "application/epub+zip", "document"},
+ {"\x00\x01\x02\x03\xff\xfe", "noext", "application/octet-stream", "other"},
+ }
+ for _, c := range cases {
+ ct, kind := fileType([]byte(c.head), c.name)
+ if ct != c.ct || kind != c.kind {
+ t.Errorf("fileType(%q, %q) = %s, %s; want %s, %s", c.head[:min(8, len(c.head))], c.name, ct, kind, c.ct, c.kind)
+ }
+ }
+}
+
+func TestServedAs(t *testing.T) {
+ cases := []struct {
+ ct string
+ download bool
+ ctype string
+ disp string
+ }{
+ {"image/png", false, "image/png", "inline"},
+ {"image/png", true, "image/png", "attachment"},
+ {"application/pdf", false, "application/pdf", "inline"},
+ {"text/plain", false, "text/plain; charset=utf-8", "inline"},
+ {"audio/mpeg", false, "audio/mpeg", "inline"},
+ {"video/mp4", false, "video/mp4", "inline"},
+ {"application/zip", false, "application/octet-stream", "attachment"},
+ {"text/html", false, "application/octet-stream", "attachment"},
+ {"image/svg+xml", false, "application/octet-stream", "attachment"},
+ {"application/javascript", true, "application/octet-stream", "attachment"},
+ }
+ for _, c := range cases {
+ ctype, disp := servedAs(c.ct, c.download)
+ if ctype != c.ctype || disp != c.disp {
+ t.Errorf("servedAs(%s, %v) = %s, %s; want %s, %s", c.ct, c.download, ctype, disp, c.ctype, c.disp)
+ }
+ }
+}
+
+func TestContentDisposition(t *testing.T) {
+ if got := contentDisposition("inline", "a.pdf"); got != `inline; filename="a.pdf"; filename*=utf-8''a.pdf` {
+ t.Errorf("ascii: %s", got)
+ }
+ got := contentDisposition("attachment", `έγγρα"φο.pdf`)
+ if !strings.HasPrefix(got, `attachment; filename="________.pdf"; filename*=utf-8''%CE%AD`) {
+ t.Errorf("greek: %s", got)
+ }
+}
+
+func TestCleanFilename(t *testing.T) {
+ cases := map[string]string{
+ `C:\Users\me\photo.png`: "photo.png",
+ "../../etc/passwd": "passwd",
+ " spaced .txt ": "spaced .txt",
+ "": "file",
+ ".": "file",
+ "/": "file",
+ "a\"b'c\x00d.txt": "abcd.txt",
+ "φωτογραφία.jpg": "φωτογραφία.jpg",
+ }
+ for in, want := range cases {
+ if got := cleanFilename(in); got != want {
+ t.Errorf("cleanFilename(%q) = %q, want %q", in, got, want)
+ }
+ }
+ if got := cleanFilename(strings.Repeat("α", 200)); len([]rune(got)) != 120 {
+ t.Errorf("long name not capped: %d runes", len([]rune(got)))
+ }
+}
+
+func TestHumanSize(t *testing.T) {
+ cases := map[int64]string{0: "1 KB", 100: "1 KB", 512 << 10: "512 KB", 1 << 20: "1 MB", 1536 << 10: "1.5 MB", 10 << 20: "10 MB"}
+ for in, want := range cases {
+ if got := humanSize(in); got != want {
+ t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestPageBounds(t *testing.T) {
+ cases := []struct{ total, per, n, offset, page, last int }{
+ {0, 50, 1, 0, 1, 1}, {0, 50, 7, 0, 1, 1}, {50, 50, 2, 0, 1, 1},
+ {120, 50, 3, 100, 3, 3}, {120, 50, 9, 100, 3, 3}, {120, 50, 0, 0, 1, 3}, {120, 50, 2, 50, 2, 3},
+ }
+ for _, c := range cases {
+ o, p, l := pageBounds(c.total, c.per, c.n)
+ if o != c.offset || p != c.page || l != c.last {
+ t.Errorf("pageBounds(%d,%d,%d) = %d,%d,%d; want %d,%d,%d", c.total, c.per, c.n, o, p, l, c.offset, c.page, c.last)
+ }
+ }
+}
+
+func TestFileBadge(t *testing.T) {
+ for name, want := range map[string]string{"a.pdf": "PDF", "site.tar.gz": "GZ", "README": "FILE", "x.verylongext": "FILE", "α.ΈΓΓΡΑΦΟ": "FILE", "n.txt": "TXT"} {
+ if got := (store.File{Filename: name}).Badge(); got != want {
+ t.Errorf("Badge(%q) = %q, want %q", name, got, want)
+ }
+ }
+}
+
+// guardPOST runs before any store access, so it can be exercised with a nil store.
+func TestGuardPOST(t *testing.T) {
+ secret := []byte("test-secret")
+ s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: secret, MaxUploadBytes: 1 << 20}, nil)
+ u := &store.User{ID: 1}
+ post := func(body string, accept string) (*httptest.ResponseRecorder, *http.Request) {
+ r := httptest.NewRequest("POST", "/b/alice/files/upload", strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if accept != "" {
+ r.Header.Set("Accept", accept)
+ }
+ r = withLang(r.WithContext(context.WithValue(r.Context(), ctxUser, u)), "en")
+ return httptest.NewRecorder(), r
+ }
+ token := auth.CSRFToken(secret, u.ID, u.TokenVersion)
+
+ // over the cap (limit + 1 MB overhead)
+ w, r := post("x="+strings.Repeat("a", 2<<20+10), "")
+ if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusRequestEntityTooLarge || !strings.Contains(w.Body.String(), "1 MB") {
+ t.Errorf("too big: code %d body %q", w.Code, w.Body.String())
+ }
+ w, r = post("x="+strings.Repeat("a", 2<<20+10), "application/json")
+ var j map[string]string
+ if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusRequestEntityTooLarge || json.NewDecoder(w.Body).Decode(&j) != nil || j["error"] == "" {
+ t.Errorf("too big (json): code %d", w.Code)
+ }
+ // a bigger limit lets the same body through (CSRF aside)
+ w, r = post("x="+strings.Repeat("a", 2<<20+10)+"&_csrf="+url.QueryEscape(token), "")
+ if !s.guardPOST(w, r, 4<<20) {
+ t.Errorf("under a 4 MB limit: code %d", w.Code)
+ }
+ // missing / valid token
+ w, r = post("x=1", "")
+ if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusForbidden {
+ t.Errorf("missing csrf: code %d", w.Code)
+ }
+ w, r = post("x=1&_csrf="+url.QueryEscape(token), "")
+ if !s.guardPOST(w, r, 1<<20) || r.FormValue("x") != "1" {
+ t.Errorf("valid token: code %d", w.Code)
+ }
+ // GET is never touched
+ r = httptest.NewRequest("GET", "/b/alice/files", nil)
+ if !s.guardPOST(httptest.NewRecorder(), r, 0) {
+ t.Error("GET should pass")
+ }
+ _ = bytes.MinRead
+}