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:
1 parent
434d2437bf
commit
a7f2afc3ad
9 files changed
+1453
-7
No files matched your search
@@ -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")
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user