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/filetype.go | 196 +++++++++++++++ internal/web/filetype_test.go | 192 +++++++++++++++ internal/web/handlers_admin.go | 34 ++- internal/web/handlers_design.go | 174 +------------- internal/web/handlers_files.go | 264 +++++++++++++++++++++ internal/web/handlers_media.go | 13 +- internal/web/handlers_pages.go | 6 +- internal/web/handlers_posts.go | 6 +- internal/web/handlers_sections.go | 6 +- internal/web/routes.go | 10 +- internal/web/server.go | 93 ++++++-- internal/web/static/dashboard.css | 25 +- internal/web/templates.go | 9 +- 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 +- internal/web/web_test.go | 28 ++- 25 files changed, 944 insertions(+), 272 deletions(-) create mode 100644 internal/web/filetype.go create mode 100644 internal/web/filetype_test.go create mode 100644 internal/web/handlers_files.go create mode 100644 internal/web/templates/dashboard/files.html delete mode 100644 internal/web/templates/dashboard/images.html (limited to 'internal/web') diff --git a/internal/web/filetype.go b/internal/web/filetype.go new file mode 100644 index 0000000..9c0f590 --- /dev/null +++ b/internal/web/filetype.go @@ -0,0 +1,196 @@ +package web + +import ( + "mime" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "unicode" +) + +// Uploads: what a file is and how it may be served. +// +// The root domain carries the session cookie and serves every blog's files +// (/b/{sub}/media previews, the root blog's own /media), so an uploaded HTML, +// SVG or XML page must never render there. The rules below make that a +// property of the stored content type: the sniffer is trusted first, a +// filename extension may only refine a generic sniff to a type on an +// allowlist, and anything not on the inline list is a download. + +// maxUploadFiles is how many files one Files-page request may carry; the body +// cap of that route is this many upload limits. +const maxUploadFiles = 10 + +// maxUploadMB caps the superadmin's per-blog override: substring() takes int4 +// offsets, and the whole upload sits in memory while it is stored. +const maxUploadMB = 1024 + +var fileKinds = []string{"image", "document", "audio", "video", "archive", "other"} + +// fileKindNames are the tab labels (translated where used, like moduleNames). +var fileKindNames = map[string]string{"image": "Images", "document": "Documents", "audio": "Audio", "video": "Video", "archive": "Archives", "other": "Other"} + +var imageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, "image/x-icon": true, "image/avif": true, "image/bmp": true} + +var documentTypes = map[string]bool{ + "application/pdf": true, "text/plain": true, "text/csv": true, "application/rtf": true, "application/epub+zip": true, + "application/msword": true, "application/vnd.ms-excel": true, "application/vnd.ms-powerpoint": true, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, + "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, + "application/vnd.oasis.opendocument.text": true, + "application/vnd.oasis.opendocument.spreadsheet": true, + "application/vnd.oasis.opendocument.presentation": true, +} + +var archiveTypes = map[string]bool{ + "application/zip": true, "application/gzip": true, "application/x-gzip": true, "application/x-7z-compressed": true, + "application/x-tar": true, "application/x-bzip2": true, "application/x-xz": true, "application/x-rar-compressed": true, "application/vnd.rar": true, +} + +var mediaTypes = map[string]bool{ + "audio/mpeg": true, "audio/mp4": true, "audio/ogg": true, "audio/flac": true, "audio/wav": true, "audio/wave": true, "audio/x-wav": true, "audio/webm": true, "audio/aac": true, + "video/mp4": true, "video/ogg": true, "video/webm": true, "video/x-matroska": true, "video/quicktime": true, +} + +// extAllowed is what an extension may turn a generic sniff into: nothing a +// browser would run. Images are deliberately absent — a real image sniffs. +func extAllowed(ct string) bool { + return documentTypes[ct] || archiveTypes[ct] || mediaTypes[ct] +} + +// fileType decides what an upload is from its first bytes and its name. The +// client's declared type is never consulted, and the result is always one of +// the known types above or application/octet-stream, so a stored content +// type is safe to serve by construction. +func fileType(head []byte, filename string) (contentType, kind string) { + ct := mediaType(http.DetectContentType(head)) + ext := strings.ToLower(path.Ext(filename)) + // The sniffer only knows containers for these. + switch { + case ct == "application/ogg": + ct = "audio/ogg" + if ext == ".ogv" { + ct = "video/ogg" + } + case ct == "video/mp4" && ext == ".m4a": + ct = "audio/mp4" + case ct == "image/vnd.microsoft.icon": + ct = "image/x-icon" + } + if ct == "text/plain" || ct == "application/octet-stream" { + switch e := mediaType(mime.TypeByExtension(ext)); { + case e == "": // unknown extension: the bytes are all we have + case extAllowed(e): + ct = e + case ct == "text/plain" && strings.HasPrefix(e, "text/") && !scriptTypes[e]: + // .md, .log, .ini…: text is text (the tables differ between machines, so the name only confirms) + default: // a name we would not serve inline (.html, .svg, .js, .exe…), whatever the bytes look like + ct = "application/octet-stream" + } + } + if !imageTypes[ct] && !extAllowed(ct) && !strings.HasPrefix(ct, "audio/") && !strings.HasPrefix(ct, "video/") { + ct = "application/octet-stream" // sniffed HTML/XML, fonts, and everything else we do not name + } + return ct, kindOf(ct) +} + +// scriptTypes are text types a browser would execute or interpret as markup. +var scriptTypes = map[string]bool{"text/html": true, "text/javascript": true, "text/xml": true, "text/css": true} + +// mediaType drops the parameters ("; charset=utf-8") from a content type. +func mediaType(ct string) string { + if ct == "" { + return "" + } + mt, _, err := mime.ParseMediaType(ct) + if err != nil { + return "" + } + return mt +} + +// kindOf buckets a content type for the Files page tabs. +func kindOf(ct string) string { + switch { + case imageTypes[ct]: + return "image" + case documentTypes[ct]: + return "document" + case strings.HasPrefix(ct, "audio/"): + return "audio" + case strings.HasPrefix(ct, "video/"): + return "video" + case archiveTypes[ct]: + return "archive" + } + return "other" +} + +// inlineOK says whether a browser may render the type in place. +func inlineOK(ct string) bool { + return imageTypes[ct] || ct == "application/pdf" || ct == "text/plain" || strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "video/") +} + +// servedAs is the Content-Type and disposition /media answers with. +func servedAs(ct string, download bool) (ctype, disposition string) { + if !inlineOK(ct) { + return "application/octet-stream", "attachment" + } + if ct == "text/plain" { + ct = "text/plain; charset=utf-8" + } + if download { + return ct, "attachment" + } + return ct, "inline" +} + +// contentDisposition carries the filename in both the plain form (ASCII only, +// for old browsers) and the RFC 5987 one (Greek names survive). +func contentDisposition(disposition, name string) string { + ascii := strings.Map(func(r rune) rune { + if r < 0x20 || r > 0x7e || r == '"' || r == '\\' { + return '_' + } + return r + }, name) + return disposition + `; filename="` + ascii + `"; filename*=utf-8''` + url.PathEscape(name) +} + +// cleanFilename keeps only the base name a browser sent (Windows paths +// included), without control characters or quotes, at most 120 runes. +func cleanFilename(name string) string { + name = path.Base(strings.ReplaceAll(name, `\`, "/")) + name = strings.Map(func(r rune) rune { + if unicode.IsControl(r) || r == '"' || r == '\'' { + return -1 + } + return r + }, name) + name = strings.TrimSpace(name) + if r := []rune(name); len(r) > 120 { + name = string(r[:120]) + } + if name == "" || name == "." || name == "/" || name == ".." { + return "file" + } + return name +} + +// humanSize prints a byte count the way the dashboard shows it. +func humanSize(n int64) string { + if n < 1<<20 { + return strconv.FormatInt(max(1, n>>10), 10) + " KB" + } + return strings.TrimSuffix(strconv.FormatFloat(float64(n)/(1<<20), 'f', 1, 64), ".0") + " MB" +} + +// pageBounds clamps a 1-based page number to the list and gives the SQL offset. +func pageBounds(total, per, n int) (offset, page, last int) { + last = max(1, (total+per-1)/per) + page = min(max(1, n), last) + return (page - 1) * per, page, last +} 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"}, + {"", "evil.svg", "application/octet-stream", "other"}, + {"", "pic.svg", "application/octet-stream", "other"}, + {"", "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 +} diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index eb040c1..bc28fd8 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -24,7 +24,7 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - s.render(w, r, "admin/index.html", map[string]any{"users": users, "cfg": s.cfg}) + s.render(w, r, "admin/index.html", map[string]any{"users": users, "cfg": s.cfg, "defaultLimit": s.cfg.MaxUploadBytes}) } func (s *Server) handleAdminNewUserForm(w http.ResponseWriter, r *http.Request) { @@ -142,6 +142,38 @@ func (s *Server) handleAdminSetDisabled(disabled bool) http.HandlerFunc { } } +// handleAdminUploadLimit sets a blog's per-file upload limit; blank restores the default. +func (s *Server) handleAdminUploadLimit(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + b, err := s.st.BlogByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + } else { + s.serverError(w, err) + } + return + } + field := strings.TrimSpace(r.FormValue("mb")) + mb := 0 + if field != "" { + mb, err = strconv.Atoi(field) + if err != nil || mb < 1 || mb > maxUploadMB { + s.plainError(w, http.StatusBadRequest, s.trf(r, "Enter a whole number of MB (1-%d), or leave blank for the default.", maxUploadMB)) + return + } + } + if err := s.st.SetBlogUploadLimit(r.Context(), b.ID, int64(mb)<<20); err != nil { + s.serverError(w, err) + return + } + if mb == 0 { + redirectOK(w, r, "/admin/", s.trf(r, "Upload limit for %s reset to the default.", b.Subdomain)) + return + } + redirectOK(w, r, "/admin/", s.trf(r, "Upload limit for %s set to %d MB.", b.Subdomain, mb)) +} + func (s *Server) handleAdminDeleteUserConfirm(w http.ResponseWriter, r *http.Request) { u := s.adminTargetUser(w, r) if u == nil { diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go index 7424eca..3e9e600 100644 --- a/internal/web/handlers_design.go +++ b/internal/web/handlers_design.go @@ -1,27 +1,15 @@ package web import ( - "bytes" - "encoding/json" - "errors" - "io" - "mime/multipart" "net/http" - "path/filepath" - "strconv" - "strings" "time" - "github.com/google/uuid" "github.com/gramanas/blogspace/internal/store" ) -// ICO is here for site icons; it is harmless anywhere else an image can go. -var allowedImageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, "image/x-icon": true} - func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) - images, err := blogStore(r).ListImages(r.Context()) + images, _, err := blogStore(r).ListFiles(r.Context(), "image", "", 0, 0) if err != nil { s.serverError(w, err) return @@ -30,22 +18,18 @@ func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) { } // designData is what design.html shows; "today" is the sample for the date formats. -func designData(theme Theme, images []store.Image) map[string]any { +func designData(theme Theme, images []store.File) map[string]any { return map[string]any{"theme": theme, "images": images, "presets": Presets(), "today": time.Now()} } func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) - if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { - s.plainError(w, http.StatusBadRequest, s.tr(r, "Upload too large or malformed form.")) - return - } theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form) // Optional direct uploads from the design form. for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage, "logo_file": &theme.Logo, "favicon_file": &theme.Favicon} { - img, err := s.readUpload(r, field) + img, err := s.readUpload(r, field, true) if err != nil { - images, _ := blogStore(r).ListImages(r.Context()) + images, _, _ := blogStore(r).ListFiles(r.Context(), "image", "", 0, 0) d := designData(theme, images) d["error"] = err.Error() s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", d) @@ -93,153 +77,3 @@ func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) { } redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.tr(r, "Design and layout reset to the defaults.")) } - -// readUpload stores the file from a multipart field, returning nil if the field is empty. -func (s *Server) readUpload(r *http.Request, field string) (*store.Image, error) { - if r.MultipartForm == nil { - return nil, nil - } - fhs := r.MultipartForm.File[field] - if len(fhs) == 0 { - return nil, nil - } - return s.storeUpload(r, fhs[0]) -} - -func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.Image, error) { - tooBig := errors.New(s.trf(r, "Image is too large (max %s).", kbString(s.cfg.MaxUploadBytes))) - if fh.Size > s.cfg.MaxUploadBytes { - return nil, tooBig - } - f, err := fh.Open() - if err != nil { - return nil, err - } - defer f.Close() - var buf bytes.Buffer - if _, err := io.CopyN(&buf, f, s.cfg.MaxUploadBytes+1); err != nil && !errors.Is(err, io.EOF) { - return nil, err - } - if int64(buf.Len()) > s.cfg.MaxUploadBytes { - return nil, tooBig - } - ct := http.DetectContentType(buf.Bytes()) - if !allowedImageTypes[ct] { - return nil, errors.New(s.tr(r, "Only PNG, JPEG, GIF, WebP and ICO images are accepted.")) - } - name := filepath.Base(fh.Filename) - if name == "" || name == "." || len(name) > 120 { - name = "image" - } - return blogStore(r).CreateImage(r.Context(), name, ct, buf.Bytes()) -} - -// imageMarkdown is the line the editor inserts for an uploaded image. -func imageMarkdown(img *store.Image) string { - return "![" + strings.NewReplacer("]", "", "\n", " ").Replace(img.Filename) + "](/media/" + img.ID.String() + ")" -} - -// appendImageMD is the no-JavaScript path of "Insert image": the file arrives -// with the form itself and is appended to the end of the text on save. -func appendImageMD(md string, img *store.Image) string { - if img == nil { - return md - } - md = strings.TrimRight(md, "\n") - if md != "" { - md += "\n\n" - } - return md + imageMarkdown(img) + "\n" -} - -func kbString(n int64) string { - if n >= 1<<20 { - return strconv.FormatInt(n>>20, 10) + " MB" - } - return strconv.FormatInt(n>>10, 10) + " KB" -} - -// ---- image library --------------------------------------------------------- - -func (s *Server) handleImages(w http.ResponseWriter, r *http.Request) { - images, err := blogStore(r).ListImages(r.Context()) - if err != nil { - s.serverError(w, err) - return - } - s.render(w, r, "dashboard/images.html", map[string]any{"images": images}) -} - -// handleImageUpload serves the Images page form and, when the client asks for -// JSON, the editor's "Insert image" script. -func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) { - blog := currentBlog(r) - wantJSON := strings.Contains(r.Header.Get("Accept"), "application/json") - fail := func(msg string) { - if wantJSON { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) - return - } - s.plainError(w, http.StatusBadRequest, msg) - } - if err := r.ParseMultipartForm(1 << 20); err != nil { - fail(s.tr(r, "Upload too large or malformed form.")) - return - } - img, err := s.readUpload(r, "file") - if err != nil { - fail(err.Error()) - return - } - if img == nil { - fail(s.tr(r, "Choose a file first.")) - return - } - if wantJSON { - writeJSON(w, http.StatusOK, map[string]string{"id": img.ID.String(), "filename": img.Filename, "markdown": imageMarkdown(img)}) - return - } - redirectOK(w, r, "/b/"+blog.Subdomain+"/images", s.trf(r, "Uploaded %s.", img.Filename)) -} - -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} - -func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) { - blog := currentBlog(r) - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - http.NotFound(w, r) - return - } - if err := blogStore(r).DeleteImage(r.Context(), id); err != nil { - s.serverError(w, err) - return - } - // Drop dangling references from the theme. - theme := ParseTheme(blog.ThemeJSON) - changed := false - if theme.BgImage == id.String() { - theme.BgImage, changed = "", true - } - if theme.HeaderImage == id.String() { - theme.HeaderImage, changed = "", true - } - if theme.Favicon == id.String() { - theme.Favicon, changed = "", true - } - if theme.Logo == id.String() { - theme.Logo, changed = "", true - } - if changed { - if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil { - s.serverError(w, err) - return - } - } - redirectOK(w, r, "/b/"+blog.Subdomain+"/images", s.tr(r, "Image deleted.")) -} diff --git a/internal/web/handlers_files.go b/internal/web/handlers_files.go new file mode 100644 index 0000000..1d90b91 --- /dev/null +++ b/internal/web/handlers_files.go @@ -0,0 +1,264 @@ +package web + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/gramanas/blogspace/internal/store" +) + +const filesPerPage = 50 + +// readUpload stores the file from a multipart field, returning nil if the +// field is empty. imagesOnly is for the design page, whose fields become theme +// image ids. +func (s *Server) readUpload(r *http.Request, field string, imagesOnly bool) (*store.File, error) { + if r.MultipartForm == nil { + return nil, nil + } + fhs := r.MultipartForm.File[field] + if len(fhs) == 0 { + return nil, nil + } + return s.storeUpload(r, fhs[0], imagesOnly) +} + +func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, imagesOnly bool) (*store.File, error) { + limit := currentBlog(r).UploadLimit(s.cfg) + tooBig := errors.New(s.trf(r, "File is too large (max %s).", humanSize(limit))) + if fh.Size > limit { + return nil, tooBig + } + f, err := fh.Open() + if err != nil { + return nil, err + } + defer f.Close() + var buf bytes.Buffer + if _, err := io.CopyN(&buf, f, limit+1); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if int64(buf.Len()) > limit { + return nil, tooBig + } + if buf.Len() == 0 { + return nil, errors.New(s.tr(r, "File is empty.")) + } + name := cleanFilename(fh.Filename) + ct, kind := fileType(buf.Bytes(), name) + if imagesOnly && kind != "image" { + return nil, errors.New(s.tr(r, "Only PNG, JPEG, GIF, WebP and ICO images can be used here.")) + } + return blogStore(r).CreateFile(r.Context(), name, ct, kind, buf.Bytes()) +} + +// fileMarkdown is the line the editor inserts: an image for images, a link +// for everything else. +func fileMarkdown(f *store.File) string { + text := strings.NewReplacer("]", "", "\n", " ").Replace(f.Filename) + open := "[" + if f.Kind == "image" { + open = "![" + } + return open + text + "](/media/" + f.ID.String() + ")" +} + +// appendFileMD is the no-JavaScript path of "Insert file": the file arrives +// with the form itself and is appended to the end of the text on save. +func appendFileMD(md string, f *store.File) string { + if f == nil { + return md + } + md = strings.TrimRight(md, "\n") + if md != "" { + md += "\n\n" + } + return md + fileMarkdown(f) + "\n" +} + +// ---- file library ---------------------------------------------------------- + +func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + kind := q.Get("kind") + if _, ok := fileKindNames[kind]; !ok { + kind = "" + } + search := strings.TrimSpace(q.Get("q")) + if rs := []rune(search); len(rs) > 100 { + search = string(rs[:100]) + } + n, _ := strconv.Atoi(q.Get("p")) + d, err := s.filesData(r, kind, search, n) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/files.html", d) +} + +// filesData is what files.html shows: the page of files matching the filter, +// the pager links and the library's usage. +func (s *Server) filesData(r *http.Request, kind, search string, n int) (map[string]any, error) { + bs := blogStore(r) + count, bytes, err := bs.FileUsage(r.Context()) + if err != nil { + return nil, err + } + offset, page, _ := pageBounds(1<<30, filesPerPage, n) + files, total, err := bs.ListFiles(r.Context(), kind, search, filesPerPage, offset) + if err != nil { + return nil, err + } + // A page past the end (stale link, last file deleted) shows the last page instead. + if o, p, _ := pageBounds(total, filesPerPage, n); p != page { + offset, page = o, p + if files, _, err = bs.ListFiles(r.Context(), kind, search, filesPerPage, offset); err != nil { + return nil, err + } + } + _, _, last := pageBounds(total, filesPerPage, page) + link := func(p int) string { + v := url.Values{} + if kind != "" { + v.Set("kind", kind) + } + if search != "" { + v.Set("q", search) + } + if p > 1 { + v.Set("p", strconv.Itoa(p)) + } + if len(v) == 0 { + return "/b/" + currentBlog(r).Subdomain + "/files" + } + return "/b/" + currentBlog(r).Subdomain + "/files?" + v.Encode() + } + return map[string]any{ + "files": files, "kind": kind, "q": search, "kinds": fileKinds, "kindNames": fileKindNames, + "pageNum": page, "lastPage": last, "prevURL": link(page - 1), "nextURL": link(page + 1), "self": link(page), + "count": count, "bytes": bytes, "limit": humanSize(currentBlog(r).UploadLimit(s.cfg)), + }, nil +} + +// handleFileUpload serves the Files page form (several files at once) and, +// when the client asks for JSON, the upload scripts (one file per request). +func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + var fhs []*multipart.FileHeader + if r.MultipartForm != nil { + fhs = r.MultipartForm.File["file"] // browsers repeat the field for + } + var msg string + var files []*store.File + switch { + case len(fhs) == 0: + msg = s.tr(r, "Choose a file first.") + case len(fhs) > maxUploadFiles: + msg = s.trf(r, "At most %d files at a time.", maxUploadFiles) + } + for _, fh := range fhs { + if msg != "" { + break + } + f, err := s.storeUpload(r, fh, false) + if err != nil { + msg = err.Error() + break + } + files = append(files, f) + } + if wantsJSON(r) { + if msg != "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) + return + } + f := files[0] + writeJSON(w, http.StatusOK, map[string]any{"id": f.ID.String(), "filename": f.Filename, "kind": f.Kind, "size": f.Size, "markdown": fileMarkdown(f)}) + return + } + if msg != "" { + d, err := s.filesData(r, "", "", 1) + if err != nil { + s.serverError(w, err) + return + } + d["error"] = msg + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/files.html", d) + return + } + if len(files) == 1 { + redirectOK(w, r, "/b/"+blog.Subdomain+"/files", s.trf(r, "Uploaded %s.", files[0].Filename)) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/files", s.trf(r, "Uploaded %d files.", len(files))) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// filesBack is where rename/delete return to: the list page the form was on. +func filesBack(r *http.Request) string { + if back := safeNext(r.FormValue("back")); back != "" { + return back + } + return "/b/" + currentBlog(r).Subdomain + "/files" +} + +func (s *Server) handleFileRename(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + name := cleanFilename(r.FormValue("filename")) + if err := blogStore(r).RenameFile(r.Context(), id, name); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + s.serverError(w, err) + return + } + redirectOK(w, r, filesBack(r), s.trf(r, "Renamed to %s.", name)) +} + +func (s *Server) handleFileDelete(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + if err := blogStore(r).DeleteFile(r.Context(), id); err != nil { + s.serverError(w, err) + return + } + // Drop dangling references from the theme. + theme := ParseTheme(blog.ThemeJSON) + changed := false + for _, ref := range []*string{&theme.BgImage, &theme.HeaderImage, &theme.Favicon, &theme.Logo} { + if *ref == id.String() { + *ref, changed = "", true + } + } + if changed { + if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil { + s.serverError(w, err) + return + } + } + redirectOK(w, r, filesBack(r), s.tr(r, "File deleted.")) +} diff --git a/internal/web/handlers_media.go b/internal/web/handlers_media.go index 4e237fa..7c4b739 100644 --- a/internal/web/handlers_media.go +++ b/internal/web/handlers_media.go @@ -1,7 +1,6 @@ package web import ( - "bytes" "errors" "io/fs" "net/http" @@ -10,7 +9,9 @@ import ( "github.com/gramanas/blogspace/internal/store" ) -// handleMedia serves an uploaded image. Ids are immutable, so clients may cache forever. +// handleMedia serves an uploaded file. Ids are immutable, so clients may cache +// forever (a renamed file keeps its old download name in caches; acceptable). +// What may render inline is decided by servedAs, see filetype.go. func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(r.PathValue("id")) if err != nil { @@ -22,7 +23,7 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotModified) return } - img, err := blogStore(r).ImageData(r.Context(), id) + f, err := blogStore(r).FileMeta(r.Context(), id) if err != nil { if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) @@ -31,11 +32,13 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - w.Header().Set("Content-Type", img.ContentType) + ctype, disposition := servedAs(f.ContentType, r.URL.Query().Has("download")) + w.Header().Set("Content-Type", ctype) + w.Header().Set("Content-Disposition", contentDisposition(disposition, f.Filename)) w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("ETag", etag) w.Header().Set("X-Content-Type-Options", "nosniff") - http.ServeContent(w, r, img.Filename, img.CreatedAt, bytes.NewReader(img.Data)) + http.ServeContent(w, r, f.Filename, f.CreatedAt, blogStore(r).FileReader(r.Context(), f)) } // handleFavicon answers the browsers that ask for /favicon.ico regardless of diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index fdd1bc8..3b128e0 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -65,11 +65,11 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { if autoSlug { p.Slug = slug.Make(p.Title) } - img, err := s.readUpload(r, "inline_image") + img, err := s.readUpload(r, "inline_file", false) var msg string switch { case err != nil: - msg = s.tr(r, "Image not added:") + " " + err.Error() + msg = s.tr(r, "File not added:") + " " + err.Error() case p.Title == "" || len(p.Title) > 120: msg = s.tr(r, "Title is required (max 120 characters).") case !slug.Valid(p.Slug) || reservedPageSlugs[p.Slug]: @@ -79,7 +79,7 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/page_form.html", map[string]any{"page": p, "error": msg}) return } - p.IntroMD = appendImageMD(p.IntroMD, img) + p.IntroMD = appendFileMD(p.IntroMD, img) p.IntroHTML = markdown.Render(p.IntroMD) base := p.Slug for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go index 8327912..2ec2b2e 100644 --- a/internal/web/handlers_posts.go +++ b/internal/web/handlers_posts.go @@ -92,12 +92,12 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { return } } - img, err := s.readUpload(r, "inline_image") + img, err := s.readUpload(r, "inline_file", false) if err != nil { - fail(http.StatusBadRequest, s.tr(r, "Image not added:")+" "+err.Error()) + fail(http.StatusBadRequest, s.tr(r, "File not added:")+" "+err.Error()) return } - p.BodyMD = appendImageMD(p.BodyMD, img) + p.BodyMD = appendFileMD(p.BodyMD, img) pageOK := false for _, pg := range pages { if pg.ID == p.PageID { diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go index 325192e..eb45e1c 100644 --- a/internal/web/handlers_sections.go +++ b/internal/web/handlers_sections.go @@ -73,10 +73,10 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) { fail := func(msg string) { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/section_form.html", map[string]any{"section": sec, "error": msg}) } - img, err := s.readUpload(r, "inline_image") + img, err := s.readUpload(r, "inline_file", false) switch { case err != nil: - fail(s.tr(r, "Image not added:") + " " + err.Error()) + fail(s.tr(r, "File not added:") + " " + err.Error()) return case len(sec.Title) > 120: fail(s.tr(r, "Title is too long (max 120 characters).")) @@ -88,7 +88,7 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) { fail(s.tr(r, "Announcement is too long (20 KB max).")) return } - sec.BodyMD = appendImageMD(sec.BodyMD, img) + sec.BodyMD = appendFileMD(sec.BodyMD, img) sec.BodyHTML = markdown.Render(sec.BodyMD) if sec.ID == 0 { sec, err = blogStore(r).CreateSection(r.Context(), sec) diff --git a/internal/web/routes.go b/internal/web/routes.go index 36ae892..273a832 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -63,10 +63,11 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign)) m.HandleFunc("POST /b/{sub}/design/preset", s.withBlog(s.handleDesignPreset)) m.HandleFunc("POST /b/{sub}/design/reset", s.withBlog(s.handleDesignReset)) - m.HandleFunc("GET /b/{sub}/images", s.withBlog(s.handleImages)) - m.HandleFunc("POST /b/{sub}/images/upload", s.withBlog(s.handleImageUpload)) - m.HandleFunc("POST /b/{sub}/images/{id}/delete", s.withBlog(s.handleImageDelete)) - // Images live in the blog's database, so dashboard previews on this host go through /b/. + m.HandleFunc("GET /b/{sub}/files", s.withBlog(s.handleFiles)) + m.HandleFunc("POST /b/{sub}/files/upload", s.withBlogFiles(maxUploadFiles, s.handleFileUpload)) + m.HandleFunc("POST /b/{sub}/files/{id}/rename", s.withBlog(s.handleFileRename)) + m.HandleFunc("POST /b/{sub}/files/{id}/delete", s.withBlog(s.handleFileDelete)) + // Files live in the blog's database, so dashboard previews on this host go through /b/. m.HandleFunc("GET /b/{sub}/media/{id}", s.withBlog(s.handleMedia)) // superadmin @@ -78,6 +79,7 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("POST /admin/users/{id}/enable", s.requireAdmin(s.handleAdminSetDisabled(false))) m.HandleFunc("GET /admin/users/{id}/delete", s.requireAdmin(s.handleAdminDeleteUserConfirm)) m.HandleFunc("POST /admin/users/{id}/delete", s.requireAdmin(s.handleAdminDeleteUser)) + m.HandleFunc("POST /admin/blogs/{id}/upload-limit", s.requireAdmin(s.handleAdminUploadLimit)) m.HandleFunc("GET /media/{id}", s.hostBlog(s.handleMedia)) m.Handle("GET /static/{file}", s.staticHandler()) 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) + } }) } diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index 5c766e7..8fe5f8a 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -118,12 +118,23 @@ details.help[open] summary { margin-bottom: 0.5em; } .editor-tools .upload input { display: inline-block; width: auto; max-width: 14em; margin: 0 0 0 0.4em; padding: 0; border: 0; background: none; font-size: 0.9em; } .editor-tools .upload-status { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; margin-right: 0.6em; } -/* ---- images -------------------------------------------------------------- */ -.gallery { overflow: hidden; } -.thumb { float: left; width: 200px; margin: 0 1.2em 1.6em 0; padding: 0.6em; text-align: center; } -.thumb img { max-width: 100%; max-height: 140px; border: 1px solid #ddd6c7; } -.thumb .meta { font-size: 0.85em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 0.4em; } -.thumb .copy { font-size: 0.75em; margin: 0.4em 0; padding: 0.3em; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; } +/* ---- files --------------------------------------------------------------- */ +.dropzone.over { border-style: dashed; background: #ebe5d6; } +.progress { list-style: none; margin: 0.6em 0 0; padding: 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; } +.progress:empty { display: none; } +.files-filter { overflow: hidden; } +.files-filter input[type=search] { width: auto; margin: 0 0.3em 0.5em 0; padding: 0.3em 0.5em; vertical-align: middle; } +.kinds { display: inline-block; margin: 0 1em 0.5em 0; vertical-align: middle; } +.kinds a { display: inline-block; padding: 0.2em 0.7em; margin: 0 0.3em 0.3em 0; border: 2px solid #1d1a17; color: #1d1a17; text-decoration: none; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; } +.kinds a:hover { background: #ebe5d6; } +.kinds a.active { background: #1d1a17; color: #f4efe4; } +table.files td.icon { width: 56px; } +table.files .thumb { display: block; width: 48px; height: 48px; object-fit: contain; border: 1px solid #ddd6c7; background: #f4efe4; } +table.files .badge { display: block; width: 48px; padding: 0.9em 0; border: 1px solid #1d1a17; background: #fffdf8; text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.65em; letter-spacing: 0.05em; overflow: hidden; } +table.files td.name { word-break: break-all; } +table.files .copy { width: 100%; min-width: 11em; padding: 0.3em; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.75em; } +.pager { margin: 0 4px 1.6em 0; text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9em; } +.pager a, .pager span { margin: 0 0.8em; } .imagepick { margin-top: 0.5em; padding-top: 0.5em; border-top: 2px dashed #ddd6c7; } .imagepick > label { margin-bottom: 0.3em; } .imagepick .picks { overflow: hidden; margin: 0.4em 0 0; } @@ -184,7 +195,7 @@ details.reset { margin-top: 1.6em; } .login-brand { text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; text-transform: uppercase; letter-spacing: 0.2em; margin: 2em 0 1em; } @media (max-width: 700px) { - .cols .card, .row label, .thumb { float: none; width: auto; margin-right: 4px; } + .cols .card, .row label { float: none; width: auto; margin-right: 4px; } .imagepick .pick { width: 46%; margin-right: 4%; } .presets .preset { width: 46%; margin-right: 4%; } .addrow .add { float: none; width: auto; margin-right: 0; } diff --git a/internal/web/templates.go b/internal/web/templates.go index 7f71304..c2f9847 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -110,9 +110,12 @@ var funcs = template.FuncMap{ "rfc": func(t time.Time) string { return t.Format(time.RFC1123Z) }, "html": func(s string) template.HTML { return template.HTML(s) }, "css": func(s string) template.CSS { return template.CSS(s) }, - "kb": func(n int) string { return fmt.Sprintf("%.0f KB", float64(n)/1024) }, - "add": func(a, b int) int { return a + b }, - "sub": func(a, b int) int { return a - b }, + "size": humanSize, + "mb": func(n int64) int64 { return n >> 20 }, + // filemd is the Markdown line for a library file, what "Insert file" writes + "filemd": fileMarkdown, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, "deref": func(p *string) string { if p == nil { return "" 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."}}

    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