feat(manage): add /manage pages to edit caption and date
/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar, which triggers a rebuild. The pages sit behind the same
htpasswd as /upload and are not linked from the public site.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// The /manage pages edit 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 a link to its edit form.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
Reference in New Issue
Block a user