aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/server.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/server.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/server.go')
-rw-r--r--internal/web/server.go93
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)
+ }
})
}