From 778cb72c8a0902bd0b8159ebd3bb7eff93f28c83 Mon Sep 17 00:00:00 2001 From: grm Date: Mon, 14 Sep 2026 23:51:55 +0300 Subject: 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 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/web_test.go | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) (limited to 'internal/web/web_test.go') diff --git a/internal/web/web_test.go b/internal/web/web_test.go index d7de43a..e2bd335 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -63,7 +63,7 @@ func TestSubdomainLength(t *testing.T) { func TestRootRoutePrecedence(t *testing.T) { cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} s := NewServer(cfg, nil) - for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/b/alice/layout": 303, "/b/alice/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8": 303, "/account/password": 303} { + for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/b/alice/layout": 303, "/b/alice/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8": 303, "/b/alice/files": 303, "/account/password": 303} { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", path, nil) req.Host = "example.com" @@ -320,7 +320,7 @@ func TestPresets(t *testing.T) { func TestAllTemplatesParse(t *testing.T) { tpl := newTemplates(false) for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", - "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html", + "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/files.html", "dashboard/settings.html", "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "dashboard/layout.html", "dashboard/module_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", "blog/page.html", "blog/post.html", "blog/404.html"} { for _, l := range i18n.Languages() { @@ -365,7 +365,7 @@ func TestGreekCatalogComplete(t *testing.T) { add(goKey, string(b)) } // Keys that reach t/tr through a variable rather than a literal. - for _, m := range []map[string]string{moduleNames, areaNames} { + for _, m := range []map[string]string{moduleNames, areaNames, fileKindNames} { for _, v := range m { used[v] = true } @@ -422,17 +422,25 @@ func TestSubdomainWebadminRedirect(t *testing.T) { } } -func TestAppendImageMD(t *testing.T) { - img := &store.Image{ID: uuid.MustParse("6ba7b810-9dad-11d1-80b4-00c04fd430c8"), Filename: "cat].png"} +func TestFileMarkdown(t *testing.T) { + id := uuid.MustParse("6ba7b810-9dad-11d1-80b4-00c04fd430c8") + img := &store.File{ID: id, Filename: "cat].png", Kind: "image"} + doc := &store.File{ID: id, Filename: "notes\nv2.pdf", Kind: "document"} line := "![cat.png](/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8)" - if got := appendImageMD("", img); got != line+"\n" { + if got := fileMarkdown(img); got != line { + t.Errorf("image: %q", got) + } + if got := fileMarkdown(doc); got != "[notes v2.pdf](/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8)" { + t.Errorf("document should be a link: %q", got) + } + if got := appendFileMD("", img); got != line+"\n" { t.Errorf("empty body: %q", got) } - if got := appendImageMD("hello\n", img); got != "hello\n\n"+line+"\n" { - t.Errorf("with body: %q", got) + if got := appendFileMD("hello\n", img); got != "hello\n\n"+line+"\n" { + t.Errorf("appended: %q", got) } - if got := appendImageMD("hello", nil); got != "hello" { - t.Errorf("nil image should not change the body: %q", got) + if got := appendFileMD("hello", nil); got != "hello" { + t.Errorf("nil file should not change the body: %q", got) } } -- cgit v1.2.3