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/server.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/server.go')
| -rw-r--r-- | internal/web/server.go | 93 |
1 files changed, 69 insertions, 24 deletions
diff --git a/internal/web/server.go b/internal/web/server.go index 4bb408e..f005b76 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -187,36 +187,57 @@ func (s *Server) session(next http.Handler) http.Handler { }) } -// requireAuth redirects anonymous users to the login page. -func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { +// requireLogin sends anonymous users to the login page and nothing else; the +// body cap and the CSRF check come in guardPOST, once the upload limit is known. +func (s *Server) requireLogin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - u := currentUser(r) - if u == nil { + if currentUser(r) == nil { http.Redirect(w, r, "/webadmin?next="+r.URL.Path, http.StatusSeeOther) return } - if r.Method == http.MethodPost { - // Cap the request body before any form parsing (uploads included). - r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+1<<20) - if err := parseForm(r); err != nil { - var tooBig *http.MaxBytesError - if errors.As(err, &tooBig) { - s.plainError(w, http.StatusRequestEntityTooLarge, s.trf(r, "Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20)) - return - } - s.plainError(w, http.StatusBadRequest, s.tr(r, "Could not read the form.")) - return - } - if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) { - s.plainError(w, http.StatusForbidden, s.tr(r, "Form expired or invalid. Go back, reload the page and try again.")) - return - } - } next(w, r) } } +// guardPOST caps a POST body at limit plus 1 MB of form overhead, parses it and +// checks the CSRF token; false means an error response was written. Other +// methods pass straight through. +func (s *Server) guardPOST(w http.ResponseWriter, r *http.Request, limit int64) bool { + if r.Method != http.MethodPost { + return true + } + u := currentUser(r) + // Cap the request body before any form parsing (uploads included). + r.Body = http.MaxBytesReader(w, r.Body, limit+1<<20) + if err := parseForm(r); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + s.fail(w, r, http.StatusRequestEntityTooLarge, s.trf(r, "Upload too large: the limit is %d MB.", limit>>20)) + return false + } + s.fail(w, r, http.StatusBadRequest, s.tr(r, "Could not read the form.")) + return false + } + if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) { + s.fail(w, r, http.StatusForbidden, s.tr(r, "Form expired or invalid. Go back, reload the page and try again.")) + return false + } + return true +} + +// requireAuth is for management pages outside a blog (dashboard, password, +// admin): logged in, small forms only. +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return s.requireLogin(func(w http.ResponseWriter, r *http.Request) { + if s.guardPOST(w, r, 0) { + next(w, r) + } + }) +} + // parseForm parses urlencoded or multipart bodies, surfacing size errors. +// Multipart parts beyond 1 MB in total spill to temp files, which net/http +// removes once the handler returns. func parseForm(r *http.Request) error { ct := r.Header.Get("Content-Type") if strings.HasPrefix(ct, "multipart/form-data") { @@ -225,6 +246,20 @@ func parseForm(r *http.Request) error { return r.ParseForm() } +// wantsJSON is how the upload scripts ask for answers they can parse. +func wantsJSON(r *http.Request) bool { + return strings.Contains(r.Header.Get("Accept"), "application/json") +} + +// fail answers an error as JSON when the client asked for it, else as the plain page. +func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg string) { + if wantsJSON(r) { + writeJSON(w, status, map[string]string{"error": msg}) + return + } + s.plainError(w, status, msg) +} + func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { if !currentUser(r).IsSuperadmin() { @@ -235,9 +270,16 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { }) } -// withBlog resolves /b/{sub}/... and enforces owner-or-superadmin. +// withBlog resolves /b/{sub}/..., enforces owner-or-superadmin and caps a POST +// at the blog's own upload limit. func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc { - return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + return s.withBlogFiles(1, next) +} + +// withBlogFiles is withBlog for a form that may carry up to n files at once +// (the Files page's multi-upload): the body cap is n limits. +func (s *Server) withBlogFiles(n int, next http.HandlerFunc) http.HandlerFunc { + return s.requireLogin(func(w http.ResponseWriter, r *http.Request) { u := currentUser(r) r, err := s.resolveBlog(r, r.PathValue("sub")) if err != nil { @@ -257,7 +299,10 @@ func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc { if blog.OwnerID != u.ID { lang = s.userLang(r, u) } - next(w, withLang(r, lang)) + r = withLang(r, lang) + if s.guardPOST(w, r, int64(n)*blog.UploadLimit(s.cfg)) { + next(w, r) + } }) } |
