/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar (caption, date), /manage/delete/{slug} asks for
confirmation and then removes the image, its sidecar and everything
generated from them. Builds now drop day pages of days without photos
and pager pages past the last one, so a delete or a date change leaves
nothing behind.
The pages are off by default: the routes are only registered with
-manage, so an install that does not opt in has no such code path and
answers 404. NixOS gets services.photogallery.manage.enable, which
also adds the nginx location only when on. Like /upload, the pages
carry no auth of their own and are not linked from the public site.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
225 lines
6.7 KiB
Go
225 lines
6.7 KiB
Go
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)
|
|
}
|