package main import ( "bytes" "log" "net/http" "net/url" "os" "path/filepath" "strings" "time" "github.com/BurntSushi/toml" ) // The /manage pages edit and delete photos that are already published. Like // /upload they carry no auth of their own: in production nginx puts them // behind htpasswd, and nothing on the public site links to them. // maxEditBody caps the edit form. It carries a caption and a date, nothing else. const maxEditBody = 64 << 10 // handleManagePage lists every photo with links to edit and delete it. func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) { photos, err := s.generator.loadPhotos() if err != nil { log.Printf("manage: %v", err) http.Error(w, "could not read photos", http.StatusInternalServerError) return } data := s.managePageData() data["Photos"] = photos if saved := r.URL.Query().Get("saved"); saved != "" { for _, p := range photos { if p.Slug == saved { data["Saved"] = p.Caption } } } data["Deleted"] = r.URL.Query().Get("deleted") s.renderManage(w, "manage.html", data) } // handleEditPage shows the edit form for one photo, prefilled from its sidecar. func (s *Server) handleEditPage(w http.ResponseWriter, r *http.Request) { photo, ok := s.findPhoto(r.PathValue("slug")) if !ok { http.NotFound(w, r) return } data := s.managePageData() data["Photo"] = photo s.renderManage(w, "edit.html", data) } // handleEdit accepts the edit form with fields: // // caption — human-readable caption (empty falls back to the slug, as on upload) // date — YYYY-MM-DD // // It rewrites the photo's .toml sidecar; the image itself is never touched. func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) { if !s.sameOrigin(r) { http.Error(w, "cross-site request refused", http.StatusForbidden) return } photo, ok := s.findPhoto(r.PathValue("slug")) if !ok { http.NotFound(w, r) return } r.Body = http.MaxBytesReader(w, r.Body, maxEditBody) if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } caption := strings.TrimSpace(r.PostFormValue("caption")) if caption == "" { caption = photo.Slug } dateStr := r.PostFormValue("date") if _, err := time.Parse("2006-01-02", dateStr); err != nil { http.Error(w, "date must be YYYY-MM-DD", http.StatusBadRequest) return } // Encode first and write in one go, so a rebuild racing this request // never reads a half-written sidecar. var buf bytes.Buffer if err := toml.NewEncoder(&buf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil { http.Error(w, "could not write metadata", http.StatusInternalServerError) return } tomlPath := filepath.Join(s.contentDir, photo.Slug+".toml") if err := os.WriteFile(tomlPath, buf.Bytes(), 0644); err != nil { log.Printf("write %s: %v", tomlPath, err) http.Error(w, "could not save metadata", http.StatusInternalServerError) return } // Build() is mutex-guarded so calling it here and from the watcher is safe. go func() { if err := s.generator.Build(); err != nil { log.Printf("rebuild after edit: %v", err) } }() http.Redirect(w, r, "/manage?saved="+url.QueryEscape(photo.Slug), http.StatusSeeOther) } // handleDeletePage asks for confirmation before a photo is deleted. Deleting // is a separate page rather than a button in the list so that it always takes // two deliberate steps. func (s *Server) handleDeletePage(w http.ResponseWriter, r *http.Request) { photo, ok := s.findPhoto(r.PathValue("slug")) if !ok { http.NotFound(w, r) return } data := s.managePageData() data["Photo"] = photo s.renderManage(w, "delete.html", data) } // handleDelete removes a photo for good: image, sidecar and everything // generated from them. There is no undo. func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { if !s.sameOrigin(r) { http.Error(w, "cross-site request refused", http.StatusForbidden) return } photo, ok := s.findPhoto(r.PathValue("slug")) if !ok { http.NotFound(w, r) return } if err := s.generator.Delete(photo.Slug); err != nil { log.Printf("delete %s: %v", photo.Slug, err) http.Error(w, "could not delete photo", http.StatusInternalServerError) return } log.Printf("deleted %s (%q)", photo.Slug, photo.Caption) // Build() is mutex-guarded so calling it here and from the watcher is safe. go func() { if err := s.generator.Build(); err != nil { log.Printf("rebuild after delete: %v", err) } }() http.Redirect(w, r, "/manage?deleted="+url.QueryEscape(photo.Caption), http.StatusSeeOther) } // findPhoto looks slug up among the photos in the content directory. Matching // against real entries, rather than building a path from the URL, is what // keeps a crafted slug from reaching outside the content directory. func (s *Server) findPhoto(slug string) (Photo, bool) { photos, err := s.generator.loadPhotos() if err != nil { log.Printf("manage: %v", err) return Photo{}, false } for _, p := range photos { if p.Slug == slug { return p, true } } return Photo{}, false } // sameOrigin reports whether a form post came from the gallery's own pages. // Basic auth credentials are replayed by the browser on every request to the // host, so without this check any other website could submit the edit or // delete form on a logged-in user's behalf. // // The Origin header is compared against -base-url as well as the Host header: // behind nginx the Host that reaches this server is the proxy's upstream // address, not the public name. Requests with neither header come from // non-browser clients such as curl, which cannot be driven cross-site. func (s *Server) sameOrigin(r *http.Request) bool { if site := r.Header.Get("Sec-Fetch-Site"); site != "" { return site == "same-origin" || site == "none" } origin := r.Header.Get("Origin") if origin == "" { return true } if origin == s.generator.baseURL { return true } u, err := url.Parse(origin) return err == nil && u.Host == r.Host } // managePageData returns the template fields shared by the manage pages. func (s *Server) managePageData() map[string]any { g := s.generator return map[string]any{ "FontsURL": g.fontsURL, "SerifFamily": g.serifFamily, "MlFamily": g.mlFamily, } } // renderManage renders into a buffer first so a template error becomes a // clean 500 instead of a truncated page. func (s *Server) renderManage(w http.ResponseWriter, tmpl string, data map[string]any) { var buf bytes.Buffer if err := s.generator.tmpl.ExecuteTemplate(&buf, tmpl, data); err != nil { log.Printf("render %s: %v", tmpl, err) http.Error(w, "could not render page", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") // Always reflect the sidecars as they are now, never a cached copy. w.Header().Set("Cache-Control", "no-store") buf.WriteTo(w) }