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/handlers_files.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) (limited to 'internal/web/handlers_files.go') 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 } -- cgit v1.2.3