diff options
| author | grm <grm@eyesin.space> | 2026-09-14 23:51:55 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-14 23:51:55 +0300 |
| commit | 778cb72c8a0902bd0b8159ebd3bb7eff93f28c83 (patch) | |
| tree | de21228c247e735591e88acbbf3bf4c83f2142e0 /internal/web/filetype_test.go | |
| parent | 5f9fc2a8667a9438b6336d75026f9a455fc9c4ce (diff) | |
| download | blogspace-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.go | 192 |
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 +} |
