From e96bbec551c0388e972894b349413fa6ef607352 Mon Sep 17 00:00:00 2001 From: grm Date: Thu, 17 Sep 2026 21:29:44 +0300 Subject: Add custom fonts: upload WOFF2/WOFF/TTF/OTF files and pick them on the Design tab Fonts become a file kind of their own (sniffed from the bytes like images, served inline from /media), and the theme gets custom_font and custom_heading_font: a chosen file is declared as @font-face and put first in the stack, the built-in Font / Heading font choice behind it as the fallback, so there is no "custom" enum value and no invalid state. The pickers are plain selects with an upload input, no script. Fonts uploaded before this are reclassified by the migration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/filetype.go | 19 +++++--- internal/web/filetype_test.go | 11 ++++- internal/web/handlers_design.go | 28 ++++++----- internal/web/handlers_files.go | 23 +++++---- internal/web/handlers_pages.go | 4 +- internal/web/handlers_posts.go | 6 +-- internal/web/handlers_sections.go | 2 +- internal/web/static/dashboard.css | 5 ++ internal/web/templates/dashboard/design.html | 8 +++- internal/web/templates/partials/fontpick.html | 13 +++++ internal/web/theme.go | 68 ++++++++++++++++++++------- internal/web/web_test.go | 35 +++++++++++++- 12 files changed, 168 insertions(+), 54 deletions(-) create mode 100644 internal/web/templates/partials/fontpick.html (limited to 'internal/web') diff --git a/internal/web/filetype.go b/internal/web/filetype.go index 9c0f590..60a220e 100644 --- a/internal/web/filetype.go +++ b/internal/web/filetype.go @@ -17,7 +17,8 @@ import ( // 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. +// allowlist, and anything not on the inline list is a download. Fonts are +// on it: a browser only ever parses one inside @font-face, never as a page. // maxUploadFiles is how many files one Files-page request may carry; the body // cap of that route is this many upload limits. @@ -27,10 +28,10 @@ const maxUploadFiles = 10 // offsets, and the whole upload sits in memory while it is stored. const maxUploadMB = 1024 -var fileKinds = []string{"image", "document", "audio", "video", "archive", "other"} +var fileKinds = []string{"image", "document", "audio", "video", "font", "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 fileKindNames = map[string]string{"image": "Images", "document": "Documents", "audio": "Audio", "video": "Video", "font": "Fonts", "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} @@ -50,6 +51,10 @@ var archiveTypes = map[string]bool{ "application/x-tar": true, "application/x-bzip2": true, "application/x-xz": true, "application/x-rar-compressed": true, "application/vnd.rar": true, } +// fontTypes are what the design page's font pickers take. Like images they +// sniff exactly, so the name never refines into one; TTC and EOT stay "other". +var fontTypes = map[string]bool{"font/woff2": true, "font/woff": true, "font/ttf": true, "font/otf": 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, @@ -91,8 +96,8 @@ func fileType(head []byte, filename string) (contentType, kind string) { 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 + if !imageTypes[ct] && !fontTypes[ct] && !extAllowed(ct) && !strings.HasPrefix(ct, "audio/") && !strings.HasPrefix(ct, "video/") { + ct = "application/octet-stream" // sniffed HTML/XML and everything else we do not name } return ct, kindOf(ct) } @@ -123,6 +128,8 @@ func kindOf(ct string) string { return "audio" case strings.HasPrefix(ct, "video/"): return "video" + case fontTypes[ct]: + return "font" case archiveTypes[ct]: return "archive" } @@ -131,7 +138,7 @@ func kindOf(ct string) string { // 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/") + return imageTypes[ct] || fontTypes[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. diff --git a/internal/web/filetype_test.go b/internal/web/filetype_test.go index 1f1d89e..45c4166 100644 --- a/internal/web/filetype_test.go +++ b/internal/web/filetype_test.go @@ -35,8 +35,14 @@ func TestFileType(t *testing.T) { {"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", "README", "text/plain", "document"}, // no extension: the bytes are all we have + {"\x00\x01\x02\x03\xff\xfe", "font.ttf", "application/octet-stream", "other"}, // a font is known by its bytes + {"wOF2\x00\x01\x00\x00", "body.woff2", "font/woff2", "font"}, + {"wOFF\x00\x01\x00\x00", "body.woff", "font/woff", "font"}, + {"\x00\x01\x00\x00\x00\x10", "body.ttf", "font/ttf", "font"}, + {"OTTO\x00\x10\x00\x80", "body.otf", "font/otf", "font"}, + {"OTTO\x00\x10\x00\x80", "evil.html", "font/otf", "font"}, + {"ttcf\x00\x01\x00\x00", "family.ttc", "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"}, @@ -69,6 +75,7 @@ func TestServedAs(t *testing.T) { {"text/plain", false, "text/plain; charset=utf-8", "inline"}, {"audio/mpeg", false, "audio/mpeg", "inline"}, {"video/mp4", false, "video/mp4", "inline"}, + {"font/woff2", false, "font/woff2", "inline"}, {"application/zip", false, "application/octet-stream", "attachment"}, {"text/html", false, "application/octet-stream", "attachment"}, {"image/svg+xml", false, "application/octet-stream", "attachment"}, diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go index acc7569..792dcdc 100644 --- a/internal/web/handlers_design.go +++ b/internal/web/handlers_design.go @@ -13,7 +13,8 @@ import ( // the menu — saved in one go. // recentImages is how many library images the pickers list up front; the -// rest are fetched by the script's library panel, page by page. +// rest are fetched by the script's library panel, page by page. The font +// pickers list as many, with no panel behind them: a blog has a few fonts. const recentImages = 48 // designView is the state the design page shows: what the database holds, or @@ -71,13 +72,17 @@ func (s *Server) designData(r *http.Request, v designView) (map[string]any, erro if err != nil { return nil, err } - // The pickers list the recent images; a chosen one older than that still + fonts, _, err := bs.ListFiles(r.Context(), "font", "", recentImages, 0) + if err != nil { + return nil, err + } + // The pickers list the recent files; a chosen one older than that still // needs its name for the select. names := map[string]string{} - for _, f := range images { + for _, f := range append(images, fonts...) { names[f.ID.String()] = f.Filename } - for _, id := range []string{v.Theme.BgImage, v.Theme.HeaderImage, v.Theme.Logo, v.Theme.Favicon} { + for _, id := range []string{v.Theme.BgImage, v.Theme.HeaderImage, v.Theme.Logo, v.Theme.Favicon, v.Theme.CustomFont, v.Theme.CustomHeadingFont} { if id == "" || names[id] != "" { continue } @@ -115,7 +120,7 @@ func (s *Server) designData(r *http.Request, v designView) (map[string]any, erro "templates": templates, "pageItem": store.MenuItem{PageID: &pageID}, "linkItem": store.MenuItem{}, "kinds": moduleKinds, "names": moduleNames, "areaNames": areaNames, "single": singleModuleAreas, "counts": []int{3, 5, 8, 10, 15, 20}, "tagCounts": []int{5, 10, 20, 30, 50}, "perPage": postsPerPageChoices, - "images": images, "imageNames": names, "imageCount": imageCount, "presets": Presets(), "today": time.Now(), + "images": images, "fonts": fonts, "imageNames": names, "imageCount": imageCount, "presets": Presets(), "today": time.Now(), }, nil } @@ -164,16 +169,17 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) { // Optional direct uploads from the pickers. One that is stored stays // chosen in the form even when something else is rejected. for _, u := range []struct { - field string - dst *string - }{{"bg_image_file", &v.Theme.BgImage}, {"header_image_file", &v.Theme.HeaderImage}, {"logo_file", &v.Theme.Logo}, {"favicon_file", &v.Theme.Favicon}} { - img, err := s.readUpload(r, u.field, true) + field, kind string + dst *string + }{{"bg_image_file", "image", &v.Theme.BgImage}, {"header_image_file", "image", &v.Theme.HeaderImage}, {"logo_file", "image", &v.Theme.Logo}, {"favicon_file", "image", &v.Theme.Favicon}, + {"custom_font_file", "font", &v.Theme.CustomFont}, {"custom_heading_font_file", "font", &v.Theme.CustomHeadingFont}} { + f, err := s.readUpload(r, u.field, u.kind) if err != nil { note(err) continue } - if img != nil { - *u.dst = img.ID.String() + if f != nil { + *u.dst = f.ID.String() } } if problem != nil { diff --git a/internal/web/handlers_files.go b/internal/web/handlers_files.go index 576f2ca..fd6ab69 100644 --- a/internal/web/handlers_files.go +++ b/internal/web/handlers_files.go @@ -20,9 +20,9 @@ import ( 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) { +// field is empty. kind restricts what is accepted ("" = anything): the pickers' +// fields become theme image or font ids. +func (s *Server) readUpload(r *http.Request, field, kind string) (*store.File, error) { if r.MultipartForm == nil { return nil, nil } @@ -30,10 +30,10 @@ func (s *Server) readUpload(r *http.Request, field string, imagesOnly bool) (*st if len(fhs) == 0 { return nil, nil } - return s.storeUpload(r, fhs[0], imagesOnly) + return s.storeUpload(r, fhs[0], kind) } -func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, imagesOnly bool) (*store.File, error) { +func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, kind string) (*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 { @@ -55,11 +55,14 @@ func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, imagesOn return nil, errors.New(s.tr(r, "File is empty.")) } name := cleanFilename(fh.Filename) - ct, kind := fileType(buf.Bytes(), name) - if imagesOnly && kind != "image" { + ct, got := fileType(buf.Bytes(), name) + switch { + case kind == "image" && got != kind: return nil, errors.New(s.tr(r, "Only PNG, JPEG, GIF, WebP and ICO images can be used here.")) + case kind == "font" && got != kind: + return nil, errors.New(s.tr(r, "Only WOFF2, WOFF, TTF and OTF fonts can be used here.")) } - return blogStore(r).CreateFile(r.Context(), name, ct, kind, buf.Bytes()) + return blogStore(r).CreateFile(r.Context(), name, ct, got, buf.Bytes()) } // fileMarkdown is the line the editor inserts: an image for images, a link @@ -204,7 +207,7 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) { if msg != "" { break } - f, err := s.storeUpload(r, fh, false) + f, err := s.storeUpload(r, fh, "") if err != nil { msg = err.Error() break @@ -284,7 +287,7 @@ func (s *Server) handleFileDelete(w http.ResponseWriter, r *http.Request) { // Drop dangling references from the theme. theme := ParseTheme(blog.ThemeJSON) changed := false - for _, ref := range []*string{&theme.BgImage, &theme.HeaderImage, &theme.Favicon, &theme.Logo} { + for _, ref := range []*string{&theme.BgImage, &theme.HeaderImage, &theme.Favicon, &theme.Logo, &theme.CustomFont, &theme.CustomHeadingFont} { if *ref == id.String() { *ref, changed = "", true } diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index 40456f2..8dc0f5f 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -66,8 +66,8 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { if autoSlug { p.Slug = slug.Make(p.Title) } - img, err := s.readUpload(r, "intro_file", false) - outroImg, err2 := s.readUpload(r, "outro_file", false) + img, err := s.readUpload(r, "intro_file", "") + outroImg, err2 := s.readUpload(r, "outro_file", "") if err == nil { err = err2 } diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go index ec56138..ac0dd90 100644 --- a/internal/web/handlers_posts.go +++ b/internal/web/handlers_posts.go @@ -114,7 +114,7 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { p.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML) p.Published = r.FormValue("published") == "on" p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64) - p.Image = imageID(pickImage(p.Image, r.FormValue("image"))) + p.Image = fileID(pickFile(p.Image, r.FormValue("image"))) tags, tagsOK := parseTags(strings.Split(r.FormValue("tags"), ",")) p.Tags = tags autoSlug := p.Slug == "" @@ -138,13 +138,13 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { return } } - img, err := s.readUpload(r, "body_file", false) + img, err := s.readUpload(r, "body_file", "") if err != nil { fail(http.StatusBadRequest, s.tr(r, "File not added:")+" "+err.Error()) return } p.BodyMD = appendFile(p.BodyMD, img, p.Format) - if f, err := s.readUpload(r, "image_file", true); err != nil { // an upload beats the picker + if f, err := s.readUpload(r, "image_file", "image"); err != nil { // an upload beats the picker fail(http.StatusBadRequest, s.tr(r, "Image not added:")+" "+err.Error()) return } else if f != nil { diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go index 04ba764..6e802d5 100644 --- a/internal/web/handlers_sections.go +++ b/internal/web/handlers_sections.go @@ -73,7 +73,7 @@ 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, "body_file", false) + img, err := s.readUpload(r, "body_file", "") switch { case err != nil: fail(s.tr(r, "File not added:") + " " + err.Error()) diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index f5e0fd4..4aa0213 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -269,6 +269,11 @@ textarea.mono { font-size: 0.9em; } .pickrow .pickctl select { max-width: 24em; margin: 0 0 0.5em; } .pickrow .pickctl .upload { margin: 0.5em 0 0; font-size: 0.85em; } .pickrow .pickctl .upload input { max-width: 24em; } +/* the design page's font pickers: a select over its upload input, two to a row */ +.fontpick { flex: 1 1 16em; min-width: 12em; } +.fontpick select { max-width: 24em; margin: 0 0 0.5em; } +.fontpick .upload { margin: 0 0 0.5em; font-size: 0.85em; } +.fontpick .upload input { max-width: 24em; } .picker { position: fixed; inset: 0; z-index: 20; background: rgba(16, 24, 40, 0.45); padding: 3vh 1em; overflow: auto; } .picker-box { max-width: 860px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 8px 30px rgba(16, 24, 40, 0.25); padding: 1em 1.2em; } .picker-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6em; margin-bottom: 0.8em; } diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html index 5fd3fbe..5c79dbb 100644 --- a/internal/web/templates/dashboard/design.html +++ b/internal/web/templates/dashboard/design.html @@ -84,6 +84,12 @@ +

{{t "Your own fonts"}}

+

{{t "A WOFF2, WOFF, TTF or OTF file from your library. It is used instead of the choice above, which readers see until it loads or if their browser cannot show it."}}

+
+ {{template "fontpick" (dict "name" "custom_font" "current" $t.CustomFont "fonts" .Data.fonts "names" .Data.imageNames "label" (t "Text font"))}} + {{template "fontpick" (dict "name" "custom_heading_font" "current" $t.CustomHeadingFont "fonts" .Data.fonts "names" .Data.imageNames "label" (t "Heading font") "hint" (t "None: headings use the text font, or the heading font above."))}} +