Files
photog/manage.go
T

225 lines
6.7 KiB
Go
Raw Normal View History

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)
}