Add a /manage page to edit, delete and restore posts

A new auth-gated web UI at /manage lists every post for inline editing of
caption, date and tags, with per-row and bulk delete and bulk tag add/remove.
It is backed by a small JSON API (all behind the same htpasswd Basic auth as
/upload, and unlinked from the public site):

  GET  /api/photos        list posts (newest first)
  POST /api/edit          rewrite one sidecar (caption, date, tags)
  POST /api/tags          bulk add/remove tags across posts
  POST /api/delete        move posts to content/.trash/ (reversible)
  GET  /api/trash         list trashed items
  POST /api/restore       move a trashed item back (409 on live-slug clash)
  POST /api/trash/empty   hard delete trashed items (id… or all=1)

Design notes:
- Editing only rewrites the .toml; the slug (and URL) never changes.
- Delete is reversible: image + sidecar move to content/.trash/ with a
  timestamp suffix, excluded from the site (loadPhotos skips dirs). The only
  hard delete is emptying the trash.
- Build now prunes: it drops the derived HTML trees (photo/ day/ album/ page/
  grid/page/) and regenerates them, and pruneRenditions removes orphaned
  images/thumbs/medium/og. Without this a deleted post would linger at its old
  URL and on stale day/album pages; this also cleans up after a hand-removed
  sidecar.
- Sidecars are written atomically (temp + rename) so a concurrent build never
  observes a half-written file.
- Slugs and trash ids are validated as bare filenames, closing path traversal
  and dotfile access.
- Every state-changing POST passes a sameOrigin (CSRF) check, since Basic-auth
  credentials are replayed by the browser on every request to the host. It
  honours Sec-Fetch-Site, falls back to Origin vs base-url/Host, and allows
  header-less non-browser clients. Ported from the independent /manage on
  plex/trunk-ng.

module.nix proxies /manage and /api/ to the app (nginx otherwise serves them
as missing static files). The apps are unchanged; the API is there for them to
adopt edit/delete later.
This commit is contained in:
puttaalu committed 2026-10-18 21:52:11 +02:00
1 parent 434d2437bf
commit a7f2afc3ad
9 files changed
+1453 -7

No files matched your search

+53
View File
@@ -5,6 +5,7 @@ import (
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -22,6 +23,11 @@ type Server struct {
generator *Generator
auth *Authenticator // nil disables auth (dev only)
authLimit *rate.Limiter
// rebuildHook overrides how a mutation triggers a rebuild. nil means the
// default asynchronous Build; tests set it to run synchronously (or not at
// all) so no background goroutine outlives the test.
rebuildHook func()
}
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator, auth *Authenticator) *Server {
@@ -47,10 +53,57 @@ func (s *Server) guard(h http.HandlerFunc) http.HandlerFunc {
return s.auth.basicAuth(s.authLimit, h)
}
// sameOrigin reports whether a form/API 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 drive a logged-in user's
// browser into submitting an edit, delete or retag on their behalf (CSRF).
//
// Sec-Fetch-Site is preferred; where it is absent the Origin header is compared
// against -base-url and the request Host (behind nginx the Host reaching this
// server is the proxy upstream, not the public name). A request with neither
// header is a non-browser client such as curl, which cannot be driven
// cross-site, so it is allowed.
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
}
// rejectCrossSite writes a 403 and returns true when r is a cross-site request.
// State-changing handlers call it first: `if s.rejectCrossSite(w, r) { return }`.
func (s *Server) rejectCrossSite(w http.ResponseWriter, r *http.Request) bool {
if s.sameOrigin(r) {
return false
}
http.Error(w, "cross-site request refused", http.StatusForbidden)
return true
}
func (s *Server) Listen() error {
mux := http.NewServeMux()
mux.HandleFunc("GET /upload", s.guard(s.handleUploadPage))
mux.HandleFunc("POST /upload", s.guard(s.handleUpload))
// Edit/delete UI and its API. All auth-gated like /upload; destructive
// endpoints (delete) move posts to content/.trash/ rather than erasing.
mux.HandleFunc("GET /manage", s.guard(s.handleManagePage))
mux.HandleFunc("GET /api/photos", s.guard(s.handleListPhotos))
mux.HandleFunc("POST /api/edit", s.guard(s.handleEdit))
mux.HandleFunc("POST /api/delete", s.guard(s.handleDelete))
mux.HandleFunc("POST /api/tags", s.guard(s.handleBulkTags))
mux.HandleFunc("GET /api/trash", s.guard(s.handleListTrash))
mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore))
mux.HandleFunc("POST /api/trash/empty", s.guard(s.handleEmptyTrash))
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})