diff options
Diffstat (limited to 'internal/web/handlers_files.go')
| -rw-r--r-- | internal/web/handlers_files.go | 264 |
1 files changed, 264 insertions, 0 deletions
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 = " + ")" +} + +// 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 <input multiple> + } + 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.")) +} |
