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/templates/admin/delete_user.html | 2 +- internal/web/templates/admin/index.html | 9 +- internal/web/templates/dashboard/files.html | 95 ++++++++++++++++++++++ internal/web/templates/dashboard/images.html | 24 ------ internal/web/templates/dashboard/overview.html | 2 +- internal/web/templates/dashboard/page_form.html | 2 +- internal/web/templates/dashboard/post_form.html | 2 +- internal/web/templates/dashboard/section_form.html | 2 +- internal/web/templates/partials/dashnav.html | 2 +- internal/web/templates/partials/editor.html | 18 ++-- internal/web/templates/partials/mdhelp.html | 2 +- 11 files changed, 121 insertions(+), 39 deletions(-) create mode 100644 internal/web/templates/dashboard/files.html delete mode 100644 internal/web/templates/dashboard/images.html (limited to 'internal/web/templates') diff --git a/internal/web/templates/admin/delete_user.html b/internal/web/templates/admin/delete_user.html index 886ff1a..79bf2fc 100644 --- a/internal/web/templates/admin/delete_user.html +++ b/internal/web/templates/admin/delete_user.html @@ -2,7 +2,7 @@ {{define "content"}}

{{tf "Delete %s?" .Data.target.Username}}

-

{{t "This permanently deletes the user"}} {{.Data.target.Username}}{{t ", their blog, and every page, post and image in it."}}

+

{{t "This permanently deletes the user"}} {{.Data.target.Username}}{{t ", their blog, and every page, post and file in it."}}

diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html index 0e49e62..055671e 100644 --- a/internal/web/templates/admin/index.html +++ b/internal/web/templates/admin/index.html @@ -6,11 +6,18 @@

- + {{range .Data.users}} +
{{t "User"}}{{t "Role"}}{{t "Blog"}}{{t "Since"}}{{t "Actions"}}
{{t "User"}}{{t "Role"}}{{t "Blog"}}{{t "Upload limit"}}{{t "Since"}}{{t "Actions"}}
{{.Username}}{{if .Disabled}} {{t "disabled"}}{{end}} {{t .Role}} {{if .Subdomain}}{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}} {{t "view"}} ↗{{else}}—{{end}}{{if .BlogID}}{{if .MaxUploadBytes}}{{size .MaxUploadBytes}}{{else}}{{size $.Data.defaultLimit}} ({{t "default"}}){{end}} +
{{t "change"}} + + + + {{t "blank = default"}} +
{{else}}—{{end}}
{{date .CreatedAt}} {{if ne .ID $.User.ID}} diff --git a/internal/web/templates/dashboard/files.html b/internal/web/templates/dashboard/files.html new file mode 100644 index 0000000..b42cec2 --- /dev/null +++ b/internal/web/templates/dashboard/files.html @@ -0,0 +1,95 @@ +{{define "title"}}{{t "Files"}} · {{.Blog.Title}}{{end}} +{{define "content"}} +{{$b := .Blog.Subdomain}} +
+

{{t "Files"}}

+

{{t "Files are stored with your blog. Put one in a post with"}} {{t "Insert file"}} {{t "in the editor, or copy its line from the table."}} {{tf "%d files, %s in use." .Data.count (size .Data.bytes)}}

+
+
+
+ + +

+
    +
    +
    +
    + + {{t "All"}} + {{range .Data.kinds}}{{t (index $.Data.kindNames .)}}{{end}} + + {{if .Data.kind}}{{end}} + +
    +
    +{{if .Data.files}} + + + {{range .Data.files}} + + + + + + + + {{end}} +
    {{t "Name"}}{{t "Kind"}}{{t "Size"}}{{t "Date"}}Markdown
    {{if eq .Kind "image"}}{{else}}{{.Badge}}{{end}}{{.Filename}} +
    {{t "rename"}} +
    + + + +
    {{t (index $.Data.kindNames .Kind)}}{{size .Size}}{{date .CreatedAt}}{{t "download"}} · +
    +{{else if or .Data.q .Data.kind}}

    {{t "No files match."}}

    +{{else}}

    {{t "No files yet."}}

    {{end}} +
    +{{if gt .Data.lastPage 1}} +
    + {{if gt .Data.pageNum 1}}← {{t "Newer"}}{{end}} + {{tf "Page %d of %d" .Data.pageNum .Data.lastPage}} + {{if lt .Data.pageNum .Data.lastPage}}{{t "Older"}} →{{end}} +
    +{{end}} + +{{end}} diff --git a/internal/web/templates/dashboard/images.html b/internal/web/templates/dashboard/images.html deleted file mode 100644 index 8b9faa3..0000000 --- a/internal/web/templates/dashboard/images.html +++ /dev/null @@ -1,24 +0,0 @@ -{{define "title"}}{{t "Images"}} · {{.Blog.Title}}{{end}} -{{define "content"}} -
    -

    {{t "Images"}}

    -

    {{t "Images are stored with your blog. Put one in a post with"}} {{t "Insert image"}} {{t "in the editor, or copy its line from below."}}

    -
    -
    -
    - - -

    -
    -
    - -{{end}} diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html index e021495..0f611cd 100644 --- a/internal/web/templates/dashboard/overview.html +++ b/internal/web/templates/dashboard/overview.html @@ -7,7 +7,7 @@

    {{t "Pages"}}

    diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html index 08c4a43..18d1119 100644 --- a/internal/web/templates/dashboard/page_form.html +++ b/internal/web/templates/dashboard/page_form.html @@ -8,7 +8,7 @@ - {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} + {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}}

    diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html index 8069a9a..d12d15f 100644 --- a/internal/web/templates/dashboard/post_form.html +++ b/internal/web/templates/dashboard/post_form.html @@ -15,7 +15,7 @@

    - {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} + {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}}

    diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html index bc3ba34..b401d9c 100644 --- a/internal/web/templates/dashboard/section_form.html +++ b/internal/web/templates/dashboard/section_form.html @@ -24,7 +24,7 @@

    {{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} {{t "Layout"}}) {{t "the announcement moves to the main content instead."}}

    - {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} + {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}}

    diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html index 65b0ed5..8b44e55 100644 --- a/internal/web/templates/partials/dashnav.html +++ b/internal/web/templates/partials/dashnav.html @@ -23,7 +23,7 @@ {{t "Announcements"}} {{t "Layout"}} {{t "Design"}} - {{t "Images"}} + {{t "Files"}} {{t "Settings"}} {{t "View blog"}} ↗ diff --git a/internal/web/templates/partials/editor.html b/internal/web/templates/partials/editor.html index eb2e315..4371624 100644 --- a/internal/web/templates/partials/editor.html +++ b/internal/web/templates/partials/editor.html @@ -1,9 +1,9 @@ {{define "editor"}}

    - + - {{t "…or paste / drop an image into the text."}} - + {{t "…or paste / drop a file into the text. Images are shown, other files linked."}} +
    {{template "mdhelp"}} {{end}} diff --git a/internal/web/templates/partials/mdhelp.html b/internal/web/templates/partials/mdhelp.html index 748452e..5489510 100644 --- a/internal/web/templates/partials/mdhelp.html +++ b/internal/web/templates/partials/mdhelp.html @@ -2,7 +2,7 @@ {{t "Formatting cheat-sheet"}} - +
    # {{t "Heading"}}, ## {{t "Smaller heading"}}**{{t "bold"}}**, *{{t "italic"}}*
    [{{t "link text"}}](https://example.org)![{{t "description"}}](/media/…) — {{t "Insert image"}} {{t "writes this for you"}}
    [{{t "link text"}}](https://example.org)![{{t "description"}}](/media/…), [{{t "file name"}}](/media/…) — {{t "Insert file"}} {{t "writes this for you"}}
    - {{t "list item"}} / 1. {{t "numbered"}}> {{t "quote"}}, `{{t "code"}}`, --- {{t "for a line"}}

    {{t "Blank line = new paragraph. Press Enter once for a line break."}}

    -- cgit v1.2.3